diff --git a/.gitea/workflows/pre-commit.yml b/.gitea/workflows/pre-commit.yml index 61d456b..039e8d9 100644 --- a/.gitea/workflows/pre-commit.yml +++ b/.gitea/workflows/pre-commit.yml @@ -3,7 +3,7 @@ name: pre-commit on: pull_request: branches: - - "16.0*" + - "18.0*" jobs: pre-commit: diff --git a/.pylintrc b/.pylintrc index 652a781..8d996d7 100644 --- a/.pylintrc +++ b/.pylintrc @@ -10,7 +10,7 @@ manifest-required-authors=Elabore manifest-required-keys=license manifest-deprecated-keys=description,active license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 -valid-odoo-versions=16.0 +valid-odoo-versions=18.0 [MESSAGES CONTROL] disable=all diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory index 14ca356..aa43e02 100644 --- a/.pylintrc-mandatory +++ b/.pylintrc-mandatory @@ -9,7 +9,7 @@ manifest-required-authors=Elabore manifest-required-keys=license manifest-deprecated-keys=description,active license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 -valid-odoo-versions=16.0 +valid-odoo-versions=18.0 [MESSAGES CONTROL] disable=all diff --git a/allow_negative_leave_and_allocation/README.rst b/allow_negative_leave_and_allocation/README.rst deleted file mode 100644 index b96a555..0000000 --- a/allow_negative_leave_and_allocation/README.rst +++ /dev/null @@ -1,44 +0,0 @@ -=================================== -allow_negative_leave_and_allocation -=================================== - -allow negative leaves and allocations - -Installation -============ - -Use Odoo normal module installation procedure to install -``allow_negative_leave_and_allocation``. - -Known issues / Roadmap -====================== - -None yet. - -Bug Tracker -=========== - -Bugs are tracked on `our issues website `_. In case of -trouble, please check there if your issue has already been -reported. If you spotted it first, help us smashing it by providing a -detailed and welcomed feedback. - -Credits -======= - -Contributors ------------- - -* `Elabore ` - -Funders -------- - -The development of this module has been financially supported by: -* Elabore (https://elabore.coop) - - -Maintainer ----------- - -This module is maintained by Elabore. \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/__manifest__.py b/allow_negative_leave_and_allocation/__manifest__.py deleted file mode 100644 index 8d7ac7b..0000000 --- a/allow_negative_leave_and_allocation/__manifest__.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright 2024 Elabore () -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -{ - "name": "allow_negative_leave_and_allocation", - "version": "16.0.1.1.0", - "author": "Elabore", - "website": "https://elabore.coop", - "maintainer": "Elabore", - "license": "AGPL-3", - "category": "hr", - "summary": "allow negative leaves and allocations", - # any module necessary for this one to work correctly - "depends": [ - "base","hr_holidays", - ], - "qweb": [], - "external_dependencies": { - "python": [], - }, - # always loaded - "data": [ - "views/hr_leave_type_views.xml", - "views/hr_leave_views.xml", - ], - "assets": { - 'web.assets_backend': [ - 'allow_negative_leave_and_allocation/static/src/xml/time_off_card.xml', - ], - }, - # only loaded in demonstration mode - "demo": [], - "js": [], - "css": [], - "installable": True, - # Install this module automatically if all dependency have been previously - # and independently installed. Used for synergetic or glue modules. - "auto_install": False, - "application": False, -} \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/i18n/allow_negative_leave_and_allocation.pot b/allow_negative_leave_and_allocation/i18n/allow_negative_leave_and_allocation.pot deleted file mode 100644 index 304690e..0000000 --- a/allow_negative_leave_and_allocation/i18n/allow_negative_leave_and_allocation.pot +++ /dev/null @@ -1,60 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * allow_negative_leave_and_allocation -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-01-02 12:11+0000\n" -"PO-Revision-Date: 2025-01-02 12:11+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: allow_negative_leave_and_allocation -#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative -msgid "Allow Negative Leaves" -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model_terms:ir.ui.view,arch_db:allow_negative_leave_and_allocation.hr_leave_type_negative_leave -msgid "Allow negative" -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model.fields,help:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative -msgid "" -"If checked, users request can exceed the allocated days and balance can go " -"in negative." -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_duration_check -msgid "The duration must be greater than 0." -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_type_value -msgid "" -"The employee, department, company or employee category of this request is " -"missing. Please make sure that your user login is linked to an employee." -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model,name:allow_negative_leave_and_allocation.model_hr_leave -msgid "Time Off" -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model,name:allow_negative_leave_and_allocation.model_hr_leave_allocation -msgid "Time Off Allocation" -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model,name:allow_negative_leave_and_allocation.model_hr_leave_type -msgid "Time Off Type" -msgstr "" \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/i18n/fr.po b/allow_negative_leave_and_allocation/i18n/fr.po deleted file mode 100644 index d4331ed..0000000 --- a/allow_negative_leave_and_allocation/i18n/fr.po +++ /dev/null @@ -1,60 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * allow_negative_leave_and_allocation -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-01-02 12:11+0000\n" -"PO-Revision-Date: 2025-01-02 12:11+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: allow_negative_leave_and_allocation -#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative -msgid "Allow Negative Leaves" -msgstr "Autoriser les congés négatifs" - -#. module: allow_negative_leave_and_allocation -#: model_terms:ir.ui.view,arch_db:allow_negative_leave_and_allocation.hr_leave_type_negative_leave -msgid "Allow negative" -msgstr "Autoriser les congés négatifs" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model.fields,help:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative -msgid "" -"If checked, users request can exceed the allocated days and balance can go " -"in negative." -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_duration_check -msgid "The duration must be greater than 0." -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_type_value -msgid "" -"The employee, department, company or employee category of this request is " -"missing. Please make sure that your user login is linked to an employee." -msgstr "" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model,name:allow_negative_leave_and_allocation.model_hr_leave -msgid "Time Off" -msgstr "Congés" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model,name:allow_negative_leave_and_allocation.model_hr_leave_allocation -msgid "Time Off Allocation" -msgstr "Allocation de congés" - -#. module: allow_negative_leave_and_allocation -#: model:ir.model,name:allow_negative_leave_and_allocation.model_hr_leave_type -msgid "Time Off Type" -msgstr "Type de congés" \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/models/__init__.py b/allow_negative_leave_and_allocation/models/__init__.py deleted file mode 100644 index fd469c5..0000000 --- a/allow_negative_leave_and_allocation/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import hr_leave_type, hr_leave, hr_leave_allocation \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/models/hr_leave.py b/allow_negative_leave_and_allocation/models/hr_leave.py deleted file mode 100644 index 9c196bd..0000000 --- a/allow_negative_leave_and_allocation/models/hr_leave.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -# Copyright (c) 2005-2006 Axelor SARL. (http://www.axelor.com) - -from odoo import api, models - -class HrLeave(models.Model): - _inherit = "hr.leave" - - @api.constrains('state', 'number_of_days', 'holiday_status_id') - def _check_holidays(self): - # On ne garde que les congés qui ne permettent pas le négatif - to_check = self.filtered(lambda h: not h.holiday_status_id.allows_negative) - if to_check: - super(HrLeave, to_check)._check_holidays() \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/models/hr_leave_allocation.py b/allow_negative_leave_and_allocation/models/hr_leave_allocation.py deleted file mode 100644 index b2a7076..0000000 --- a/allow_negative_leave_and_allocation/models/hr_leave_allocation.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -# Copyright (c) 2005-2006 Axelor SARL. (http://www.axelor.com) - -from odoo import models - -class HrLeaveAllocation(models.Model): - _inherit = "hr.leave.allocation" - - _sql_constraints = [ - ('type_value', - "CHECK( (holiday_type='employee' AND (employee_id IS NOT NULL OR multi_employee IS TRUE)) or " - "(holiday_type='category' AND category_id IS NOT NULL) or " - "(holiday_type='department' AND department_id IS NOT NULL) or " - "(holiday_type='company' AND mode_company_id IS NOT NULL))", - "The employee, department, company or employee category of this request is missing. Please make sure that your user login is linked to an employee."), - ] \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/models/hr_leave_type.py b/allow_negative_leave_and_allocation/models/hr_leave_type.py deleted file mode 100644 index e90d35c..0000000 --- a/allow_negative_leave_and_allocation/models/hr_leave_type.py +++ /dev/null @@ -1,25 +0,0 @@ - -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class HolidaysType(models.Model): - _inherit = "hr.leave.type" - - # negative time off - allows_negative = fields.Boolean(string='Allow Negative Leaves', - help="If checked, users request can exceed the allocated days and balance can go in negative.") - - @api.depends('requires_allocation') - def _compute_valid(self): - res = super()._compute_valid() - for holiday_type in res: - if not holiday_type.has_valid_allocation: - holiday_type.has_valid_allocation = holiday_type.allows_negative - - def _get_days_request(self): - res = super()._get_days_request() - res[1]['allows_negative'] = self.allows_negative - return res diff --git a/allow_negative_leave_and_allocation/static/src/xml/time_off_card.xml b/allow_negative_leave_and_allocation/static/src/xml/time_off_card.xml deleted file mode 100644 index f0bf77c..0000000 --- a/allow_negative_leave_and_allocation/static/src/xml/time_off_card.xml +++ /dev/null @@ -1,9 +0,0 @@ - \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/views/hr_leave_type_views.xml b/allow_negative_leave_and_allocation/views/hr_leave_type_views.xml deleted file mode 100644 index 374e932..0000000 --- a/allow_negative_leave_and_allocation/views/hr_leave_type_views.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - hr.leave.type.negative.leave - hr.leave.type - - - - - - - - - - - - \ No newline at end of file diff --git a/allow_negative_leave_and_allocation/views/hr_leave_views.xml b/allow_negative_leave_and_allocation/views/hr_leave_views.xml deleted file mode 100644 index f9f0eec..0000000 --- a/allow_negative_leave_and_allocation/views/hr_leave_views.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - hr.leave.view.negative.leave - hr.leave - - - - [ - '|', - ('requires_allocation', '=', 'no'), - '&', - ('has_valid_allocation', '=', True), - '|', - ('allows_negative', '=', True), - '&', - ('virtual_remaining_leaves', '>', 0), - ('allows_negative', '=', False), - ] - - - - \ No newline at end of file diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/.gitignore b/allow_negative_leave_and_allocation_hr_holidays_attendance/.gitignore deleted file mode 100644 index df8a896..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.*~ -*pyc \ No newline at end of file diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/LICENSE b/allow_negative_leave_and_allocation_hr_holidays_attendance/LICENSE deleted file mode 100644 index 0ad25db..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/README.rst b/allow_negative_leave_and_allocation_hr_holidays_attendance/README.rst deleted file mode 100644 index 396d11e..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/README.rst +++ /dev/null @@ -1,43 +0,0 @@ -========================================================== -allow_negative_leave_and_allocation_hr_holidays_attendance -========================================================== - -manage heritance of Duration in TimeOffCard - -Installation -============ - -The module self-installs when ``allow_negative_leave_and_allocation`` and ``hr_holidays_attendance``. - -Known issues / Roadmap -====================== - -None yet. - -Bug Tracker -=========== - -Bugs are tracked on `our issues website `_. In case of -trouble, please check there if your issue has already been -reported. If you spotted it first, help us smashing it by providing a -detailed and welcomed feedback. - -Credits -======= - -Contributors ------------- - -* `Elabore ` - -Funders -------- - -The development of this module has been financially supported by: -* Elabore (https://elabore.coop) - - -Maintainer ----------- - -This module is maintained by Elabore. \ No newline at end of file diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/__init__.py b/allow_negative_leave_and_allocation_hr_holidays_attendance/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/__manifest__.py b/allow_negative_leave_and_allocation_hr_holidays_attendance/__manifest__.py deleted file mode 100644 index 4983002..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/__manifest__.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2025 Elabore () -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -{ - "name": "allow_negative_leave_and_allocation_hr_holidays_attendance", - "version": "16.0.1.0.0", - "author": "Elabore", - "website": "https://elabore.coop", - "maintainer": "Elabore", - "license": "AGPL-3", - "category": "HR", - "summary": "manage heritance of Duration in TimeOffCard", - # any module necessary for this one to work correctly - "depends": [ - "base","allow_negative_leave_and_allocation","hr_holidays_attendance", - ], - "qweb": [], - "external_dependencies": { - "python": [], - }, - # always loaded - "data": [], - "assets": { - 'web.assets_backend': [ - 'allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml', - ], - }, - # only loaded in demonstration mode - "demo": [], - "js": [], - "css": [], - "installable": True, - # Install this module automatically if all dependency have been previously - # and independently installed. Used for synergetic or glue modules. - "auto_install": True, - "application": False, -} \ No newline at end of file diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml b/allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml deleted file mode 100644 index 4926de3..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml +++ /dev/null @@ -1,11 +0,0 @@ - \ No newline at end of file diff --git a/hr_effective_attendance_period/README.rst b/hr_effective_attendance_period/README.rst new file mode 100644 index 0000000..dcec42f --- /dev/null +++ b/hr_effective_attendance_period/README.rst @@ -0,0 +1,67 @@ +============================== +hr_effective_attendance_period +============================== + +Provide indication that attendance periods are effectively worked or not. + +Description +=========== + +This module adds a boolean field on working time attendance lines to mark +which periods are effectively worked by employees. It provides utility methods +on the resource calendar to determine: + +- Whether a day is a working day +- Whether a day is a full working day (both morning and afternoon) +- Whether all attendances of a day are effectively worked (no leaves) + +Installation +============ + +Use Odoo normal module installation procedure to install +``hr_effective_attendance_period``. + +This module depends on: + +* ``base`` (Odoo core) +* ``resource`` (Odoo core) + +Configuration +============= + +Go to ``Configuration > Technical > Resource > Working Times`` and create +Working Times for your company. For each attendance line, check +``Effective Attendance Period`` to indicate which periods are effectively +attended by employees. + +Known issues / Roadmap +====================== + +None yet. + +Bug Tracker +=========== + +Bugs are tracked on `our issues website `_. +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 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) + +Maintainer +---------- + +This module is maintained by Elabore. diff --git a/allow_negative_leave_and_allocation/__init__.py b/hr_effective_attendance_period/__init__.py similarity index 100% rename from allow_negative_leave_and_allocation/__init__.py rename to hr_effective_attendance_period/__init__.py diff --git a/hr_effective_attendance_period/__manifest__.py b/hr_effective_attendance_period/__manifest__.py new file mode 100644 index 0000000..c20c36a --- /dev/null +++ b/hr_effective_attendance_period/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2023 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "hr_effective_attendance_period", + "version": "18.0.1.0.0", + "author": "Elabore", + "website": "https://git.elabore.coop/elabore/hr-tools", + "maintainer": "Stéphan Sainléger", + "license": "AGPL-3", + "category": "Tools", + "summary": "Indicate whether attendance periods are effectively worked", + # any module necessary for this one to work correctly + "depends": [ + "base", + "resource", + ], + "data": [ + "views/resource_views.xml", + ], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/hr_effective_attendance_period/i18n/README b/hr_effective_attendance_period/i18n/README new file mode 100644 index 0000000..62197a1 --- /dev/null +++ b/hr_effective_attendance_period/i18n/README @@ -0,0 +1 @@ +This directory should contain the *.po for Odoo translation. diff --git a/hr_effective_attendance_period/i18n/fr.po b/hr_effective_attendance_period/i18n/fr.po new file mode 100644 index 0000000..099512e --- /dev/null +++ b/hr_effective_attendance_period/i18n/fr.po @@ -0,0 +1,49 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_effective_attendance_period +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-01-24 21:50+0000\n" +"PO-Revision-Date: 2023-01-24 21:50+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_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar__display_name +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: hr_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance__effective_attendance_period +msgid "Effective Attendance Period" +msgstr "Présence effective" + +#. module: hr_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar__id +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance__id +msgid "ID" +msgstr "" + +#. module: hr_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar____last_update +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: hr_effective_attendance_period +#: model:ir.model,name:hr_effective_attendance_period.model_resource_calendar +msgid "Resource Working Time" +msgstr "Temps de travail de la ressource" + +#. module: hr_effective_attendance_period +#: model:ir.model,name:hr_effective_attendance_period.model_resource_calendar_attendance +msgid "Work Detail" +msgstr "Détail du travail" diff --git a/hr_effective_attendance_period/i18n/hr_effective_attendance_period.pot b/hr_effective_attendance_period/i18n/hr_effective_attendance_period.pot new file mode 100644 index 0000000..dd84576 --- /dev/null +++ b/hr_effective_attendance_period/i18n/hr_effective_attendance_period.pot @@ -0,0 +1,49 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_effective_attendance_period +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-01-24 21:49+0000\n" +"PO-Revision-Date: 2023-01-24 21: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_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar__display_name +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance__effective_attendance_period +msgid "Effective Attendance Period" +msgstr "" + +#. module: hr_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar__id +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance__id +msgid "ID" +msgstr "" + +#. module: hr_effective_attendance_period +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar____last_update +#: model:ir.model.fields,field_description:hr_effective_attendance_period.field_resource_calendar_attendance____last_update +msgid "Last Modified on" +msgstr "" + +#. module: hr_effective_attendance_period +#: model:ir.model,name:hr_effective_attendance_period.model_resource_calendar +msgid "Resource Working Time" +msgstr "" + +#. module: hr_effective_attendance_period +#: model:ir.model,name:hr_effective_attendance_period.model_resource_calendar_attendance +msgid "Work Detail" +msgstr "" diff --git a/hr_effective_attendance_period/models/__init__.py b/hr_effective_attendance_period/models/__init__.py new file mode 100644 index 0000000..364a06e --- /dev/null +++ b/hr_effective_attendance_period/models/__init__.py @@ -0,0 +1 @@ +from . import resource diff --git a/hr_effective_attendance_period/models/resource.py b/hr_effective_attendance_period/models/resource.py new file mode 100644 index 0000000..e845613 --- /dev/null +++ b/hr_effective_attendance_period/models/resource.py @@ -0,0 +1,97 @@ +import math +from datetime import timedelta + +import pytz + +from odoo import fields, models + + +class ResourceCalendarAttendance(models.Model): + _inherit = "resource.calendar.attendance" + + effective_attendance_period = fields.Boolean(store=True) + + +class ResourceCalendar(models.Model): + _inherit = "resource.calendar" + + def _retrieve_day_matching_attendances(self, day): + domain = [ + ("calendar_id", "=", self.id), + ("dayofweek", "=", day.weekday()), + ("effective_attendance_period", "=", True), + ] + if self.two_weeks_calendar: + # Employee has Even/Odd weekly calendar + week_type = 1 if int(math.floor((day.toordinal() - 1) / 7) % 2) else 0 + domain.append(("week_type", "=", week_type)) + result = self.env["resource.calendar.attendance"].search(domain) + return result + + def is_working_day(self, day): + day_attendances = self._retrieve_day_matching_attendances(day) + if len(day_attendances) == 0: + # This day of the week is not supposed to be a working day + return False + else: + # This day of the week is supposed to be a working day + return True + + def is_full_working_day(self, day): + day_attendances = self._retrieve_day_matching_attendances(day) + morning_worked = ( + len(day_attendances.filtered(lambda x: x.day_period == "morning")) > 0 + ) + afternoon_worked = ( + len(day_attendances.filtered(lambda x: x.day_period == "afternoon")) > 0 + ) + return morning_worked and afternoon_worked + + def _compute_datetime_in_utc_tz(self, date): + user_tz = pytz.timezone(self.env.context.get("tz", "utc") or "utc") + return user_tz.localize(date).astimezone(pytz.utc) + + def _is_worked_attendance(self, resource, day, attendance): + attendance_start = fields.Datetime.to_datetime(day.date()) + timedelta( + hours=attendance.hour_from + ) + attendance_end = fields.Datetime.to_datetime(day.date()) + timedelta( + hours=attendance.hour_to + ) + utc_start = self._compute_datetime_in_utc_tz(attendance_start) + utc_end = self._compute_datetime_in_utc_tz(attendance_end) + resource_leaves = ( + self.env["resource.calendar.leaves"] + .sudo() + .search( + [ + ("company_id", "=", resource.company_id.id), + ("date_from", "<=", utc_start), + ("date_to", ">=", utc_end), + "|", + ("resource_id", "=", resource.id), + ("resource_id", "=", None), + ] + ) + ) + if resource_leaves: + return False + else: + # a part or the whole attendance is worked + return True + + def is_worked_day(self, resource, day): + day_attendances = self._retrieve_day_matching_attendances(day) + # If at least one attendance is worked, return True + for attendance in day_attendances: + if self._is_worked_attendance(resource, day, attendance): + return True + return False + + def all_attendances_worked(self, resource, day): + day_attendances = self._retrieve_day_matching_attendances(day) + # If at least one attendance is not worked, return False + for attendance in day_attendances: + if not self._is_worked_attendance(resource, day, attendance): + return False + return True diff --git a/hr_effective_attendance_period/views/resource_views.xml b/hr_effective_attendance_period/views/resource_views.xml new file mode 100644 index 0000000..f274709 --- /dev/null +++ b/hr_effective_attendance_period/views/resource_views.xml @@ -0,0 +1,36 @@ + + + + resource.calendar.attendance.view.list.inherit + resource.calendar.attendance + + + + + + + + + + resource.calendar.attendance.view.list.inherit + resource.calendar.attendance + + + + + + + + diff --git a/hr_expense_analytic_account_preselect_with_project/README.rst b/hr_expense_analytic_account_preselect_with_project/README.rst new file mode 100644 index 0000000..8d378f7 --- /dev/null +++ b/hr_expense_analytic_account_preselect_with_project/README.rst @@ -0,0 +1,66 @@ +================================================== +HR Expense Analytic Account Preselect with Project +================================================== + +Preselect the analytic distribution on expenses based on the selected project. + +Description +=========== + +This module adds a ``Project`` field to expense forms. When a project is +selected, the analytic distribution is automatically set to the project's +analytic account (100%), so the user does not need to manually set the +analytic distribution. + +Installation +============ + +Use Odoo normal module installation procedure to install +``hr_expense_analytic_account_preselect_with_project``. + +This module depends on: + +* ``base`` (Odoo core) +* ``hr_expense`` (Odoo core) +* ``project`` (Odoo core) + +Usage +===== + +When creating an expense: + +1. Select a ``Project`` in the expense form. +2. The analytic distribution is automatically populated with the project's + analytic account at 100%. + +Known issues / Roadmap +====================== + +None yet. + +Bug Tracker +=========== + +Bugs are tracked on `our issues website `_. +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 detailed and welcomed feedback. + +Credits +======= + +Contributors +------------ + +* Clément Thomas + +Funders +------- + +The development of this module has been financially supported by: + +* Elabore (https://elabore.coop) + +Maintainer +---------- + +This module is maintained by Elabore. diff --git a/hr_timesheet_import_from_calendar/__init__.py b/hr_expense_analytic_account_preselect_with_project/__init__.py similarity index 51% rename from hr_timesheet_import_from_calendar/__init__.py rename to hr_expense_analytic_account_preselect_with_project/__init__.py index c536983..0650744 100644 --- a/hr_timesheet_import_from_calendar/__init__.py +++ b/hr_expense_analytic_account_preselect_with_project/__init__.py @@ -1,2 +1 @@ from . import models -from . import wizard \ No newline at end of file diff --git a/hr_expense_analytic_account_preselect_with_project/__manifest__.py b/hr_expense_analytic_account_preselect_with_project/__manifest__.py new file mode 100644 index 0000000..a8638ef --- /dev/null +++ b/hr_expense_analytic_account_preselect_with_project/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2023 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "hr_expense_analytic_account_preselect_with_project", + "version": "18.0.1.0.0", + "author": "Elabore", + "website": "https://git.elabore.coop/elabore/hr-tools", + "maintainer": "Clément Thomas", + "license": "AGPL-3", + "category": "Tools", + "summary": "Preselect analytic distribution on expenses from project", + # any module necessary for this one to work correctly + "depends": [ + "base", + "hr_expense", + "project", + ], + "data": [ + "views/hr_expense_views.xml", + ], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/hr_expense_analytic_account_preselect_with_project/i18n/README b/hr_expense_analytic_account_preselect_with_project/i18n/README new file mode 100644 index 0000000..62197a1 --- /dev/null +++ b/hr_expense_analytic_account_preselect_with_project/i18n/README @@ -0,0 +1 @@ +This directory should contain the *.po for Odoo translation. diff --git a/hr_expense_analytic_account_preselect_with_project/models/__init__.py b/hr_expense_analytic_account_preselect_with_project/models/__init__.py new file mode 100644 index 0000000..86b1ea3 --- /dev/null +++ b/hr_expense_analytic_account_preselect_with_project/models/__init__.py @@ -0,0 +1 @@ +from . import hr_expense diff --git a/hr_expense_analytic_account_preselect_with_project/models/hr_expense.py b/hr_expense_analytic_account_preselect_with_project/models/hr_expense.py new file mode 100644 index 0000000..04282e9 --- /dev/null +++ b/hr_expense_analytic_account_preselect_with_project/models/hr_expense.py @@ -0,0 +1,14 @@ +from odoo import api, fields, models + + +class HrExpense(models.Model): + _inherit = "hr.expense" + + project_id = fields.Many2one("project.project", string="Projet", required=True) + + @api.onchange("project_id") + def set_analytic_account(self): + if self.project_id.account_id: + self.analytic_distribution = {str(self.project_id.account_id.id): 100} + else: + self.analytic_distribution = {} diff --git a/hr_expense_analytic_account_preselect_with_project/views/hr_expense_views.xml b/hr_expense_analytic_account_preselect_with_project/views/hr_expense_views.xml new file mode 100644 index 0000000..1adaa65 --- /dev/null +++ b/hr_expense_analytic_account_preselect_with_project/views/hr_expense_views.xml @@ -0,0 +1,18 @@ + + + + hr.expense.view.form.analytic.account.preselect.with.project + hr.expense + + + + + + + + diff --git a/allow_negative_leave_and_allocation/.gitignore b/hr_holidays_timeoff_analysis/.gitignore similarity index 50% rename from allow_negative_leave_and_allocation/.gitignore rename to hr_holidays_timeoff_analysis/.gitignore index df8a896..6da5887 100644 --- a/allow_negative_leave_and_allocation/.gitignore +++ b/hr_holidays_timeoff_analysis/.gitignore @@ -1,2 +1,2 @@ *.*~ -*pyc \ No newline at end of file +*pyc diff --git a/allow_negative_leave_and_allocation/LICENSE b/hr_holidays_timeoff_analysis/LICENSE similarity index 100% rename from allow_negative_leave_and_allocation/LICENSE rename to hr_holidays_timeoff_analysis/LICENSE diff --git a/hr_holidays_timeoff_analysis/README.md b/hr_holidays_timeoff_analysis/README.md new file mode 100644 index 0000000..3825669 --- /dev/null +++ b/hr_holidays_timeoff_analysis/README.md @@ -0,0 +1,95 @@ +# hr_holidays_timeoff_analysis + +Breaks down validated time-off requests into individual day records and provides a pivot table for analyzing leave consumption by employee, leave type, and period. + +## Features + +### Time-off day model (`hr.leave.timeoff.day`) + +One record per actual day of absence, with the following fields: + +* **Date**: the specific day. +* **Employee**: the employee on leave. +* **Leave request**: link to the original `hr.leave` record. +* **Leave type**: related field from the leave request (e.g. Paid leave, Sick leave). +* **Duration by day**: the fraction of the day taken off (1.0 for a full day, 0.5 for a half day, or a proportional value for hourly leaves based on `number_of_days_display`). + +### Calendar resolution + +The module determines the employee's working calendar using the following priority: + +1. **Employee calendar planning** (`hr.employee.calendar`): if the `hr_employee_calendar_planning` module is installed, the calendar matching the date range is used. This allows correct handling of employees who switch between full-time and part-time. +2. **Employee calendar** (`resource_calendar_id`): the calendar set directly on the employee. +3. **Company calendar**: fallback to the company's default calendar. + +Only days where the employee is scheduled to work are recorded. Weekends and non-attendance days are skipped. + +### Public holiday handling + +Public holidays are excluded from time-off day records. The module converts public holiday dates to the employee's timezone before comparison, ensuring correct results for holidays that span midnight UTC. + +### Scheduled action (cron) + +A daily cron job (`cron_manage_timeoff_days`) performs two operations: + +1. **Create**: Iterates over all validated leaves and creates a `hr.leave.timeoff.day` record for each working day within the leave period (skipping weekends, non-attendance days, and public holidays). +2. **Delete**: Removes records linked to leaves that are no longer validated (draft, refused) or have been cancelled (`active=False`). Also cleans up orphan records not linked to any leave. + +### Views + +* **List view**: Editable list showing date, employee, leave request, leave type, and duration. +* **Pivot view**: Leave days broken down by month (columns) and employee + leave type (rows) — the primary analysis tool. +* **Search view**: Date filter defaulting to the current year. + +The menu entry is located under **Time Off > Reporting > Timeoff Days**, accessible to Time Off managers. + +### Access rights + +Read-only access is granted to the Time Off manager group (`hr_holidays.group_hr_holidays_manager`). + +### Tests + +The module includes tests covering: + +* Multi-day leaves spanning two months. +* Half-day leaves (duration = 0.5). +* Hourly leaves compared against the employee's calendar. +* Part-time employee calendars (4-day week). +* Public holidays falling within a leave period. +* Unvalidated and deleted leaves (cleanup verification). +* Cancelled leaves (`active=False`). +* Employee calendar planning integration (switching calendars mid-period). + +## Installation + +This module depends on: + +* `base` (Odoo core) +* `hr_holidays` (Odoo core) + +Optional: install `hr_employee_calendar_planning` to enable per-period calendar resolution. + +Use the standard Odoo module installation procedure to install `hr_holidays_timeoff_analysis`. + +## Known issues / Roadmap + +None yet. + +## Bug Tracker + +Bugs are tracked on [our issues website](https://git.elabore.coop/Elabore/elabore-addons/issues). In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us smashing it by providing a detailed and welcomed feedback. + +## Credits + +### Contributors + +* [Elabore](mailto:contact@elabore.coop) + +### Funders + +The development of this module has been financially supported by: +* [Elabore](https://elabore.coop) + +### Maintainer + +This module is maintained by Elabore. diff --git a/hr_timesheet_sheet_usability/README.rst b/hr_holidays_timeoff_analysis/README.rst similarity index 66% rename from hr_timesheet_sheet_usability/README.rst rename to hr_holidays_timeoff_analysis/README.rst index bf52f64..82b16fb 100644 --- a/hr_timesheet_sheet_usability/README.rst +++ b/hr_holidays_timeoff_analysis/README.rst @@ -1,25 +1,14 @@ ============================ -hr_timesheet_sheet_usability +hr_holidays_timeoff_analysis ============================ -Summary -======= - -Various changes to improve the usability of hr_timesheet_sheet application - -Description -=========== - -This module changes : -- menu, menu order, menu access right -- french translation -of hr_timesheet_sheet module +indicate day by day if a date is timeoff or not and generate an analyses pivot by employee Installation ============ Use Odoo normal module installation procedure to install -``hr_timesheet_sheet_usability``. +``hr_holidays_timeoff_analysis``. Known issues / Roadmap ====================== @@ -29,7 +18,7 @@ None yet. Bug Tracker =========== -Bugs are tracked on `our issues website `_. In case of +Bugs are tracked on `our issues website `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us smashing it by providing a detailed and welcomed feedback. diff --git a/hr_holidays_timeoff_analysis/__init__.py b/hr_holidays_timeoff_analysis/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/hr_holidays_timeoff_analysis/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_holidays_timeoff_analysis/__manifest__.py b/hr_holidays_timeoff_analysis/__manifest__.py new file mode 100644 index 0000000..1b35570 --- /dev/null +++ b/hr_holidays_timeoff_analysis/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2025 Elabore () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "hr_holidays_timeoff_analysis", + "version": "18.0.1.0.1", + "author": "Elabore", + "website": "https://git.elabore.coop/elabore/elabore-addons", + "maintainer": "Elabore", + "license": "AGPL-3", + "category": "HR", + "summary": ( + "indicate day by day if a date is timeoff" + " or not and generate an analyses pivot by employee" + ), + # any module necessary for this one to work correctly + "depends": [ + "base", + "hr_holidays", + ], + # always loaded + "data": [ + "security/ir.model.access.csv", + "views/hr_leave_timeoff_day_views.xml", + "data/hr_leave_timeoff_day_cron.xml", + ], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/hr_holidays_timeoff_analysis/data/hr_leave_timeoff_day_cron.xml b/hr_holidays_timeoff_analysis/data/hr_leave_timeoff_day_cron.xml new file mode 100644 index 0000000..379ccab --- /dev/null +++ b/hr_holidays_timeoff_analysis/data/hr_leave_timeoff_day_cron.xml @@ -0,0 +1,11 @@ + + + Create and update Timeoff Days + + code + model.cron_manage_timeoff_days() + 1 + days + True + + diff --git a/hr_holidays_timeoff_analysis/i18n/fr.po b/hr_holidays_timeoff_analysis/i18n/fr.po new file mode 100644 index 0000000..f339d66 --- /dev/null +++ b/hr_holidays_timeoff_analysis/i18n/fr.po @@ -0,0 +1,104 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_holidays_timeoff_analysis +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-21 08:09+0000\n" +"PO-Revision-Date: 2025-08-21 08:09+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_holidays_timeoff_analysis +#: model:ir.actions.server,name:hr_holidays_timeoff_analysis.ir_cron_create_timeoff_day_ir_actions_server +#: model:ir.cron,cron_name:hr_holidays_timeoff_analysis.ir_cron_create_timeoff_day +msgid "Create and update Timeoff Days" +msgstr "Générer et mettre à jour la liste des jours en congés pour analyses" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__create_uid +msgid "Created by" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__create_date +msgid "Created on" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__date +msgid "Date" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__employee_id +msgid "Employee" +msgstr "Employé" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__hr_leave_id +msgid "Hr Leave" +msgstr "Congés" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__id +msgid "ID" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day____last_update +msgid "Last Modified on" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__write_date +msgid "Last Updated on" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__leave_duration_by_day +msgid "Leave Duration By Day" +msgstr "Durée d'absence par jour" + +#. module: hr_holidays_timeoff_analysis +#: model_terms:ir.ui.view,arch_db:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_view_search +msgid "Period" +msgstr "Période" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__hr_leave_type +msgid "Time Off Type" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model,name:hr_holidays_timeoff_analysis.model_hr_leave_timeoff_day +msgid "Timeoff Day" +msgstr "Jour d'absence" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.actions.act_window,name:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_action +#: model:ir.ui.menu,name:hr_holidays_timeoff_analysis.menu_hr_holidays_timeoff_day_menu +#: model_terms:ir.ui.view,arch_db:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_view_search +msgid "Timeoff Days" +msgstr "Jours d'absence" + +#. module: hr_holidays_timeoff_analysis +#: model_terms:ir.ui.view,arch_db:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_view_pivot +msgid "Timeoff Days Analysis" +msgstr "Analyse des jours d'absence" diff --git a/hr_holidays_timeoff_analysis/i18n/hr_holidays_timeoff_analysis.pot b/hr_holidays_timeoff_analysis/i18n/hr_holidays_timeoff_analysis.pot new file mode 100644 index 0000000..d5ba638 --- /dev/null +++ b/hr_holidays_timeoff_analysis/i18n/hr_holidays_timeoff_analysis.pot @@ -0,0 +1,104 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_holidays_timeoff_analysis +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-21 08:09+0000\n" +"PO-Revision-Date: 2025-08-21 08:09+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_holidays_timeoff_analysis +#: model:ir.actions.server,name:hr_holidays_timeoff_analysis.ir_cron_create_timeoff_day_ir_actions_server +#: model:ir.cron,cron_name:hr_holidays_timeoff_analysis.ir_cron_create_timeoff_day +msgid "Create and update Timeoff Days" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__create_uid +msgid "Created by" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__create_date +msgid "Created on" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__date +msgid "Date" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__display_name +msgid "Display Name" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__employee_id +msgid "Employee" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__hr_leave_id +msgid "Hr Leave" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__id +msgid "ID" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day____last_update +msgid "Last Modified on" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__write_date +msgid "Last Updated on" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__leave_duration_by_day +msgid "Leave Duration By Day" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model_terms:ir.ui.view,arch_db:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_view_search +msgid "Period" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model.fields,field_description:hr_holidays_timeoff_analysis.field_hr_leave_timeoff_day__hr_leave_type +msgid "Time Off Type" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.model,name:hr_holidays_timeoff_analysis.model_hr_leave_timeoff_day +msgid "Timeoff Day" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model:ir.actions.act_window,name:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_action +#: model:ir.ui.menu,name:hr_holidays_timeoff_analysis.menu_hr_holidays_timeoff_day_menu +#: model_terms:ir.ui.view,arch_db:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_view_search +msgid "Timeoff Days" +msgstr "" + +#. module: hr_holidays_timeoff_analysis +#: model_terms:ir.ui.view,arch_db:hr_holidays_timeoff_analysis.hr_leave_timeoff_day_view_pivot +msgid "Timeoff Days Analysis" +msgstr "" diff --git a/hr_holidays_timeoff_analysis/models/__init__.py b/hr_holidays_timeoff_analysis/models/__init__.py new file mode 100644 index 0000000..a9b879f --- /dev/null +++ b/hr_holidays_timeoff_analysis/models/__init__.py @@ -0,0 +1 @@ +from . import hr_leave_timeoff_day diff --git a/hr_holidays_timeoff_analysis/models/hr_leave_timeoff_day.py b/hr_holidays_timeoff_analysis/models/hr_leave_timeoff_day.py new file mode 100644 index 0000000..29f88ca --- /dev/null +++ b/hr_holidays_timeoff_analysis/models/hr_leave_timeoff_day.py @@ -0,0 +1,196 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import timedelta + +import pytz + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class TimeOffDay(models.Model): + _name = "hr.leave.timeoff.day" + _description = "Timeoff Day" + _order = "date desc" + + date = fields.Date() + employee_id = fields.Many2one("hr.employee") + hr_leave_id = fields.Many2one("hr.leave") + hr_leave_type = fields.Many2one(related="hr_leave_id.holiday_status_id") + leave_duration_by_day = fields.Float() + + def employee_is_scheduled_to_work_this_day(self, date, employee, leave): + """ + Check if the employee is scheduled to work on this day according to his + calendar. + """ + calendar = self.get_calendar(employee, date) + if not calendar or not calendar.attendance_ids: + return False + day_of_week = str(date.weekday()) + attendances = calendar.attendance_ids.filtered( + lambda att: att.dayofweek == day_of_week + and not att.display_type + ) + if calendar.two_weeks_calendar: + # Only keep attendances of the week matching the target date + week_type = str( + self.env["resource.calendar.attendance"].get_week_type(date) + ) + attendances = attendances.filtered( + lambda att: att.week_type == week_type + ) + return bool(attendances) + + def get_calendar(self, employee, date, leave=None): + """ + Get the working time calendar of the employee. + """ + calendar = False + # check if hr_employee_calendar model exists + # (module hr_employee_calendar_planning is installed) + if "hr.employee.calendar" in self.env: + hr_employee_calendar = self.env["hr.employee.calendar"].search( + [ + ("employee_id", "=", employee.id), + ("date_start", "<=", date), + "|", + ("date_end", ">=", date), + ("date_end", "=", False), + ], + limit=1, + ) + if hr_employee_calendar and hr_employee_calendar.calendar_id: + calendar = hr_employee_calendar.calendar_id + # If no hr_employee_calendar found or calendar found + # in hr_employee_calendar, take the one set on the employee + if not calendar and employee.resource_calendar_id: + calendar = employee.resource_calendar_id + # If no specific calendar is set on the employee, take the one of the company + if not calendar and employee.company_id.resource_calendar_id: + calendar = employee.company_id.resource_calendar_id + return calendar + + def _convert_to_employee_tz(self, date, employee): + """ + Convert a UTC datetime to the employee's timezone datetime. + """ + employee_tz = pytz.timezone(employee.tz or "UTC") + if date.tzinfo is None: + dt = pytz.utc.localize(date) + return dt.astimezone(employee_tz) + + def _is_public_holiday_according_to_employe_tz(self, date, employee): + if not date or not employee: + return False + # get public holidays for the employee + public_holidays = employee._get_public_holidays(date, date) + if not public_holidays: + return False + if len(public_holidays) > 1: + raise UserError( + _( + "Several holidays have been found on the" + " date '%s'. Please correct the anomaly" + " before continuing." + ) + % date + ) + ph = public_holidays[0] + # Convert public holiday to the employee timezone + ph_datetime_from_tz = self._convert_to_employee_tz(ph.date_from, employee) + ph_datetime_to_tz = self._convert_to_employee_tz(ph.date_to, employee) + # Convert datetime to date + ph_date_from = ph_datetime_from_tz.date() + ph_date_to = ph_datetime_to_tz.date() + # Check if the stat date falls within the public + # holiday range after conversion in employee tz + if ph_date_from <= date <= ph_date_to: + return True + else: + return False + + def compute_leave_duration_by_day(self, leave): + """ + Compute the leave duration by day based on the leave type. + """ + leave_duration_by_day = 0.0 + # Full day case + if leave.request_unit_half: + leave_duration_by_day = 0.5 + elif leave.request_unit_hours: + leave_duration_by_day = leave.number_of_days + else: + leave_duration_by_day = 1.0 + return leave_duration_by_day + + @api.model + def cron_manage_timeoff_days(self): + self.cron_create_timeoff_days() + self.cron_delete_timeoff_days() + + def cron_create_timeoff_days(self): + # Browse all validated leaves + leaves = self.env["hr.leave"].search( + [ + ("state", "=", "validate"), + ("request_date_from", "!=", False), + ("request_date_to", "!=", False), + ("employee_id", "!=", False), + ] + ) + for leave in leaves: + current_date = leave.request_date_from + employee = leave.employee_id + while current_date <= leave.request_date_to: + if self.employee_is_scheduled_to_work_this_day( + current_date, employee, leave + ) and not self._is_public_holiday_according_to_employe_tz( + current_date, employee + ): + # The employee is scheduled to work this day + # according his calendar and it's not a + # public holiday, + # so create a timeoff day record if it does not already exist + if not self.search( + [ + ("date", "=", current_date), + ("employee_id", "=", employee.id), + ("hr_leave_id", "=", leave.id), + ], + limit=1, + ): + self.create( + { + "date": current_date, + "employee_id": employee.id, + "hr_leave_id": leave.id, + "leave_duration_by_day": ( + self.compute_leave_duration_by_day(leave) + ), + } + ) + current_date += timedelta(days=1) + + def cron_delete_timeoff_days(self): + # Browse all unvalidated or canceled leaves + leaves = self.env["hr.leave"].search( + [ + ("state", "!=", "validate"), + ("request_date_from", "!=", False), + ("request_date_to", "!=", False), + ("employee_id", "!=", False), + ] + ) + # Delete timeoff days for leaves that are no longer validated + for leave in leaves: + self.search( + [ + ("hr_leave_id", "=", leave.id), + ] + ).unlink() + # Delete timeoff days that are not linked to any leave + self.search( + [ + ("hr_leave_id", "=", False), + ] + ).unlink() diff --git a/hr_holidays_timeoff_analysis/security/ir.model.access.csv b/hr_holidays_timeoff_analysis/security/ir.model.access.csv new file mode 100644 index 0000000..f9363a3 --- /dev/null +++ b/hr_holidays_timeoff_analysis/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_hr_leave_timeoff_day_user,access_hr_holidays_timeoff_day_user,model_hr_leave_timeoff_day,hr_holidays.group_hr_holidays_user,1,0,0,0 +access_hr_leave_timeoff_day_manager,access_hr_holidays_timeoff_day_manager,model_hr_leave_timeoff_day,hr_holidays.group_hr_holidays_manager,1,0,0,0 diff --git a/hr_holidays_timeoff_analysis/tests/__init__.py b/hr_holidays_timeoff_analysis/tests/__init__.py new file mode 100644 index 0000000..56a62c1 --- /dev/null +++ b/hr_holidays_timeoff_analysis/tests/__init__.py @@ -0,0 +1 @@ +from . import test_hr_leave_timeoff_day diff --git a/hr_holidays_timeoff_analysis/tests/test_hr_leave_timeoff_day.py b/hr_holidays_timeoff_analysis/tests/test_hr_leave_timeoff_day.py new file mode 100644 index 0000000..56ead23 --- /dev/null +++ b/hr_holidays_timeoff_analysis/tests/test_hr_leave_timeoff_day.py @@ -0,0 +1,719 @@ +from odoo.fields import Date +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestHrLeaveTimeoffDay(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.employee = cls.env["hr.employee"].create( + { + "name": "Camille", + "tz": "Europe/Paris", + } + ) + cls.base_calendar = cls.env["resource.calendar"].create( + { + "name": "default calendar", + } + ) + + cls.time_off_type = cls.env["hr.leave.type"].create( + { + "name": "Time Off", + "requires_allocation": "no", + "request_unit": "half_day", + } + ) + + cls.time_off_hour_type = cls.env["hr.leave.type"].create( + { + "name": "Recovery", + "requires_allocation": "no", + "request_unit": "hour", + } + ) + + def test_leave_between_2_months_nb_of_days(self): + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-06-30"), + "request_date_to": Date.to_date("2025-07-06"), + "holiday_status_id": self.time_off_type.id, + } + ) + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il existe bien 5 hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 5, "There should be 5 timeoff days for this leave" + ) + # Vérifie que la somme des leave_duration_by_day fait bien 5 + total_duration = sum(timeoff_days.mapped("leave_duration_by_day")) + self.assertEqual( + total_duration, + 5.0, + "The sum of leave_duration_by_day should be 5.0 for this leave", + ) + + def test_leave_duration_by_day_half_day(self): + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-07-02"), + "request_date_to": Date.to_date("2025-07-02"), + "holiday_status_id": self.time_off_type.id, + "request_unit_half": True, + } + ) + leave.state = "validate" # Simulate the leave being validated + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 1, "There should be 1 timeoff day for this leave" + ) + + total_duration = sum(timeoff_days.mapped("leave_duration_by_day")) + self.assertEqual( + total_duration, + 0.5, + "leave_duration_by_day should be 0.5 for a half day leave", + ) + + def test_leave_duration_by_day_hour_compare_to_employee_calendar(self): + employee_calendar = self.env["resource.calendar"].create( + { + "name": "Employee Calendar", + "attendance_ids": [ + ( + 0, + 0, + { + "name": "Monday Morning", + "dayofweek": "0", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Monday Afternoon", + "dayofweek": "0", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Tuesday Morning", + "dayofweek": "1", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Tuesday Afternoon", + "dayofweek": "1", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Wednesday Morning", + "dayofweek": "2", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Wednesday Afternoon", + "dayofweek": "2", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Thursday Morning", + "dayofweek": "3", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Thursday Afternoon", + "dayofweek": "3", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Friday Morning", + "dayofweek": "4", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Friday Afternoon", + "dayofweek": "4", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ], + } + ) + self.employee.resource_calendar_id = employee_calendar + + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-07-03"), + "request_date_to": Date.to_date("2025-07-03"), + "request_unit_hours": True, + "request_hour_from": "8", + "request_hour_to": "12", + "holiday_status_id": self.time_off_hour_type.id, + } + ) + leave._compute_date_from_to() + leave._compute_duration() + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 1, "There should be 1 timeoff day for this leave" + ) + + total_duration = sum(timeoff_days.mapped("leave_duration_by_day")) + self.assertEqual( + total_duration, + 0.5, + "leave_duration_by_day should be 0.5 for 4 hours on an 8h day", + ) + + def test_leave_duration_by_day_compare_to_time_part_employee_calendar(self): + employee_calendar = self.env["resource.calendar"].create( + { + "name": "Employee Calendar", + "attendance_ids": [ + ( + 0, + 0, + { + "name": "Monday Morning", + "dayofweek": "0", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Monday Afternoon", + "dayofweek": "0", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Tuesday Morning", + "dayofweek": "1", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Tuesday Afternoon", + "dayofweek": "1", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Wednesday Morning", + "dayofweek": "2", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Wednesday Afternoon", + "dayofweek": "2", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Thursday Morning", + "dayofweek": "3", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Thursday Afternoon", + "dayofweek": "3", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ], + } + ) + self.employee.resource_calendar_id = employee_calendar + + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-07-21"), + "request_date_to": Date.to_date("2025-07-27"), + "holiday_status_id": self.time_off_type.id, + } + ) + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 4, "There should be 4 timeoff day for this leave" + ) + + total_duration = sum(timeoff_days.mapped("leave_duration_by_day")) + self.assertEqual( + total_duration, + 4, + "leave_duration_by_day should be 4 day", + ) + + def test_public_holidays_between_a_leave(self): + # Create a public holiday on 8 May 2025, + # because of TZ conversion we set it from 7 May 22:00 to 8 May 23:00 + self.env["resource.calendar.leaves"].create( + { + "name": "8 mai 2025", + "date_from": "2025-05-07 22:00:00", + "date_to": "2025-05-08 23:00:00", + } + ) + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-05-05"), + "request_date_to": Date.to_date( + "2025-05-11" + ), # a public holiday is in between (8 may) + "holiday_status_id": self.time_off_type.id, + } + ) + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il existe bien 4 hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 4, "There should be 4 timeoff days for this leave" + ) + # Vérifie que la somme des leave_duration_by_day fait bien 4 + total_duration = sum(timeoff_days.mapped("leave_duration_by_day")) + self.assertEqual( + total_duration, + 4.0, + "The sum of leave_duration_by_day should be 4.0 for this leave", + ) + + def test_unvalidated_leave(self): + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-08-04"), + "request_date_to": Date.to_date("2025-08-10"), + "holiday_status_id": self.time_off_type.id, + } + ) + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il existe bien 5 hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 5, "There should be 5 timeoff days for this leave" + ) + + leave.state = "confirm" + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il n'existe plus de hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 0, "There should be no timeoff days for this leave" + ) + + def test_deleted_leave(self): + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-08-18"), + "request_date_to": Date.to_date("2025-08-24"), + "holiday_status_id": self.time_off_type.id, + } + ) + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il existe bien 5 hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 5, "There should be 5 timeoff days for this leave" + ) + + leave.state = "confirm" + leave.unlink() + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il n'existe plus de hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 0, "There should be no timeoff days for this leave" + ) + + def test_unactive_leave(self): + """ + When a leave is canceled, his status stay 'validate' but the field active is set + to False. + """ + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date("2025-12-08"), + "request_date_to": Date.to_date("2025-12-14"), + "holiday_status_id": self.time_off_type.id, + } + ) + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il existe bien 5 hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 5, "There should be 5 timeoff days for this leave" + ) + + leave._force_cancel( + "reason test", "mail.mt_note" + ) # Simulate the leave being unactive but status stay 'validate' + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + # Vérifie qu'il n'existe plus de hr.leave.timeoff.day pour ce leave + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 0, "There should be no timeoff days for this leave" + ) + + def test_hr_employee_calendar(self): + if "hr.employee.calendar" not in self.env: + return + + employee_calendar_1 = ( + self.base_calendar + ) # full time calendar (from monday to friday) + employee_calendar_2 = self.env["resource.calendar"].create( + { # part time calendar (from monday to thursday) + "name": "Employee Calendar", + "attendance_ids": [ + ( + 0, + 0, + { + "name": "Monday Morning", + "dayofweek": "0", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Monday Afternoon", + "dayofweek": "0", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Tuesday Morning", + "dayofweek": "1", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Tuesday Afternoon", + "dayofweek": "1", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Wednesday Morning", + "dayofweek": "2", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Wednesday Afternoon", + "dayofweek": "2", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ( + 0, + 0, + { + "name": "Thursday Morning", + "dayofweek": "3", + "hour_from": 8, + "hour_to": 12, + "day_period": "morning", + }, + ), + ( + 0, + 0, + { + "name": "Thursday Afternoon", + "dayofweek": "3", + "hour_from": 13, + "hour_to": 17, + "day_period": "afternoon", + }, + ), + ], + } + ) + self.env["hr.employee.calendar"].create( + { + "employee_id": self.employee.id, + "date_start": Date.to_date("2023-07-01"), + "date_end": Date.to_date("2025-06-30"), + "calendar_id": employee_calendar_1.id, + } + ) + self.env["hr.employee.calendar"].create( + { + "employee_id": self.employee.id, + "date_start": Date.to_date("2025-07-01"), + "date_end": None, + "calendar_id": employee_calendar_2.id, + } + ) + self.employee.resource_calendar_id = employee_calendar_2 + + # Leave during employee_calendar_1 period when employee is full time + leave = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date( + "2024-07-22" + ), # this leave occures when employee_calendar_1 is applied + "request_date_to": Date.to_date("2024-07-28"), + "holiday_status_id": self.time_off_type.id, + } + ) + leave.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + timeoff_days = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave.id), + ] + ) + self.assertEqual( + len(timeoff_days), 5, "There should be 5 timeoff day for this leave" + ) + + total_duration = sum(timeoff_days.mapped("leave_duration_by_day")) + self.assertEqual( + total_duration, + 5, + "leave_duration_by_day should be 5 day", + ) + + leave.state = "confirm" + leave.unlink() + + # Leave during employee_calendar_2 period (the employee his part time now) + leave_2 = self.env["hr.leave"].create( + { + "employee_id": self.employee.id, + "request_date_from": Date.to_date( + "2025-11-24" + ), # this leave occures when employee_calendar_1 is applied + "request_date_to": Date.to_date("2025-11-30"), + "holiday_status_id": self.time_off_type.id, + } + ) + leave_2.state = "validate" # Simulate the leave being validated + + self.env["hr.leave.timeoff.day"].cron_manage_timeoff_days() + timeoff_days_2 = self.env["hr.leave.timeoff.day"].search( + [ + ("employee_id", "=", self.employee.id), + ("hr_leave_id", "=", leave_2.id), + ] + ) + self.assertEqual( + len(timeoff_days_2), 4, "There should be 4 timeoff day for this leave" + ) + + total_duration_2 = sum(timeoff_days_2.mapped("leave_duration_by_day")) + self.assertEqual( + total_duration_2, + 4, + "leave_duration_by_day should be 4 day", + ) diff --git a/hr_holidays_timeoff_analysis/views/hr_leave_timeoff_day_views.xml b/hr_holidays_timeoff_analysis/views/hr_leave_timeoff_day_views.xml new file mode 100644 index 0000000..811082f --- /dev/null +++ b/hr_holidays_timeoff_analysis/views/hr_leave_timeoff_day_views.xml @@ -0,0 +1,62 @@ + + + + hr.leave.timeoff.day + + + + + + + + + + + + + hr.leave.timeoff.day + + + + + + + + + + + + hr.leave.timeoff.day + + + + + + + + + + + + Timeoff Days + hr.leave.timeoff.day + list,form,pivot + + {'search_default_filter_date': True} + + + + + 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..e47445e --- /dev/null +++ b/hr_luncheon_voucher/views/hr_employee_views.xml @@ -0,0 +1,76 @@ + + + + + hr.employee.form.lv + hr.employee + + +
+
+ + + + + + + + + + + +
+
+ + + view_employee_tree_lv + hr.employee + + + +