[MIG] hr_luncheon_voucher: migrate to 18.0

This commit is contained in:
Stéphan Sainléger
2026-05-30 00:34:22 +02:00
parent e4cc346dfe
commit cc719291eb
24 changed files with 1857 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
# HR Luncheon Voucher
[![Beta](https://img.shields.io/badge/maturity-Beta-yellow.png)](https://odoo-community.org/page/development-status)
[![License: AGPL-3](https://img.shields.io/badge/licence-AGPL--3-blue.png)](http://www.gnu.org/licenses/agpl-3.0-standalone.html)
[![elabore/hr-tools](https://img.shields.io/badge/github-elabore--coop%2Fhr--tools-lightgray.png?logo=github)](https://github.com/elabore-coop/hr-tools)
Manage luncheon vouchers credit and distribution for employees.
## Description
This module allows the management of luncheon vouchers attribution and
distribution. Employees can indicate which days are not concerned by luncheon
vouchers. HR managers can adjust the number of luncheon vouchers to distribute
and follow each employee's credit.
### Key Models
- **Luncheon Voucher Allocation** (`hr.lv.allocation`): tracks each distribution
campaign per employee, with a state workflow (Draft → Confirmed → Distributed)
and computed counters (acquired, due, distributed, balance).
- **Employee extensions** (`hr.employee`): additional fields store running totals
of acquired, distributed, and remaining vouchers, plus a default monthly
distribution value.
- **Meeting type extension** (`calendar.event.type`): a boolean field
`remove_luncheon_voucher` lets you mark event categories that cancel the daily
voucher (e.g. off-site meetings, free lunch).
### Attribution Rules
A luncheon voucher is acquired for a working day if:
- The employee worked on one or all the attendances of the day (depending on
whether the option **Half working days cancel luncheon vouchers** is enabled).
- There is no meeting that cancels the voucher during that day (e.g. off-site
or free lunch meetings).
- An attendance is considered worked as long as there is no leave covering the
whole attendance time slot.
The calculation is fully automated when an allocation request is created: the
system iterates through each day of the period, checks the working calendar,
validates attendance coverage, verifies leaves, and cross-references calendar
events.
### Allocation Lifecycle
| State | Description |
|-------|-------------|
| **Draft** | Allocation request created by the wizard. HR can adjust the number of vouchers to distribute. |
| **Confirmed** | HR manager validates the figures. Employee counters are updated. |
| **Distributed** | Vouchers have been effectively handed out. Final counters are set. |
### Employee Dashboard
In the employee form view, three stat buttons display the current running
totals: Acquired, Distributed, and Remaining (Due) vouchers. HR can also
refresh these values on demand.
## Installation
Use Odoo normal module installation procedure to install `hr_luncheon_voucher`.
### Dependencies
- `base` (Odoo core)
- `calendar` (Odoo core)
- `hr` (Odoo core)
- `hr_holidays` (Odoo core)
- `resource` (Odoo core)
- `hr_effective_attendance_period` (this repository)
### Security / Access Rights
| Model | HR Users | HR Managers |
|-------|----------|-------------|
| `hr.lv.allocation` | Read only | Full CRUD |
| `generate.lv.allocation.requests` | Read only | Full CRUD |
## Configuration
1. **Define cancelling meeting categories.**
Go to *Configuration > Technical > Calendar > Meeting Types* and enable the
*Remove luncheon voucher* flag on the relevant categories (e.g. Off-site,
Free lunch). Two default categories are pre-loaded by the module.
2. **Toggle the half-day rule.**
Go to *Configuration > General Settings > Employees* and check/uncheck
*Half working days cancel luncheon vouchers* depending on whether partial
attendance should still grant a voucher.
3. **Set default monthly distribution per employee.**
Open each employee's form, go to the *Luncheon Vouchers* section under HR
settings, and enter the *Default monthly distribution* value. This value is
pre-filled when a new allocation is created.
4. **Configure effective attendance periods.**
Go to *Configuration > Technical > Resource > Working Times* and create
Working Time entries for each attendance combination used in your company.
Make sure to indicate which periods are effectively attended — the module
relies on `hr_effective_attendance_period` to determine daily presence.
## Usage
### Calendar Events
When creating a calendar event that cancels luncheon voucher distribution
(off-site meeting, free lunch, etc.), simply assign the corresponding meeting
category to the event.
### Generating Allocations
For each distribution period, the HR manager should:
1. Go to the **Employees** list view.
2. Select all employees concerned by luncheon vouchers distribution.
3. Click the header button **Generate Luncheon Vouchers Allocations**.
4. Fill in the wizard: set a campaign name (e.g. "January 2026"), a start
date, and an end date.
5. Click **Create allocations requests**.
A voucher allocation request is created for each selected employee in **Draft**
state. The number of acquired vouchers is computed automatically based on the
attribution rules.
### Allocation Workflow
1. **Confirm** an allocation request when the figures are verified. Employee
running counters are updated.
2. **Distribute** the allocation when the vouchers have been effectively handed
out. Click *Distribute Vouchers* to mark the request as distributed.
3. **Adjust distribution** at any time in Draft state to override the
pre-filled distributed quantity.
4. **Back to draft** allows correcting an already confirmed or distributed
allocation.
5. The **Vouchers balance** column shows the difference between due and
distributed vouchers for each allocation.
### Monitoring
Each employee form displays three stat buttons — *Acquired*, *Distributed*,
and *Due* — giving a real-time summary of the employee's luncheon voucher
status. Use the **Refresh Luncheon Vouchers** button in the form header to
recompute these values if needed.
## Known Issues / Roadmap
None yet.
## Bug Tracker
Bugs are tracked on [Gitea Issues](https://git.elabore.coop/Elabore/hr-tools/issues).
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smash it by providing a detailed and welcomed
feedback.
## Credits
### Contributors
- Stéphan Sainléger <https://github.com/stephansainleger>
### Funders
The development of this module has been financially supported by:
- Elabore (https://elabore.coop)
- Amaco (https://amaco.org)
### Maintainer
This module is maintained by Elabore.

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizard

View File

@@ -0,0 +1,30 @@
{
"name": "HR Luncheon Voucher",
"category": "Human Resources",
"version": "18.0.1.0.0",
"summary": "Manage luncheon vouchers credit and distribution",
"author": "Elabore",
"website": "https://git.elabore.coop/elabore/hr-tools",
"license": "AGPL-3",
"installable": True,
"application": True,
"auto_install": False,
"depends": [
"base",
"calendar",
"hr",
"hr_effective_attendance_period",
"hr_holidays",
"resource",
],
"data": [
"security/ir.model.access.csv",
"views/event_type.xml",
"views/hr_employee_views.xml",
"views/hr_lv_allocation_views.xml",
"views/res_config_settings_views.xml",
"views/menus.xml",
"wizard/generate_lv_allocations_wizard.xml",
"data/event_type_data.xml",
],
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" ?>
<odoo noupdate="1">
<record id="categ_meet_free_lunch" model="calendar.event.type">
<field name="name">Free lunch</field>
<field name="ref">categ_meet_free_lunch</field>
<field name="remove_luncheon_voucher">1</field>
</record>
<record id="categ_meet_offsite" model="calendar.event.type">
<field name="name">Off-site</field>
<field name="ref">categ_meet_offsite</field>
<field name="remove_luncheon_voucher">1</field>
</record>
</odoo>

View File

@@ -0,0 +1,347 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_luncheon_voucher
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-04-28 12:49+0000\n"
"PO-Revision-Date: 2023-04-28 12:49+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.view_employee_form_lv
msgid "Acquired"
msgstr "Acquis"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__number_acquired_lv
msgid "Acquired Vouchers"
msgstr "Tickets Acquis"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_form
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_tree
msgid "Adjust distribution"
msgstr "Ajuster la distribution"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_form
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_tree
msgid "Back to draft"
msgstr "Brouillon"
#. module: hr_luncheon_voucher
#: model:ir.model,name:hr_luncheon_voucher.model_hr_employee_base
msgid "Basic Employee"
msgstr "Employé basique"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.lv_allocations_requests_wizard
msgid "Cancel"
msgstr "Annuler"
#. module: hr_luncheon_voucher
#: model:ir.model,name:hr_luncheon_voucher.model_res_company
msgid "Companies"
msgstr "Sociétés"
#. module: hr_luncheon_voucher
#: model:ir.model,name:hr_luncheon_voucher.model_res_config_settings
msgid "Config Settings"
msgstr "Paramètres de config"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_form
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_tree
msgid "Confirm"
msgstr "Confirmer"
#. module: hr_luncheon_voucher
#: model:ir.model.fields.selection,name:hr_luncheon_voucher.selection__hr_lv_allocation__state__confirmed
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_search
msgid "Confirmed"
msgstr "Confirmé"
#. module: hr_luncheon_voucher
#: model:ir.actions.act_window,name:hr_luncheon_voucher.lv_allocations_requests_wizard_action
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.lv_allocations_requests_wizard
msgid "Create Luncheon Vouchers allocations requests"
msgstr "Créer demandes d'allocation de Tickets Restaurants"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.lv_allocations_requests_wizard
msgid "Create allocations requests"
msgstr "Créer les demandes d'allocation"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__create_uid
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__create_uid
msgid "Created by"
msgstr "Créé par"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__create_date
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__create_date
msgid "Created on"
msgstr "Créé le"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee__default_monthly_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base__default_monthly_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_public__default_monthly_lv
msgid "Default monthly distribution"
msgstr ""
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_calendar_event_type__display_name
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__display_name
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base__display_name
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__display_name
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_company__display_name
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_config_settings__display_name
msgid "Display Name"
msgstr "Nom affiché"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_form
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_tree
msgid "Distribute Vouchers"
msgstr "Tickets distribués"
#. module: hr_luncheon_voucher
#: model:ir.model.fields.selection,name:hr_luncheon_voucher.selection__hr_lv_allocation__state__distributed
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_search
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.view_employee_form_lv
msgid "Distributed"
msgstr "Distribué"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__number_distributed_lv
msgid "Distributed Vouchers"
msgstr "Tickets distribués"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee__distributed_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base__distributed_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_public__distributed_lv
msgid "Distributed luncheon vouchers"
msgstr "Tickets resto distribués"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__distrib_campaign_name
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__distrib_campaign_name
msgid "Distribution campaign"
msgstr "Campagne de distribution"
#. module: hr_luncheon_voucher
#: model:ir.model.fields.selection,name:hr_luncheon_voucher.selection__hr_lv_allocation__state__draft
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_search
msgid "Draft"
msgstr "Brouillon"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.view_employee_form_lv
msgid "Dued"
msgstr "Dû"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__number_dued_lv
msgid "Dued Vouchers"
msgstr "Tickets dûs"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__employee_id
msgid "Employee"
msgstr "Employé"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.res_config_settings_lv_form
msgid "Employee Luncheon Vouchers"
msgstr "Tickets Restaurant de l'employé"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__date_to
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__date_to
msgid "End Date"
msgstr "Date de fin"
#. module: hr_luncheon_voucher
#: model:ir.model,name:hr_luncheon_voucher.model_calendar_event_type
msgid "Event Meeting Type"
msgstr "Type d'événement réunion"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.view_employee_tree_lv
msgid "Generate Luncheon Vouchers Allocations"
msgstr "Générer les allocations de Tickets Restaurants"
#. module: hr_luncheon_voucher
#: model:ir.model,name:hr_luncheon_voucher.model_generate_lv_allocation_requests
msgid "Generate Luncheon Vouchers Allocations Requests"
msgstr "Générer les demandes d'allocations de Tickets Restaurants"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_company__hr_half_day_cancels_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_config_settings__hr_half_day_cancels_voucher
msgid "Half working days cancel luncheon vouchers"
msgstr "Une demi-journée travaillée annule les tickets restaurants."
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_calendar_event_type__id
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__id
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base__id
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__id
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_company__id
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_config_settings__id
msgid "ID"
msgstr ""
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_calendar_event_type____last_update
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests____last_update
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base____last_update
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation____last_update
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_company____last_update
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_res_config_settings____last_update
msgid "Last Modified on"
msgstr "Dernière modification le"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__write_uid
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__write_uid
msgid "Last Updated by"
msgstr "Dernière mise à jour par"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__write_date
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__write_date
msgid "Last Updated on"
msgstr "Dernière mise à jour le"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.view_employee_form_lv
msgid "Luncheon Vouchers"
msgstr "Tickets Restaurants"
#. module: hr_luncheon_voucher
#: model:ir.model,name:hr_luncheon_voucher.model_hr_lv_allocation
msgid "Luncheon Vouchers Allocation"
msgstr "Allocation de Tickets Restaurants"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.res_config_settings_lv_form
msgid "Luncheon Vouchers Half-day Cancel"
msgstr "Annulation Tickets Restaurants sur une demi-journée"
#. module: hr_luncheon_voucher
#: model:ir.ui.menu,name:hr_luncheon_voucher.menu_hr_lv_allocations
msgid "Luncheon vouchers"
msgstr "Ticket restaurants"
#. module: hr_luncheon_voucher
#: model:ir.actions.act_window,name:hr_luncheon_voucher.act_lv_allocations
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_search
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_tree
msgid "Luncheon vouchers allocations"
msgstr "Allocations de Tickets restaurant"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_form
msgid "Luncheon vouchers calculation"
msgstr "Calculs des tickets restaurants"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee__lv_allocations_ids
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base__lv_allocations_ids
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_public__lv_allocations_ids
msgid "Lv Allocations"
msgstr "Allocations TR"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__name
msgid "Name"
msgstr "Nom"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.lv_allocations_requests_wizard
msgid "Period to consider"
msgstr "Période du calcul"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_calendar_event_type__ref
msgid "Reference"
msgstr "Référence"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.view_employee_form_lv
msgid "Refresh Luncheon Vouchers"
msgstr "Rafraîchir les Tickets Restaurants"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee__dued_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base__dued_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_public__dued_lv
msgid "Remaining luncheon vouchers"
msgstr ""
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_calendar_event_type__remove_luncheon_voucher
msgid "Remove luncheon voucher"
msgstr "Supprimer le ticket"
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.hr_lv_allocation_form
msgid "Request context"
msgstr "Contexte de la requête"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_generate_lv_allocation_requests__date_from
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__date_from
msgid "Start Date"
msgstr "Date de début"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__state
msgid "Status"
msgstr "État"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,help:hr_luncheon_voucher.field_hr_lv_allocation__state
msgid ""
"The status is set to 'Draft', when an allocation request is created. The "
"status is 'Confirmed', when an allocation request is confirmed by HR "
"manager. The status is 'Distributed', when the luncheon vouchers have been "
"distributed."
msgstr ""
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee__total_acquired_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_base__total_acquired_lv
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_employee_public__total_acquired_lv
msgid "Total allocated luncheon vouchers"
msgstr ""
#. module: hr_luncheon_voucher
#: model_terms:ir.ui.view,arch_db:hr_luncheon_voucher.res_config_settings_lv_form
msgid ""
"Voucher is acquired only if the employee worked during all his\n"
" attendance."
msgstr ""
#. module: hr_luncheon_voucher
#: model:ir.model.fields,help:hr_luncheon_voucher.field_hr_lv_allocation__lv_balance
msgid ""
"Vouchers available after distribution. Dued vouchers - Distributed vouchers"
msgstr "Tickets disponibles après distribution. = Tickets dûs - tickets distribués"
#. module: hr_luncheon_voucher
#: model:ir.model.fields,field_description:hr_luncheon_voucher.field_hr_lv_allocation__lv_balance
msgid "Vouchers balance"
msgstr "Solde TR"

View File

@@ -0,0 +1,5 @@
from . import calendar_event_type
from . import hr_employee
from . import hr_lv_allocation
from . import res_company
from . import res_config_settings

View File

@@ -0,0 +1,16 @@
from odoo import fields, models
class MeetingType(models.Model):
_inherit = "calendar.event.type"
ref = fields.Char(
string="Reference",
copy=False,
store=True,
)
remove_luncheon_voucher = fields.Boolean(
string="Remove luncheon voucher",
copy=True,
store=True,
)

View File

@@ -0,0 +1,88 @@
from odoo import fields, models
class HrEmployeeBase(models.AbstractModel):
_inherit = "hr.employee.base"
lv_allocations_ids = fields.One2many("hr.lv.allocation", "employee_id")
total_acquired_lv = fields.Integer(
string="Total allocated luncheon vouchers", store=True, copy=False
)
distributed_lv = fields.Integer(
string="Distributed luncheon vouchers", store=True, copy=False
)
dued_lv = fields.Integer(
string="Remaining luncheon vouchers", store=True, copy=False
)
default_monthly_lv = fields.Integer(
string="Default monthly distribution", store=True, copy=True
)
def refresh_lv_values(self):
for record in self:
record._compute_total_acquired_lv()
record._compute_distributed_lv()
record._compute_dued_lv()
def _compute_total_acquired_lv(self):
for record in self:
allocations = self.env["hr.lv.allocation"].search(
[
("employee_id", "=", record.id),
("state", "in", ["confirmed", "distributed"]),
]
)
record.total_acquired_lv = sum(allocations.mapped("number_acquired_lv"))
def _compute_distributed_lv(self):
for record in self:
allocations = self.env["hr.lv.allocation"].search(
[("employee_id", "=", record.id), ("state", "=", "distributed")]
)
record.distributed_lv = sum(allocations.mapped("number_distributed_lv"))
def _compute_dued_lv(self):
for record in self:
record.dued_lv = record.total_acquired_lv - record.distributed_lv
def generate_mass_lv_allocation(self, values):
for record in self:
record.generate_lv_allocation(values)
def generate_lv_allocation(self, values):
self.ensure_one()
values["employee_id"] = self.id
values["name"] = values["distrib_campaign_name"] + " - " + self.name
self.env["hr.lv.allocation"].create(values)
def action_lv_allocations(self):
action = self.env["ir.actions.act_window"]._for_xml_id(
"hr_luncheon_voucher.act_lv_allocations"
)
action["context"] = {
"search_default_employee_id": self.id,
"default_employee_id": self.id,
}
action["domain"] = [("employee_id", "=", self.id)]
return action
def action_lv_allocations_requests_wizard(self):
action = self.env["ir.actions.act_window"]._for_xml_id(
"hr_luncheon_voucher.lv_allocations_requests_wizard_action"
)
ctx = dict(self.env.context)
ctx["active_ids"] = self.ids
action["context"] = ctx
return action
def _search_display_name(self, operator, value):
"""
On lv allocation, allow employee search on all companies.
"""
if self.env.context.get("search_all_campanies"):
return super(HrEmployeeBase, self.sudo())._search_display_name(
operator, value
)
return super()._search_display_name(operator, value)

View File

@@ -0,0 +1,216 @@
from datetime import datetime, time, timedelta
from dateutil.rrule import DAILY, rrule
from pytz import UTC
from odoo import api, fields, models
class LuncheonVouchersAllocation(models.Model):
_name = "hr.lv.allocation"
_description = "Luncheon Vouchers Allocation"
name = fields.Char()
distrib_campaign_name = fields.Char("Distribution campaign")
state = fields.Selection(
[
("draft", "Draft"),
("confirmed", "Confirmed"),
("distributed", "Distributed"),
],
string="Status",
readonly=True,
copy=False,
default="draft",
help=(
"The status is set to 'Draft', when an allocation request is created. "
"The status is 'Confirmed', when an allocation request is confirmed by "
"HR manager. The status is 'Distributed', when the luncheon vouchers "
"have been distributed."
),
)
date_from = fields.Datetime(
string="Start Date",
store=True,
readonly=False,
copy=False,
states={
"confirmed": [("readonly", True)],
"distributed": [("readonly", True)],
},
)
date_to = fields.Datetime(
string="End Date",
store=True,
readonly=False,
copy=False,
states={
"confirmed": [("readonly", True)],
"distributed": [("readonly", True)],
},
)
employee_id = fields.Many2one(
"hr.employee",
store=True,
string="Employee",
index=True,
readonly=False,
ondelete="restrict",
states={
"confirmed": [("readonly", True)],
"distributed": [("readonly", True)],
},
)
number_acquired_lv = fields.Integer(
string="Acquired Vouchers",
store=True,
readonly=False,
states={
"confirmed": [("readonly", True)],
"distributed": [("readonly", True)],
},
)
number_dued_lv = fields.Integer(
string="Dued Vouchers",
store=True,
readonly=False,
states={
"confirmed": [("readonly", True)],
"distributed": [("readonly", True)],
},
)
number_distributed_lv = fields.Integer(
string="Distributed Vouchers",
store=True,
readonly=False,
states={
"confirmed": [("readonly", False)],
"distributed": [("readonly", True)],
},
)
lv_balance = fields.Integer(
"Vouchers balance",
compute="_compute_lv_balance",
help="Vouchers available after distribution. Dued vouchers - Distributed.",
)
@api.depends("number_dued_lv", "number_distributed_lv")
def _compute_lv_balance(self):
for allocation in self:
allocation.lv_balance = (
allocation.number_dued_lv - allocation.number_distributed_lv
)
@api.model_create_multi
def create(self, values):
res = super().create(values)
res._calculate_number_acquired_lv()
res._calculate_number_dued_lv()
res._default_number_distributed_lv()
return res
@api.depends("employee_id")
def _default_number_distributed_lv(self):
for record in self:
record.number_distributed_lv = record.employee_id.default_monthly_lv
def _has_cancelling_voucher_event(self, day):
category_no_voucher_ids = self.env["calendar.event.type"].search(
[("remove_luncheon_voucher", "=", True)]
)
events = self.env["calendar.event"].search(
[
("categ_ids", "in", category_no_voucher_ids.ids),
("partner_ids", "in", self.employee_id.user_id.partner_id.id),
]
)
day_start = fields.Datetime.to_datetime(day.date())
day_end = fields.Datetime.to_datetime(day.date()) + timedelta(hours=24)
cancelling_events = events.filtered(
lambda x: not ((x.start < day_start) and (x.stop <= day_start))
and not ((x.start >= day_end) and (x.stop > day_end))
)
if len(cancelling_events) > 0:
return True
else:
return False
def _calculate_number_acquired_lv(self):
nb_eligible_days = 0
dfrom = datetime.combine(
fields.Date.from_string(self.date_from), time.min
).replace(tzinfo=UTC)
dto = datetime.combine(fields.Date.from_string(self.date_to), time.max).replace(
tzinfo=UTC
)
period_days = rrule(DAILY, dfrom, until=dto)
calendar_resource = self.employee_id.resource_calendar_id
for day in period_days:
# Check if this days is a working day
if not calendar_resource.is_working_day(day):
continue
# The employee should work this day but...
if (
self.env.company.hr_half_day_cancels_voucher
and not calendar_resource.is_full_working_day(day)
):
# Voucher requires full-day attendance
continue
# Check leaves
if not calendar_resource.is_worked_day(self.employee_id, day):
continue
# The employee has worked this day but...
if (
self.env.company.hr_half_day_cancels_voucher
and not calendar_resource.all_attendances_worked(
self.employee_id.resource_id, day
)
):
# Voucher requires all attendances to be worked
continue
# Check there is no event cancelling the voucher
if self._has_cancelling_voucher_event(day):
continue
# All checks passed, the days is eligible for a voucher
nb_eligible_days += 1
self.number_acquired_lv = nb_eligible_days
def _calculate_number_dued_lv(self):
for record in self:
if record.state == "distributed":
record.number_dued_lv = record.employee_id.dued_lv
else:
record.number_dued_lv = (
record.employee_id.dued_lv + record.number_acquired_lv
)
def confirm_allocation(self):
for record in self:
if record.state == "draft":
record.state = "confirmed"
record.employee_id._compute_total_acquired_lv()
record.employee_id._compute_dued_lv()
def back_to_draft(self):
for record in self:
if record.state in ["confirmed", "distributed"]:
record.state = "draft"
record.employee_id._compute_total_acquired_lv()
record.employee_id._compute_distributed_lv()
record.employee_id._compute_dued_lv()
def distribute_allocation(self):
for record in self:
if record.state == "confirmed":
record.state = "distributed"
record.employee_id._compute_distributed_lv()
record.employee_id._compute_dued_lv()
def adjust_distribution(self):
for record in self:
for record in self:
if record.state == "draft":
record.number_distributed_lv = (
record.employee_id.dued_lv + record.number_acquired_lv
)

View File

@@ -0,0 +1,9 @@
from odoo import fields, models
class Company(models.Model):
_inherit = "res.company"
hr_half_day_cancels_voucher = fields.Boolean(
string="Half working days cancel luncheon vouchers"
)

View File

@@ -0,0 +1,11 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
hr_half_day_cancels_voucher = fields.Boolean(
string="Half working days cancel luncheon vouchers",
related="company_id.hr_half_day_cancels_voucher",
readonly=False,
)

View File

@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_lv_allocation_user,access_lv_allocation_user,model_hr_lv_allocation,hr.group_hr_user,1,0,0,0
access_lv_allocation_manager,access_lv_allocation_manager,model_hr_lv_allocation,hr.group_hr_manager,1,1,1,1
access_lv_allocation_wizard_user,access_lv_allocation_wizard_user,model_generate_lv_allocation_requests,hr.group_hr_user,1,0,0,0
access_lv_allocation_wizard_manager,access_lv_allocation_wizard_manager,model_generate_lv_allocation_requests,hr.group_hr_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_lv_allocation_user access_lv_allocation_user model_hr_lv_allocation hr.group_hr_user 1 0 0 0
3 access_lv_allocation_manager access_lv_allocation_manager model_hr_lv_allocation hr.group_hr_manager 1 1 1 1
4 access_lv_allocation_wizard_user access_lv_allocation_wizard_user model_generate_lv_allocation_requests hr.group_hr_user 1 0 0 0
5 access_lv_allocation_wizard_manager access_lv_allocation_wizard_manager model_generate_lv_allocation_requests hr.group_hr_manager 1 1 1 1

View File

@@ -0,0 +1,3 @@
from . import test_hr_employee
from . import test_hr_lv_allocation
from . import test_generate_lv_allocations_wizard

View File

@@ -0,0 +1,112 @@
from odoo.tests.common import TransactionCase
class TestGenerateLvAllocationsWizard(TransactionCase):
"""Tests for generate.lv.allocation.requests wizard."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.employee_admin = cls.env.ref("hr.employee_admin")
cls.employee_other = cls.env["hr.employee"].create(
{
"name": "Other Employee",
"resource_calendar_id": cls.employee_admin.resource_calendar_id.id,
}
)
# ------------------------------------------------------------------
# generate_lv_allocations
# ------------------------------------------------------------------
def test_generate_for_single_employee(self):
"""Wizard creates one allocation for the selected employee."""
wizard = self._create_wizard()
# Simulate single employee selection
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
allocations = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(len(allocations), 1)
self.assertEqual(allocations.employee_id, self.employee_admin)
def test_generate_for_multiple_employees(self):
"""Wizard creates one allocation per selected employee."""
wizard = self._create_wizard()
ctx = {
"active_ids": [
self.employee_admin.id,
self.employee_other.id,
]
}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
allocations = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(len(allocations), 2)
def test_generate_allocation_name_format(self):
"""Allocation name follows 'campaign - employee' pattern."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
expected_name = f"March 2026 - {self.employee_admin.name}"
self.assertEqual(alloc.name, expected_name)
def test_generate_allocations_have_dates(self):
"""Allocations carry the wizard's date range."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(alloc.date_from, wizard.date_from)
self.assertEqual(alloc.date_to, wizard.date_to)
def test_generate_allocations_start_in_draft(self):
"""New allocations start in 'draft' state."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
wizard.generate_lv_allocations()
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "March 2026")]
)
self.assertEqual(alloc.state, "draft")
def test_generate_returns_action(self):
"""The wizard returns an action dict (act_window)."""
wizard = self._create_wizard()
ctx = {"active_ids": [self.employee_admin.id]}
wizard = wizard.with_context(**ctx)
action = wizard.generate_lv_allocations()
self.assertIn("type", action)
self.assertEqual(action["type"], "ir.actions.act_window")
self.assertEqual(action["res_model"], "hr.lv.allocation")
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _create_wizard(self):
return self.env["generate.lv.allocation.requests"].create(
{
"distrib_campaign_name": "March 2026",
"date_from": "2026-03-01",
"date_to": "2026-03-31",
}
)

View File

@@ -0,0 +1,169 @@
from odoo.tests.common import TransactionCase
class TestHrEmployee(TransactionCase):
"""Tests for hr.employee extensions (_compute*, generate_lv_allocation)."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Use a demo employee (created by hr module)
cls.employee = cls.env.ref("hr.employee_admin")
# Ensure a clean slate
cls.employee.write(
{
"total_acquired_lv": 0,
"distributed_lv": 0,
"dued_lv": 0,
}
)
# ------------------------------------------------------------------
# _compute_total_acquired_lv
# ------------------------------------------------------------------
def test_compute_total_acquired_lv_no_allocations(self):
"""No allocations → total_acquired_lv is 0."""
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 0)
def test_compute_total_acquired_lv_with_allocations(self):
"""Confirmed + distributed allocations are summed."""
self._create_allocation(state="confirmed", number_acquired_lv=5)
self._create_allocation(state="distributed", number_acquired_lv=3)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 8)
def test_compute_total_acquired_lv_excludes_draft(self):
"""Draft allocations are NOT counted."""
self._create_allocation(state="draft", number_acquired_lv=10)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 0)
# ------------------------------------------------------------------
# _compute_distributed_lv
# ------------------------------------------------------------------
def test_compute_distributed_lv_no_allocations(self):
"""No allocations → distributed_lv is 0."""
self.employee.refresh_lv_values()
self.assertEqual(self.employee.distributed_lv, 0)
def test_compute_distributed_lv_only_distributed(self):
"""Only 'distributed' state allocations are counted."""
self._create_allocation(state="distributed", number_distributed_lv=4)
self._create_allocation(state="confirmed", number_distributed_lv=2)
self._create_allocation(state="draft", number_distributed_lv=1)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.distributed_lv, 4)
def test_compute_distributed_lv_multiple_distributed(self):
"""Multiple distributed allocations are summed."""
self._create_allocation(state="distributed", number_distributed_lv=3)
self._create_allocation(state="distributed", number_distributed_lv=7)
self.employee.refresh_lv_values()
self.assertEqual(self.employee.distributed_lv, 10)
# ------------------------------------------------------------------
# _compute_dued_lv
# ------------------------------------------------------------------
def test_compute_dued_lv(self):
"""dued_lv = total_acquired_lv - distributed_lv."""
self.employee.write(
{"total_acquired_lv": 20, "distributed_lv": 6}
)
self.employee._compute_dued_lv()
self.assertEqual(self.employee.dued_lv, 14)
def test_compute_dued_lv_zero_when_equal(self):
"""dued_lv is 0 when acquired equals distributed."""
self.employee.write(
{"total_acquired_lv": 10, "distributed_lv": 10}
)
self.employee._compute_dued_lv()
self.assertEqual(self.employee.dued_lv, 0)
# ------------------------------------------------------------------
# generate_lv_allocation
# ------------------------------------------------------------------
def test_generate_lv_allocation_creates_record(self):
"""generate_lv_allocation creates an hr.lv.allocation with correct values."""
campaign_name = "Test Campaign"
values = {
"distrib_campaign_name": campaign_name,
"date_from": "2026-01-01",
"date_to": "2026-01-31",
}
self.employee.generate_lv_allocation(values)
alloc = self.env["hr.lv.allocation"].search(
[
("employee_id", "=", self.employee.id),
("distrib_campaign_name", "=", campaign_name),
]
)
self.assertTrue(alloc)
self.assertEqual(len(alloc), 1)
self.assertEqual(alloc.distrib_campaign_name, campaign_name)
self.assertEqual(alloc.employee_id, self.employee)
self.assertIn(self.employee.name, alloc.name)
def test_generate_lv_allocation_sets_employee_id(self):
"""The passed employee_id in values is overridden by the record's own id."""
other = self.env["hr.employee"].create(
{
"name": "Other Employee",
"resource_calendar_id": self.employee.resource_calendar_id.id,
}
)
values = {
"distrib_campaign_name": "Campaign Override",
"employee_id": other.id,
"date_from": "2026-02-01",
"date_to": "2026-02-28",
}
self.employee.generate_lv_allocation(values)
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "Campaign Override")]
)
self.assertEqual(alloc.employee_id, self.employee)
def test_generate_lv_allocation_updates_counters(self):
"""After creation, acquired and due are computed (triggers create logic)."""
values = {
"distrib_campaign_name": "Counters Check",
"date_from": "2026-03-01",
"date_to": "2026-03-31",
}
self.employee.generate_lv_allocation(values)
alloc = self.env["hr.lv.allocation"].search(
[("distrib_campaign_name", "=", "Counters Check")]
)
# number_acquired_lv should be computed (may be 0 on non-working days)
self.assertIsNotNone(alloc.number_acquired_lv)
self.assertIsNotNone(alloc.number_dued_lv)
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _create_allocation(self, **kwargs):
# Extract numeric fields that create() overrides, to set them after creation
numeric_fields = {
k: kwargs.pop(k)
for k in ["number_acquired_lv", "number_dued_lv", "number_distributed_lv"]
if k in kwargs
}
defaults = {
"employee_id": self.employee.id,
"distrib_campaign_name": "Test",
"date_from": "2026-01-01",
"date_to": "2026-01-31",
"name": "Test - Employee",
}
defaults.update(kwargs)
alloc = self.env["hr.lv.allocation"].create(defaults)
if numeric_fields:
alloc.write(numeric_fields)
return alloc

View File

@@ -0,0 +1,317 @@
from odoo.tests.common import TransactionCase
class TestHrLvAllocation(TransactionCase):
"""Tests for hr.lv.allocation model methods."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.employee = cls.env.ref("hr.employee_admin")
cls.calendar = cls.env.ref("resource.resource_calendar_std")
cls.employee.resource_calendar_id = cls.calendar
# Ensure the calendar has attendances marked as effective periods
# (required by hr_effective_attendance_period for is_working_day etc.)
Attendance = cls.env["resource.calendar.attendance"]
existing = Attendance.search([("calendar_id", "=", cls.calendar.id)])
if existing:
existing.write({"effective_attendance_period": True})
else:
for day_idx, day_name in enumerate(
["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
):
Attendance.create(
{
"calendar_id": cls.calendar.id,
"name": f"{day_name} Morning",
"dayofweek": str(day_idx),
"day_period": "morning",
"hour_from": 8,
"hour_to": 12,
"effective_attendance_period": True,
}
)
Attendance.create(
{
"calendar_id": cls.calendar.id,
"name": f"{day_name} Afternoon",
"dayofweek": str(day_idx),
"day_period": "afternoon",
"hour_from": 13,
"hour_to": 17,
"effective_attendance_period": True,
}
)
cls.allocation = cls._build_allocation(cls)
# Ensure a cancelling event type exists
cls.cancelling_categ = cls.env.ref(
"hr_luncheon_voucher.categ_meet_free_lunch",
raise_if_not_found=False,
)
if not cls.cancelling_categ:
cls.cancelling_categ = cls.env["calendar.event.type"].create(
{"name": "Free Lunch Test", "remove_luncheon_voucher": True}
)
else:
cls.cancelling_categ.remove_luncheon_voucher = True
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _build_allocation(self, **overrides):
vals = {
"employee_id": self.employee.id,
"distrib_campaign_name": "Test Campaign",
"date_from": "2026-01-01",
"date_to": "2026-01-31",
"name": "Test Campaign - Admin",
}
vals.update(overrides)
return self.env["hr.lv.allocation"].create(vals)
# ------------------------------------------------------------------
# _compute_lv_balance
# ------------------------------------------------------------------
def test_lv_balance_positive(self):
"""Balance = number_dued_lv - number_distributed_lv (positive)."""
alloc = self._build_allocation()
alloc.write(
{"number_dued_lv": 10, "number_distributed_lv": 3}
)
alloc._compute_lv_balance()
self.assertEqual(alloc.lv_balance, 7)
def test_lv_balance_zero(self):
"""Balance is 0 when due equals distributed."""
alloc = self._build_allocation()
alloc.write(
{"number_dued_lv": 5, "number_distributed_lv": 5}
)
alloc._compute_lv_balance()
self.assertEqual(alloc.lv_balance, 0)
def test_lv_balance_negative(self):
"""Balance can be negative (over-distribution)."""
alloc = self._build_allocation()
alloc.write(
{"number_dued_lv": 2, "number_distributed_lv": 5}
)
alloc._compute_lv_balance()
self.assertEqual(alloc.lv_balance, -3)
# ------------------------------------------------------------------
# _has_cancelling_voucher_event
# ------------------------------------------------------------------
def test_has_cancelling_event_true(self):
"""Returns True when a cancelling event exists for the employee."""
partner = self.employee.user_id.partner_id
self.env["calendar.event"].create(
{
"name": "Team Lunch",
"start": "2026-01-15 12:00:00",
"stop": "2026-01-15 14:00:00",
"categ_ids": [(6, 0, self.cancelling_categ.ids)],
"partner_ids": [(4, partner.id)],
}
)
day = self.env.cr.now().replace(day=15, month=1, year=2026)
self.assertTrue(self.allocation._has_cancelling_voucher_event(day))
def test_has_cancelling_event_false_no_event(self):
"""Returns False when no cancelling event exists."""
day = self.env.cr.now().replace(day=10, month=1, year=2026)
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
def test_has_cancelling_event_false_non_cancelling_category(self):
"""Returns False when events exist but without the cancelling flag."""
categ = self.env["calendar.event.type"].create(
{"name": "Regular Meeting", "remove_luncheon_voucher": False}
)
partner = self.employee.user_id.partner_id
self.env["calendar.event"].create(
{
"name": "Stand-up",
"start": "2026-01-15 09:00:00",
"stop": "2026-01-15 09:30:00",
"categ_ids": [(6, 0, categ.ids)],
"partner_ids": [(4, partner.id)],
}
)
day = self.env.cr.now().replace(day=15, month=1, year=2026)
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
def test_has_cancelling_event_outside_day(self):
"""Returns False when the event is entirely outside the target day."""
partner = self.employee.user_id.partner_id
self.env["calendar.event"].create(
{
"name": "Late Dinner",
"start": "2026-01-15 22:00:00",
"stop": "2026-01-16 00:30:00",
"categ_ids": [(6, 0, self.cancelling_categ.ids)],
"partner_ids": [(4, partner.id)],
}
)
# Check a different day
day = self.env.cr.now().replace(day=14, month=1, year=2026)
self.assertFalse(self.allocation._has_cancelling_voucher_event(day))
# ------------------------------------------------------------------
# _calculate_number_acquired_lv
# ------------------------------------------------------------------
def test_calculate_acquired_on_non_working_day(self):
"""Acquired is 0 for a period of non-working days (e.g. weekend-only)."""
alloc = self._build_allocation(
date_from="2026-01-03", # Saturday
date_to="2026-01-04", # Sunday
)
alloc._calculate_number_acquired_lv()
self.assertEqual(alloc.number_acquired_lv, 0)
def test_calculate_acquired_basic(self):
"""Acquired is > 0 for a normal working period (employee works)."""
alloc = self._build_allocation(
date_from="2026-01-05", # Monday
date_to="2026-01-09", # Friday
)
alloc._calculate_number_acquired_lv()
# Standard 5-day calendar: 5 working days, no leaves, no events
self.assertEqual(alloc.number_acquired_lv, 5)
def test_calculate_acquired_with_leave(self):
"""Acquired excludes days covered by leave."""
# Create a leave covering Wednesday 2026-01-07
self.env["resource.calendar.leaves"].create(
{
"name": "Sick leave",
"date_from": "2026-01-07 00:00:00",
"date_to": "2026-01-07 23:59:59",
"calendar_id": self.calendar.id,
"resource_id": self.employee.resource_id.id,
}
)
alloc = self._build_allocation(
date_from="2026-01-05", # Monday
date_to="2026-01-09", # Friday
)
alloc._calculate_number_acquired_lv()
# 5 working days - 1 leave = 4
self.assertEqual(alloc.number_acquired_lv, 4)
def test_calculate_acquired_with_half_day_cancel(self):
"""When hr_half_day_cancels_voucher is enabled, partial days are excluded."""
self.env.company.hr_half_day_cancels_voucher = True
# Modify calendar to have only morning attendance on Wednesday
wed_attendance = self.env["resource.calendar.attendance"].search(
[
("calendar_id", "=", self.calendar.id),
("dayofweek", "=", "2"), # Wednesday
("day_period", "=", "afternoon"),
]
)
wed_attendance.unlink()
alloc = self._build_allocation(
date_from="2026-01-05", # Monday
date_to="2026-01-09", # Friday
)
alloc._calculate_number_acquired_lv()
# 4 full days + 1 half-day excluded = 4
self.assertEqual(alloc.number_acquired_lv, 4)
# ------------------------------------------------------------------
# _calculate_number_dued_lv
# ------------------------------------------------------------------
def test_calculate_dued_draft(self):
"""In non-distributed state: dued = employee.dued_lv + number_acquired_lv."""
self.employee.write({"dued_lv": 5})
alloc = self._build_allocation(state="draft")
alloc.number_acquired_lv = 10
alloc._calculate_number_dued_lv()
self.assertEqual(alloc.number_dued_lv, 15)
def test_calculate_dued_distributed(self):
"""In distributed: dued = employee.dued_lv only (no addition)."""
self.employee.write({"dued_lv": 8})
alloc = self._build_allocation(state="distributed")
alloc.number_acquired_lv = 20
alloc._calculate_number_dued_lv()
self.assertEqual(alloc.number_dued_lv, 8)
# ------------------------------------------------------------------
# confirm_allocation
# ------------------------------------------------------------------
def test_confirm_draft(self):
"""confirm_allocation transitions draft → confirmed and updates counters."""
self.employee.write({"total_acquired_lv": 0, "dued_lv": 0})
alloc = self._build_allocation(state="draft")
alloc.number_acquired_lv = 6
alloc.confirm_allocation()
self.assertEqual(alloc.state, "confirmed")
self.employee.refresh_lv_values()
self.assertEqual(self.employee.total_acquired_lv, 6)
def test_confirm_already_confirmed_does_not_change(self):
"""confirm_allocation on a confirmed record is a no-op."""
alloc = self._build_allocation(state="confirmed")
alloc.confirm_allocation()
self.assertEqual(alloc.state, "confirmed")
# ------------------------------------------------------------------
# back_to_draft
# ------------------------------------------------------------------
def test_back_to_draft_from_confirmed(self):
"""back_to_draft transitions confirmed → draft."""
alloc = self._build_allocation(state="confirmed")
alloc.back_to_draft()
self.assertEqual(alloc.state, "draft")
def test_back_to_draft_from_distributed(self):
"""back_to_draft transitions distributed → draft."""
alloc = self._build_allocation(state="distributed")
alloc.back_to_draft()
self.assertEqual(alloc.state, "draft")
def test_back_to_draft_draft_is_noop(self):
"""back_to_draft on a draft record is a no-op."""
alloc = self._build_allocation(state="draft")
alloc.back_to_draft()
self.assertEqual(alloc.state, "draft")
# ------------------------------------------------------------------
# distribute_allocation
# ------------------------------------------------------------------
def test_distribute_confirmed(self):
"""distribute_allocation transitions confirmed → distributed."""
alloc = self._build_allocation(state="confirmed")
alloc.distribute_allocation()
self.assertEqual(alloc.state, "distributed")
def test_distribute_draft_is_noop(self):
"""distribute_allocation on draft is a no-op."""
alloc = self._build_allocation(state="draft")
alloc.distribute_allocation()
self.assertEqual(alloc.state, "draft")
# ------------------------------------------------------------------
# adjust_distribution
# ------------------------------------------------------------------
def test_adjust_distribution(self):
"""Adjust distribution sets number_distributed_lv = dued + acquired."""
alloc = self._build_allocation(state="draft")
alloc.number_acquired_lv = 5
alloc.employee_id.dued_lv = 3
alloc.adjust_distribution()
self.assertEqual(alloc.number_distributed_lv, 8)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_event_type_luncheonvoucher_tree" model="ir.ui.view">
<field name="name">event.type.luncheonvoucher</field>
<field name="model">calendar.event.type</field>
<field name="inherit_id" ref="calendar.view_calendar_event_type_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="remove_luncheon_voucher" />
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_employee_form_lv" model="ir.ui.view">
<field name="name">hr.employee.form.lv</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form" />
<field name="arch" type="xml">
<header position="inside">
<button
type="object"
name="refresh_lv_values"
string="Refresh Luncheon Vouchers"
class="btn-secundary"
/>
</header>
<xpath expr="//div[@name='button_box']" position="inside">
<button
type="object"
name="action_lv_allocations"
class="oe_stat_button"
icon="fa-ticket"
>
<field
name="total_acquired_lv"
widget="statinfo"
string="Acquired"
/>
</button>
<button
type="object"
name="action_lv_allocations"
class="oe_stat_button"
icon="fa-ticket"
>
<field
name="distributed_lv"
widget="statinfo"
string="Distributed"
/>
</button>
<button
type="object"
name="action_lv_allocations"
class="oe_stat_button"
icon="fa-ticket"
>
<field name="dued_lv" widget="statinfo" string="Dued" />
</button>
</xpath>
<xpath expr="//page[@name='hr_settings']/group" position="inside">
<group name="luncheon_vouchers" string="Luncheon Vouchers">
<field name="default_monthly_lv" />
<field name="lv_allocations_ids" />
</group>
</xpath>
</field>
</record>
<record id="view_employee_tree_lv" model="ir.ui.view">
<field name="name">view_employee_tree_lv</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_tree" />
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<header>
<button
type="object"
name="action_lv_allocations_requests_wizard"
string="Generate Luncheon Vouchers Allocations"
class="btn-primary"
/>
</header>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="hr_lv_allocation_search" model="ir.ui.view">
<field name="name">hr.lv.allocation.search</field>
<field name="model">hr.lv.allocation</field>
<field name="arch" type="xml">
<search string="Luncheon vouchers allocations">
<field name="state" />
<field name="employee_id" context="{'search_all_campanies':True}" />
<field name="distrib_campaign_name" />
<filter
name="draft"
string="Draft"
domain="[('state', '=', 'draft')]"
/>
<filter
name="confirmed"
string="Confirmed"
domain="[('state', '=', 'confirmed')]"
/>
<filter
name="distributed"
string="Distributed"
domain="[('state', '=', 'distributed')]"
/>
</search>
</field>
</record>
<record id="hr_lv_allocation_tree" model="ir.ui.view">
<field name="name">hr.lv.allocation.list</field>
<field name="model">hr.lv.allocation</field>
<field name="arch" type="xml">
<list>
<header>
<button
type="object"
name="confirm_allocation"
string="Confirm"
class="btn-primary"
/>
<button
type="object"
name="distribute_allocation"
string="Distribute Vouchers"
class="btn-primary"
/>
<button
type="object"
name="back_to_draft"
string="Back to draft"
class="btn-secundary"
/>
<button
type="object"
name="adjust_distribution"
string="Adjust distribution"
class="btn-secundary"
/>
</header>
<field name="distrib_campaign_name" />
<field name="employee_id" />
<field name="state" />
<field name="date_from" widget="date" />
<field name="date_to" widget="date" />
<field name="number_acquired_lv" />
<field name="number_dued_lv" />
<field name="number_distributed_lv" />
<field name="lv_balance" />
</list>
</field>
</record>
<record id="hr_lv_allocation_form" model="ir.ui.view">
<field name="name">hr.lv.allocation.form</field>
<field name="model">hr.lv.allocation</field>
<field name="arch" type="xml">
<form string="">
<header>
<button
type="object"
name="confirm_allocation"
string="Confirm"
class="btn-primary"
invisible="state != 'draft'"
/>
<button
type="object"
name="distribute_allocation"
string="Distribute Vouchers"
class="btn-primary"
invisible="state != 'confirmed'"
/>
<button
type="object"
name="back_to_draft"
string="Back to draft"
class="btn-secundary"
invisible="state == 'draft'"
/>
<button
type="object"
name="adjust_distribution"
string="Adjust distribution"
class="btn-secundary"
invisible="state != 'draft'"
/>
</header>
<sheet>
<h1>
<field name="name" readonly="1" />
</h1>
<group string="Request context">
<field name="distrib_campaign_name" />
<field name="employee_id" />
<field name="state" />
<field name="date_from" widget="date" />
<field name="date_to" widget="date" />
</group>
<group string="Luncheon vouchers calculation">
<field name="number_acquired_lv" />
<field name="number_dued_lv" />
<field name="number_distributed_lv" />
</group>
</sheet>
</form>
</field>
</record>
<record id="act_lv_allocations" model="ir.actions.act_window">
<field name="name">Luncheon vouchers allocations</field>
<field name="res_model">hr.lv.allocation</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<menuitem
id="menu_hr_lv_allocations"
action="act_lv_allocations"
parent="hr.menu_hr_employee_payroll"
sequence="1"
name="Luncheon vouchers"
/>
</odoo>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_config_settings_lv_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.lv</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="99" />
<field name="inherit_id" ref="hr.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath
expr="//block[@name='employee_rights_setting_container']/.."
position="inside"
>
<h2>Employee Luncheon Vouchers</h2>
<div class="row mt16 o_settings_container" name="employee_lv_container">
<div
class="col-12 col-lg-6 o_setting_box"
id="employee_lv_halfday_cancel"
title="Luncheon Vouchers Half-day Cancel"
>
<div class="o_setting_left_pane">
<field name="hr_half_day_cancels_voucher" />
</div>
<div class="o_setting_right_pane">
<label for="hr_half_day_cancels_voucher" />
<div class="text-muted" name="hr_presence_options_advanced">
Voucher is acquired only if the employee worked during all his
attendance.
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import generate_lv_allocations_wizard

View File

@@ -0,0 +1,26 @@
from odoo import fields, models
class GenerateLVAllocationRequests(models.TransientModel):
_name = "generate.lv.allocation.requests"
_description = "Generate Luncheon Vouchers Allocations Requests"
distrib_campaign_name = fields.Char("Distribution campaign", required=True)
date_from = fields.Datetime(string="Start Date", required=True)
date_to = fields.Datetime(string="End Date", required=True)
def generate_lv_allocations(self):
values = {}
values["distrib_campaign_name"] = self.distrib_campaign_name
values["date_from"] = self.date_from
values["date_to"] = self.date_to
employees = self.env["hr.employee"].search(
[
("id", "in", self.env.context.get("active_ids")),
]
)
employees.generate_mass_lv_allocation(values)
# Open lv allocation tree view
return self.env["ir.actions.act_window"]._for_xml_id(
"hr_luncheon_voucher.act_lv_allocations"
)

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="lv_allocations_requests_wizard" model="ir.ui.view">
<field name="name">lv.allocations.requests.wizard</field>
<field name="model">generate.lv.allocation.requests</field>
<field name="arch" type="xml">
<form string="Create Luncheon Vouchers allocations requests">
<group name="dates" string="Period to consider">
<group>
<field name="distrib_campaign_name" />
<field name="date_from" widget="date" />
<field name="date_to" widget="date" />
</group>
</group>
<footer>
<button
string="Create allocations requests"
name="generate_lv_allocations"
type="object"
class="btn-primary"
/>
<button
string="Cancel"
class="btn-secondary"
special="cancel"
/>
</footer>
</form>
</field>
</record>
<record
id="lv_allocations_requests_wizard_action"
model="ir.actions.act_window"
>
<field name="name">Create Luncheon Vouchers allocations requests</field>
<field name="res_model">generate.lv.allocation.requests</field>
<field name="view_mode">form</field>
<field name="view_id" ref="lv_allocations_requests_wizard" />
<field name="target">new</field>
</record>
</odoo>