diff --git a/hr-luncheon-voucher/README.rst b/hr-luncheon-voucher/README.rst new file mode 100644 index 0000000..ae4e169 --- /dev/null +++ b/hr-luncheon-voucher/README.rst @@ -0,0 +1,79 @@ +=================== +HR Luncheon Voucher +=================== + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Faccount--analytic-lightgray.png?logo=github + :target: https://github.com/elabore-coop/hr-tools + :alt: elabore/hr-tools + +|badge1| |badge2| |badge3| + +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 ajust the number of luncheon vouchers to distribute, and follow each employee credit. + +Installation +============ +Use Odoo normal module installation procedure to install ``hr-luncheon-voucher``. + +Configuration +============= +1. Go to ``Configuration > Technical > Calendar > Meeting Types`` and define the meeting categories which cancel the daily luncheon voucher distribution. +2. Go to ``Configuration > General Settings > Employees`` and define if employees need to work the whole day to get a luncheon voucher. +3. Go to ``Employees`` and define for each employee the default number of luncheon vouchers to distribute in each distribution campaign. + +Use +=== +- when an calendar event makes the luncheon voucher distribution cancelled (Off-site or free lunch for instance), add the corresponding category to the event. +- for each distribution period, the HR manager should: + - go in ``Employees`` tree view + - select all the employees concerned by luncheon vouchers distribution + - click on header button ``Generate luncheon vouchers allocation`` + - Fill the wizard form +- a voucher allocation request is created for each employee +- HR manager confirms an allocation request when the figures are confirmed +- HR manager marks the requests as "Distributed" when the vouchers has been effectively distributed +- HR manager can correct the allocation requests with ``Back to draft`` button +- employees' luncheon voucher counters are updated considering the vouchers acquired, dued and distributed at each campaign. + +**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 if option ``Half working days cancel luncheon vouchers`` is True or not) + - there is no meeting which cancel the voucher during that day (``Site off`` or ``Free lunch`` meeting for instance) +- an attendance is considered as worked as long as there is no leave on the whole attendance time slot + +Known issues / Roadmap +====================== +None yet. + +Bug Tracker +=========== +Bugs are tracked on `GitHub 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 +------------ +- 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..35e7c96 --- /dev/null +++ b/hr-luncheon-voucher/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import wizard diff --git a/hr-luncheon-voucher/__manifest__.py b/hr-luncheon-voucher/__manifest__.py new file mode 100755 index 0000000..e83cc4a --- /dev/null +++ b/hr-luncheon-voucher/__manifest__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +{ + "name": "HR Luncheon Voucher", + "category": "Human Resources", + "version": "14.0.1.0", + "summary": "Manage luncheon vouchers credit and distribution", + "author": "Elabore", + "website": "https://elabore.coop/", + "installable": True, + "application": True, + "auto_install": False, + "depends": [ + "base", + "calendar", + "hr", + "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", + ], + "qweb": [], +} 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..2173afd --- /dev/null +++ b/hr-luncheon-voucher/data/event_type_data.xml @@ -0,0 +1,16 @@ + + + + + Free lunch + categ_meet_free_lunch + 1 + + + + Off-site + categ_meet_offsite + 1 + + + \ No newline at end of file diff --git a/hr-luncheon-voucher/models/__init__.py b/hr-luncheon-voucher/models/__init__.py new file mode 100644 index 0000000..af55a20 --- /dev/null +++ b/hr-luncheon-voucher/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from . import calendar_event_type +from . import hr_employee +from . import hr_lv_allocation +from . import resource +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..3d7b79f --- /dev/null +++ b/hr-luncheon-voucher/models/calendar_event_type.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +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..a092f5f --- /dev/null +++ b/hr-luncheon-voucher/models/hr_employee.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +from xml.dom.minicompat import EmptyNodeList +from odoo import fields, models, api, _ + + +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", "=", ["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 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..6915d0c --- /dev/null +++ b/hr-luncheon-voucher/models/hr_lv_allocation.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +import math +from datetime import datetime, date, timedelta, time +from dateutil.rrule import rrule, DAILY +from pytz import timezone, UTC, utc + +from odoo import fields, models, api, _ + + +class LuncheonVouchersAllocation(models.Model): + _name = "hr.lv.allocation" + _description = "Luncheon Vouchers Allocation" + _order = "create_date desc" + _inherit = ["mail.thread", "mail.activity.mixin"] + _mail_post_access = "read" + + name = fields.Char('Name') + distrib_campaign_name = fields.Char('Distribution campaign') + state = fields.Selection( + [ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("distributed", "Distributed"), + ], + string="Status", + readonly=True, + tracking=True, + copy=False, + default="draft", + help="The status is set to 'Draft', when an allocation request is created." + + "\nThe status is 'Confirmed', when an allocation request is confirmed by HR manager." + + "\nThe status is 'Distributed', when the luncheon vouchers have been distributed.", + ) + date_from = fields.Datetime( + string=_("Start Date"), + store=True, + readonly=False, + copy=False, + tracking=True, + states={ + "confirmed": [("readonly", True)], + "distributed": [("readonly", True)], + }, + ) + date_to = fields.Datetime( + string=_("End Date"), + store=True, + readonly=False, + copy=False, + tracking=True, + states={ + "confirmed": [("readonly", True)], + "distributed": [("readonly", True)], + }, + ) + employee_id = fields.Many2one( + "hr.employee", + store=True, + string=_("Employee"), + index=True, + readonly=False, + ondelete="restrict", + tracking=True, + states={ + "confirmed": [("readonly", True)], + "distributed": [("readonly", True)], + }, + ) + number_acquired_lv = fields.Integer( + string=_("Acquired Vouchers"), + store=True, + readonly=False, + tracking=True, + states={ + "confirmed": [("readonly", True)], + "distributed": [("readonly", True)], + }, + ) + number_dued_lv = fields.Integer( + string=_("Dued Vouchers"), + store=True, + readonly=False, + tracking=True, + states={ + "confirmed": [("readonly", True)], + "distributed": [("readonly", True)], + }, + ) + number_distributed_lv = fields.Integer( + string=_("Distributed Vouchers"), + store=True, + readonly=False, + tracking=True, + states={ + "confirmed": [("readonly", False)], + "distributed": [("readonly", True)], + }, + ) + + def create(self, values): + res = super(LuncheonVouchersAllocation, self).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)]) + 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): + # The luncheon voucher is acquired only if the employee has worked the entire day + 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): + # The luncheon voucher is acquired only if the employee has worked the entire day + 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..0588fb6 --- /dev/null +++ b/hr-luncheon-voucher/models/res_company.py @@ -0,0 +1,7 @@ +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..88c25ea --- /dev/null +++ b/hr-luncheon-voucher/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +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/models/resource.py b/hr-luncheon-voucher/models/resource.py new file mode 100644 index 0000000..e79f06c --- /dev/null +++ b/hr-luncheon-voucher/models/resource.py @@ -0,0 +1,59 @@ +from datetime import timedelta +import math +from odoo import models, fields + + +class ResourceCalendar(models.Model): + _inherit = "resource.calendar" + + def _retrieve_day_matching_attendances(self, day): + domain = [("calendar_id", "=", self.id),("dayofweek", "=", day.weekday())] + 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 _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) + resource_leaves = self.env["resource.calendar.leaves"].search([("resource_id", "=", resource.id), ("date_from", "<=", attendance_start), ("date_to", ">=", attendance_end)]) + 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-luncheon-voucher/security/ir.model.access.csv b/hr-luncheon-voucher/security/ir.model.access.csv new file mode 100644 index 0000000..1e8775d --- /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 \ No newline at end of file diff --git a/hr-luncheon-voucher/views/event_type.xml b/hr-luncheon-voucher/views/event_type.xml new file mode 100644 index 0000000..469d9cb --- /dev/null +++ b/hr-luncheon-voucher/views/event_type.xml @@ -0,0 +1,13 @@ + + + + event.type.luncheonvoucher + calendar.event.type + + + + + + + + \ No newline at end of file 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..f5c9d9c --- /dev/null +++ b/hr-luncheon-voucher/views/hr_employee_views.xml @@ -0,0 +1,45 @@ + + + + + hr.employee.form.lv + hr.employee + + +
+
+ + + + + + + + + + + +
+
+ + + view_employee_tree_lv + hr.employee + + + +
+
+
+
+
+ +
\ No newline at end of file diff --git a/hr-luncheon-voucher/views/hr_lv_allocation_views.xml b/hr-luncheon-voucher/views/hr_lv_allocation_views.xml new file mode 100644 index 0000000..c812fc2 --- /dev/null +++ b/hr-luncheon-voucher/views/hr_lv_allocation_views.xml @@ -0,0 +1,77 @@ + + + + + hr.lv.allocation.search + hr.lv.allocation + + + + + + + + + + + + hr.lv.allocation.tree + hr.lv.allocation + + +
+
+ + + + + + + + +
+
+
+ + + hr.lv.allocation.form + hr.lv.allocation + +
+
+
+ +

+ +

+ + + + + + + + + + + + +
+
+
+
+ + + Luncheon vouchers allocations + hr.lv.allocation + tree,form + +
\ No newline at end of file diff --git a/hr-luncheon-voucher/views/menus.xml b/hr-luncheon-voucher/views/menus.xml new file mode 100644 index 0000000..fe896b4 --- /dev/null +++ b/hr-luncheon-voucher/views/menus.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/hr-luncheon-voucher/views/res_config_settings_views.xml b/hr-luncheon-voucher/views/res_config_settings_views.xml new file mode 100644 index 0000000..f0eb2c1 --- /dev/null +++ b/hr-luncheon-voucher/views/res_config_settings_views.xml @@ -0,0 +1,27 @@ + + + + res.config.settings.view.form.inherit.lv + res.config.settings + + + + +

Employee Luncheon Vouchers

+
+
+
+ +
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/hr-luncheon-voucher/wizard/__init__.py b/hr-luncheon-voucher/wizard/__init__.py new file mode 100644 index 0000000..9da2498 --- /dev/null +++ b/hr-luncheon-voucher/wizard/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import generate_lv_allocations_wizard diff --git a/hr-luncheon-voucher/wizard/generate_lv_allocations_wizard.py b/hr-luncheon-voucher/wizard/generate_lv_allocations_wizard.py new file mode 100644 index 0000000..90516bc --- /dev/null +++ b/hr-luncheon-voucher/wizard/generate_lv_allocations_wizard.py @@ -0,0 +1,29 @@ +from odoo import _, api, fields, models + + +class GenerateLVAllocationRequests(models.TransientModel): + _name = "generate.lv.allocation.requests" + _description = "Generate Luncheon Vouchers Allocations Requests" + distrib_campaign_name = fields.Char('Distribution campaign', required=True) + date_from = fields.Datetime( + string=_("Start Date"), + required=True + ) + date_to = fields.Datetime( + string=_("End Date"), + required=True + ) + + def generate_lv_allocations(self): + values = {} + values["distrib_campaign_name"] = self.distrib_campaign_name + values["date_from"] = self.date_from + values["date_to"] = self.date_to + employees = self.env["hr.employee"].search( + [ + ("id", "in", self.env.context.get("active_ids")), + ] + ) + employees.generate_mass_lv_allocation(values) + # Open lv allocation tree view + return self.env["ir.actions.act_window"]._for_xml_id("hr-luncheon-voucher.act_lv_allocations") diff --git a/hr-luncheon-voucher/wizard/generate_lv_allocations_wizard.xml b/hr-luncheon-voucher/wizard/generate_lv_allocations_wizard.xml new file mode 100644 index 0000000..2851c4e --- /dev/null +++ b/hr-luncheon-voucher/wizard/generate_lv_allocations_wizard.xml @@ -0,0 +1,32 @@ + + + + + lv.allocations.requests.wizard + generate.lv.allocation.requests + +
+ + + + + + + +
+
+
+
+
+ + + Create Luncheon Vouchers allocations requests + generate.lv.allocation.requests + form + + new + +
+
\ No newline at end of file