From cc719291ebe7b2c05e5f4a4caec4de1fc4f02a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Sat, 30 May 2026 00:34:22 +0200 Subject: [PATCH] [MIG] hr_luncheon_voucher: migrate to 18.0 --- hr_luncheon_voucher/README.md | 170 +++++++++ hr_luncheon_voucher/__init__.py | 2 + hr_luncheon_voucher/__manifest__.py | 30 ++ hr_luncheon_voucher/data/event_type_data.xml | 14 + hr_luncheon_voucher/i18n/fr.po | 347 ++++++++++++++++++ hr_luncheon_voucher/models/__init__.py | 5 + .../models/calendar_event_type.py | 16 + hr_luncheon_voucher/models/hr_employee.py | 88 +++++ .../models/hr_lv_allocation.py | 216 +++++++++++ hr_luncheon_voucher/models/res_company.py | 9 + .../models/res_config_settings.py | 11 + .../security/ir.model.access.csv | 5 + hr_luncheon_voucher/tests/__init__.py | 3 + .../test_generate_lv_allocations_wizard.py | 112 ++++++ hr_luncheon_voucher/tests/test_hr_employee.py | 169 +++++++++ .../tests/test_hr_lv_allocation.py | 317 ++++++++++++++++ hr_luncheon_voucher/views/event_type.xml | 13 + .../views/hr_employee_views.xml | 78 ++++ .../views/hr_lv_allocation_views.xml | 136 +++++++ hr_luncheon_voucher/views/menus.xml | 12 + .../views/res_config_settings_views.xml | 35 ++ hr_luncheon_voucher/wizard/__init__.py | 1 + .../wizard/generate_lv_allocations_wizard.py | 26 ++ .../wizard/generate_lv_allocations_wizard.xml | 42 +++ 24 files changed, 1857 insertions(+) create mode 100644 hr_luncheon_voucher/README.md create mode 100644 hr_luncheon_voucher/__init__.py create mode 100644 hr_luncheon_voucher/__manifest__.py create mode 100644 hr_luncheon_voucher/data/event_type_data.xml create mode 100644 hr_luncheon_voucher/i18n/fr.po create mode 100644 hr_luncheon_voucher/models/__init__.py create mode 100644 hr_luncheon_voucher/models/calendar_event_type.py create mode 100644 hr_luncheon_voucher/models/hr_employee.py create mode 100644 hr_luncheon_voucher/models/hr_lv_allocation.py create mode 100644 hr_luncheon_voucher/models/res_company.py create mode 100644 hr_luncheon_voucher/models/res_config_settings.py create mode 100644 hr_luncheon_voucher/security/ir.model.access.csv create mode 100644 hr_luncheon_voucher/tests/__init__.py create mode 100644 hr_luncheon_voucher/tests/test_generate_lv_allocations_wizard.py create mode 100644 hr_luncheon_voucher/tests/test_hr_employee.py create mode 100644 hr_luncheon_voucher/tests/test_hr_lv_allocation.py create mode 100644 hr_luncheon_voucher/views/event_type.xml create mode 100644 hr_luncheon_voucher/views/hr_employee_views.xml create mode 100644 hr_luncheon_voucher/views/hr_lv_allocation_views.xml create mode 100644 hr_luncheon_voucher/views/menus.xml create mode 100644 hr_luncheon_voucher/views/res_config_settings_views.xml create mode 100644 hr_luncheon_voucher/wizard/__init__.py create mode 100644 hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.py create mode 100644 hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.xml diff --git a/hr_luncheon_voucher/README.md b/hr_luncheon_voucher/README.md new file mode 100644 index 0000000..f40d09c --- /dev/null +++ b/hr_luncheon_voucher/README.md @@ -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 + +### 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. diff --git a/hr_luncheon_voucher/__init__.py b/hr_luncheon_voucher/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/hr_luncheon_voucher/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/hr_luncheon_voucher/__manifest__.py b/hr_luncheon_voucher/__manifest__.py new file mode 100644 index 0000000..f667c13 --- /dev/null +++ b/hr_luncheon_voucher/__manifest__.py @@ -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", + ], +} diff --git a/hr_luncheon_voucher/data/event_type_data.xml b/hr_luncheon_voucher/data/event_type_data.xml new file mode 100644 index 0000000..f0a149c --- /dev/null +++ b/hr_luncheon_voucher/data/event_type_data.xml @@ -0,0 +1,14 @@ + + + + Free lunch + categ_meet_free_lunch + 1 + + + + Off-site + categ_meet_offsite + 1 + + diff --git a/hr_luncheon_voucher/i18n/fr.po b/hr_luncheon_voucher/i18n/fr.po new file mode 100644 index 0000000..0439a65 --- /dev/null +++ b/hr_luncheon_voucher/i18n/fr.po @@ -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" diff --git a/hr_luncheon_voucher/models/__init__.py b/hr_luncheon_voucher/models/__init__.py new file mode 100644 index 0000000..3cff762 --- /dev/null +++ b/hr_luncheon_voucher/models/__init__.py @@ -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 diff --git a/hr_luncheon_voucher/models/calendar_event_type.py b/hr_luncheon_voucher/models/calendar_event_type.py new file mode 100644 index 0000000..cb10c10 --- /dev/null +++ b/hr_luncheon_voucher/models/calendar_event_type.py @@ -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, + ) diff --git a/hr_luncheon_voucher/models/hr_employee.py b/hr_luncheon_voucher/models/hr_employee.py new file mode 100644 index 0000000..29176f4 --- /dev/null +++ b/hr_luncheon_voucher/models/hr_employee.py @@ -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) diff --git a/hr_luncheon_voucher/models/hr_lv_allocation.py b/hr_luncheon_voucher/models/hr_lv_allocation.py new file mode 100644 index 0000000..0669032 --- /dev/null +++ b/hr_luncheon_voucher/models/hr_lv_allocation.py @@ -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 + ) diff --git a/hr_luncheon_voucher/models/res_company.py b/hr_luncheon_voucher/models/res_company.py new file mode 100644 index 0000000..c44d1d9 --- /dev/null +++ b/hr_luncheon_voucher/models/res_company.py @@ -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" + ) diff --git a/hr_luncheon_voucher/models/res_config_settings.py b/hr_luncheon_voucher/models/res_config_settings.py new file mode 100644 index 0000000..33e8b61 --- /dev/null +++ b/hr_luncheon_voucher/models/res_config_settings.py @@ -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, + ) diff --git a/hr_luncheon_voucher/security/ir.model.access.csv b/hr_luncheon_voucher/security/ir.model.access.csv new file mode 100644 index 0000000..030a333 --- /dev/null +++ b/hr_luncheon_voucher/security/ir.model.access.csv @@ -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 diff --git a/hr_luncheon_voucher/tests/__init__.py b/hr_luncheon_voucher/tests/__init__.py new file mode 100644 index 0000000..1f52916 --- /dev/null +++ b/hr_luncheon_voucher/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_hr_employee +from . import test_hr_lv_allocation +from . import test_generate_lv_allocations_wizard \ No newline at end of file diff --git a/hr_luncheon_voucher/tests/test_generate_lv_allocations_wizard.py b/hr_luncheon_voucher/tests/test_generate_lv_allocations_wizard.py new file mode 100644 index 0000000..28b3690 --- /dev/null +++ b/hr_luncheon_voucher/tests/test_generate_lv_allocations_wizard.py @@ -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", + } + ) diff --git a/hr_luncheon_voucher/tests/test_hr_employee.py b/hr_luncheon_voucher/tests/test_hr_employee.py new file mode 100644 index 0000000..a0ec25f --- /dev/null +++ b/hr_luncheon_voucher/tests/test_hr_employee.py @@ -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 diff --git a/hr_luncheon_voucher/tests/test_hr_lv_allocation.py b/hr_luncheon_voucher/tests/test_hr_lv_allocation.py new file mode 100644 index 0000000..353418a --- /dev/null +++ b/hr_luncheon_voucher/tests/test_hr_lv_allocation.py @@ -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) diff --git a/hr_luncheon_voucher/views/event_type.xml b/hr_luncheon_voucher/views/event_type.xml new file mode 100644 index 0000000..d2f486d --- /dev/null +++ b/hr_luncheon_voucher/views/event_type.xml @@ -0,0 +1,13 @@ + + + + event.type.luncheonvoucher + calendar.event.type + + + + + + + + diff --git a/hr_luncheon_voucher/views/hr_employee_views.xml b/hr_luncheon_voucher/views/hr_employee_views.xml new file mode 100644 index 0000000..674fc1b --- /dev/null +++ b/hr_luncheon_voucher/views/hr_employee_views.xml @@ -0,0 +1,78 @@ + + + + + hr.employee.form.lv + hr.employee + + +
+
+ + + + + + + + + + + +
+
+ + + view_employee_tree_lv + hr.employee + + + +
+
+
+
+
+ +
diff --git a/hr_luncheon_voucher/views/hr_lv_allocation_views.xml b/hr_luncheon_voucher/views/hr_lv_allocation_views.xml new file mode 100644 index 0000000..b4c4064 --- /dev/null +++ b/hr_luncheon_voucher/views/hr_lv_allocation_views.xml @@ -0,0 +1,136 @@ + + + + + hr.lv.allocation.search + hr.lv.allocation + + + + + + + + + + + + + + hr.lv.allocation.list + hr.lv.allocation + + +
+
+ + + + + + + + + +
+
+
+ + + hr.lv.allocation.form + hr.lv.allocation + +
+
+
+ +

+ +

+ + + + + + + + + + + + +
+
+
+
+ + + Luncheon vouchers allocations + hr.lv.allocation + list,form + +
diff --git a/hr_luncheon_voucher/views/menus.xml b/hr_luncheon_voucher/views/menus.xml new file mode 100644 index 0000000..6896b59 --- /dev/null +++ b/hr_luncheon_voucher/views/menus.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/hr_luncheon_voucher/views/res_config_settings_views.xml b/hr_luncheon_voucher/views/res_config_settings_views.xml new file mode 100644 index 0000000..7ff504b --- /dev/null +++ b/hr_luncheon_voucher/views/res_config_settings_views.xml @@ -0,0 +1,35 @@ + + + + res.config.settings.view.form.inherit.lv + res.config.settings + + + + +

Employee Luncheon Vouchers

+
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/hr_luncheon_voucher/wizard/__init__.py b/hr_luncheon_voucher/wizard/__init__.py new file mode 100644 index 0000000..5654c38 --- /dev/null +++ b/hr_luncheon_voucher/wizard/__init__.py @@ -0,0 +1 @@ +from . import generate_lv_allocations_wizard diff --git a/hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.py b/hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.py new file mode 100644 index 0000000..ffd36b7 --- /dev/null +++ b/hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.py @@ -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" + ) diff --git a/hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.xml b/hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.xml new file mode 100644 index 0000000..3489b1e --- /dev/null +++ b/hr_luncheon_voucher/wizard/generate_lv_allocations_wizard.xml @@ -0,0 +1,42 @@ + + + + lv.allocations.requests.wizard + generate.lv.allocation.requests + +
+ + + + + + + +
+
+
+
+
+ + + Create Luncheon Vouchers allocations requests + generate.lv.allocation.requests + form + + new + +