From 124219173c1886265cf1f64886a47d42fd04a738 Mon Sep 17 00:00:00 2001 From: dessanhemrayev Date: Thu, 6 Jul 2023 03:39:11 +0300 Subject: [PATCH 01/11] [IMP] hr_timesheet_purchase_order: create PO automatically [FIX]hr_timesheet_purchase_order:change field type Change field type `selection` to `integer` Add new method border min max and chech month `february` Fix and update tests [FIX] hr_timesheet_purchase_order: add method --- hr_timesheet_purchase_order/__manifest__.py | 2 + .../data/hr_timesheet_cron.xml | 11 + .../models/__init__.py | 2 + .../models/hr_employee.py | 7 + .../models/hr_employee_base.py | 244 ++++++- .../models/hr_timesheet_recurrence.py | 360 +++++++++++ .../readme/DESCRIPTION.rst | 3 +- hr_timesheet_purchase_order/readme/USAGE.rst | 8 +- .../security/ir.model.access.csv | 2 + hr_timesheet_purchase_order/tests/__init__.py | 1 + .../test_create_timesheet_po_recurrence.py | 595 ++++++++++++++++++ .../views/hr_employee_view.xml | 127 ++++ 12 files changed, 1353 insertions(+), 9 deletions(-) create mode 100644 hr_timesheet_purchase_order/data/hr_timesheet_cron.xml create mode 100644 hr_timesheet_purchase_order/models/hr_employee.py create mode 100644 hr_timesheet_purchase_order/models/hr_timesheet_recurrence.py create mode 100644 hr_timesheet_purchase_order/security/ir.model.access.csv create mode 100644 hr_timesheet_purchase_order/tests/test_create_timesheet_po_recurrence.py diff --git a/hr_timesheet_purchase_order/__manifest__.py b/hr_timesheet_purchase_order/__manifest__.py index c5e5f1b15..2161f8ba5 100644 --- a/hr_timesheet_purchase_order/__manifest__.py +++ b/hr_timesheet_purchase_order/__manifest__.py @@ -13,7 +13,9 @@ "external_dependencies": {}, "demo": [], "data": [ + "security/ir.model.access.csv", "data/ir_actions_server.xml", + "data/hr_timesheet_cron.xml", "views/hr_employee_view.xml", "views/hr_timesheet_sheet_view.xml", "views/purchase_order_view.xml", diff --git a/hr_timesheet_purchase_order/data/hr_timesheet_cron.xml b/hr_timesheet_purchase_order/data/hr_timesheet_cron.xml new file mode 100644 index 000000000..41019a063 --- /dev/null +++ b/hr_timesheet_purchase_order/data/hr_timesheet_cron.xml @@ -0,0 +1,11 @@ + + + + HR Timesheet : Auto generate Purchase Order + + code + model._cron_generate_auto_po() + days + -1 + + diff --git a/hr_timesheet_purchase_order/models/__init__.py b/hr_timesheet_purchase_order/models/__init__.py index 920f66323..865ca1552 100644 --- a/hr_timesheet_purchase_order/models/__init__.py +++ b/hr_timesheet_purchase_order/models/__init__.py @@ -1,4 +1,6 @@ +from . import hr_timesheet_recurrence from . import hr_employee_base +from . import hr_employee from . import hr_timesheet_sheet from . import res_config_settings from . import res_company diff --git a/hr_timesheet_purchase_order/models/hr_employee.py b/hr_timesheet_purchase_order/models/hr_employee.py new file mode 100644 index 000000000..d9d402eb3 --- /dev/null +++ b/hr_timesheet_purchase_order/models/hr_employee.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class HR(models.Model): + _inherit = "hr.employee" + + recurrence_id = fields.Many2one("hr.timesheet.recurrence", copy=False) diff --git a/hr_timesheet_purchase_order/models/hr_employee_base.py b/hr_timesheet_purchase_order/models/hr_employee_base.py index 4b5359484..4315da1b6 100644 --- a/hr_timesheet_purchase_order/models/hr_employee_base.py +++ b/hr_timesheet_purchase_order/models/hr_employee_base.py @@ -1,10 +1,246 @@ -from odoo import fields, models +from datetime import timedelta + +from odoo import api, fields, models + +from .hr_timesheet_recurrence import DAYS class HrEmployeeBase(models.AbstractModel): _inherit = "hr.employee.base" - allow_generate_purchase_order = fields.Boolean( - string="Generate POs from Timesheet", default=False - ) + allow_generate_purchase_order = fields.Boolean(string="Generate POs from Timesheet") billing_partner_id = fields.Many2one("res.partner") + + is_auto_po_generate = fields.Boolean(string="Automatic PO generation") + recurrence_id = fields.Many2one("hr.timesheet.recurrence", copy=False) + + is_send_po = fields.Boolean(string="Send RFQ by email after creation") + + next_recurrence_date = fields.Date(related="recurrence_id.next_recurrence_date") + repeat_interval = fields.Integer( + string="Repeat Every", default=1, compute="_compute_repeat", readonly=False + ) + repeat_unit = fields.Selection( + [ + ("day", "Days"), + ("week", "Weeks"), + ("month", "Months"), + ("year", "Years"), + ], + default="week", + compute="_compute_repeat", + readonly=False, + ) + repeat_type = fields.Selection( + [ + ("forever", "Forever"), + ("until", "End Date"), + ("after", "Number of Repetitions"), + ], + default="forever", + string="Until", + compute="_compute_repeat", + readonly=False, + ) + repeat_until = fields.Date( + string="End Date", compute="_compute_repeat", readonly=False + ) + repeat_number = fields.Integer( + string="Repetitions", default=1, compute="_compute_repeat", readonly=False + ) + + repeat_on_month = fields.Selection( + [ + ("date", "Date of the Month"), + ("day", "Day of the Month"), + ], + default="date", + compute="_compute_repeat", + readonly=False, + ) + + repeat_on_year = fields.Selection( + [ + ("date", "Date of the Year"), + ("day", "Day of the Year"), + ], + default="date", + compute="_compute_repeat", + readonly=False, + ) + + mon = fields.Boolean(string="Mon", compute="_compute_repeat", readonly=False) + tue = fields.Boolean(string="Tue", compute="_compute_repeat", readonly=False) + wed = fields.Boolean(string="Wed", compute="_compute_repeat", readonly=False) + thu = fields.Boolean(string="Thu", compute="_compute_repeat", readonly=False) + fri = fields.Boolean(string="Fri", compute="_compute_repeat", readonly=False) + sat = fields.Boolean(string="Sat", compute="_compute_repeat", readonly=False) + sun = fields.Boolean(string="Sun", compute="_compute_repeat", readonly=False) + + repeat_day = fields.Integer( + compute="_compute_repeat", + readonly=False, + ) + + @api.onchange("repeat_day", "repeat_month") + def _onchange_repeat_day(self): + if 0 > self.repeat_day or self.repeat_day > 31: + self.repeat_day = 1 + if self.repeat_month == "february" and self.repeat_day > 29: + self.repeat_day = 28 + + repeat_week = fields.Selection( + [ + ("first", "First"), + ("second", "Second"), + ("third", "Third"), + ("last", "Last"), + ], + default="first", + compute="_compute_repeat", + readonly=False, + ) + repeat_weekday = fields.Selection( + [ + ("mon", "Monday"), + ("tue", "Tuesday"), + ("wed", "Wednesday"), + ("thu", "Thursday"), + ("fri", "Friday"), + ("sat", "Saturday"), + ("sun", "Sunday"), + ], + string="Day Of The Week", + compute="_compute_repeat", + readonly=False, + ) + repeat_month = fields.Selection( + [ + ("january", "January"), + ("february", "February"), + ("march", "March"), + ("april", "April"), + ("may", "May"), + ("june", "June"), + ("july", "July"), + ("august", "August"), + ("september", "September"), + ("october", "October"), + ("november", "November"), + ("december", "December"), + ], + compute="_compute_repeat", + readonly=False, + ) + + repeat_show_dow = fields.Boolean(compute="_compute_repeat_visibility") + repeat_show_day = fields.Boolean(compute="_compute_repeat_visibility") + repeat_show_week = fields.Boolean(compute="_compute_repeat_visibility") + repeat_show_month = fields.Boolean(compute="_compute_repeat_visibility") + + @api.depends( + "is_auto_po_generate", "repeat_unit", "repeat_on_month", "repeat_on_year" + ) + def _compute_repeat_visibility(self): + """Based on the selected parameters sets + the fields that should be visible to the user + """ + for item in self: + item.repeat_show_day = ( + item.is_auto_po_generate + and (item.repeat_unit == "month" and item.repeat_on_month == "date") + or (item.repeat_unit == "year" and item.repeat_on_year == "date") + ) + item.repeat_show_week = ( + item.is_auto_po_generate + and (item.repeat_unit == "month" and item.repeat_on_month == "day") + or (item.repeat_unit == "year" and item.repeat_on_year == "day") + ) + item.repeat_show_dow = ( + item.is_auto_po_generate and item.repeat_unit == "week" + ) + item.repeat_show_month = ( + item.is_auto_po_generate and item.repeat_unit == "year" + ) + + @api.depends("is_auto_po_generate") + def _compute_repeat(self): + rec_fields = self._get_recurrence_fields() + defaults = self.default_get(rec_fields) + for employee in self: + for f in rec_fields: + if employee.recurrence_id: + employee[f] = employee.recurrence_id[f] + else: + employee[f] = ( + defaults.get(f) if employee.is_auto_po_generate else False + ) + + @api.model + def _get_recurrence_fields(self): + return [ + "repeat_interval", + "repeat_unit", + "repeat_type", + "repeat_until", + "repeat_number", + "repeat_on_month", + "repeat_on_year", + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", + "repeat_day", + "repeat_week", + "repeat_month", + "repeat_weekday", + ] + + @api.model + def default_get(self, default_fields): + vals = super().default_get(default_fields) + days = list(DAYS.keys()) + week_start = fields.Datetime.today().weekday() + if all(d in default_fields for d in days): + vals[days[week_start]] = True + if "repeat_day" in default_fields: + vals["repeat_day"] = str(fields.Datetime.today().day) + if "repeat_month" in default_fields: + vals["repeat_month"] = self._fields.get("repeat_month").selection[ + fields.Datetime.today().month - 1 + ][0] + if "repeat_until" in default_fields: + vals["repeat_until"] = fields.Date.today() + timedelta(days=7) + if "repeat_weekday" in default_fields: + vals["repeat_weekday"] = self._fields.get("repeat_weekday").selection[ + week_start + ][0] + return vals + + def write(self, vals): + rec_fields = vals.keys() & self._get_recurrence_fields() + if "is_auto_po_generate" in vals and not vals.get("is_auto_po_generate"): + self.recurrence_id.unlink() + if rec_fields: + rec_values = {rec_field: vals[rec_field] for rec_field in rec_fields} + for timesheet in self: + if timesheet.recurrence_id: + timesheet.recurrence_id.write(rec_values) + elif vals.get("is_auto_po_generate"): + rec_values["next_recurrence_date"] = fields.Datetime.today() + recurrence = self.env["hr.timesheet.recurrence"].create(rec_values) + timesheet.recurrence_id = recurrence.id + return super().write(vals) + + @api.model + def create(self, vals): + rec_fields = vals.keys() & self._get_recurrence_fields() + if rec_fields and vals.get("is_auto_po_generate"): + rec_values = {rec_field: vals[rec_field] for rec_field in rec_fields} + rec_values["next_recurrence_date"] = fields.Datetime.today() + recurrence = self.env["hr.timesheet.recurrence"].create(rec_values) + vals["recurrence_id"] = recurrence.id + return super().create(vals) diff --git a/hr_timesheet_purchase_order/models/hr_timesheet_recurrence.py b/hr_timesheet_purchase_order/models/hr_timesheet_recurrence.py new file mode 100644 index 000000000..fa42f26c2 --- /dev/null +++ b/hr_timesheet_purchase_order/models/hr_timesheet_recurrence.py @@ -0,0 +1,360 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from calendar import monthrange + +from dateutil.relativedelta import relativedelta +from dateutil.rrule import ( + DAILY, + FR, + MO, + MONTHLY, + SA, + SU, + TH, + TU, + WE, + WEEKLY, + YEARLY, + rrule, +) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +MONTHS = { + "january": 31, + "february": 28, + "march": 31, + "april": 30, + "may": 31, + "june": 30, + "july": 31, + "august": 31, + "september": 30, + "october": 31, + "november": 30, + "december": 31, +} + +DAYS = { + "mon": MO, + "tue": TU, + "wed": WE, + "thu": TH, + "fri": FR, + "sat": SA, + "sun": SU, +} + +WEEKS = { + "first": 1, + "second": 2, + "third": 3, + "last": 4, +} + + +class HRTimeSheetRecurrence(models.Model): + _name = "hr.timesheet.recurrence" + _description = "HR TimeSheet Recurrence" + + task_ids = fields.One2many("hr.employee", "recurrence_id") + next_recurrence_date = fields.Date() + recurrence_left = fields.Integer(string="Number of tasks left to create") + + repeat_interval = fields.Integer(string="Repeat Every", default=1) + repeat_unit = fields.Selection( + [ + ("day", "Days"), + ("week", "Weeks"), + ("month", "Months"), + ("year", "Years"), + ], + default="week", + ) + repeat_type = fields.Selection( + [ + ("forever", "Forever"), + ("until", "End Date"), + ("after", "Number of Repetitions"), + ], + default="forever", + string="Until", + ) + repeat_until = fields.Date(string="End Date") + repeat_number = fields.Integer(string="Repetitions") + + repeat_on_month = fields.Selection( + [ + ("date", "Date of the Month"), + ("day", "Day of the Month"), + ] + ) + + repeat_on_year = fields.Selection( + [ + ("date", "Date of the Year"), + ("day", "Day of the Year"), + ] + ) + + mon = fields.Boolean(string="Mon") + tue = fields.Boolean(string="Tue") + wed = fields.Boolean(string="Wed") + thu = fields.Boolean(string="Thu") + fri = fields.Boolean(string="Fri") + sat = fields.Boolean(string="Sat") + sun = fields.Boolean(string="Sun") + + repeat_day = fields.Integer() + repeat_week = fields.Selection( + [ + ("first", "First"), + ("second", "Second"), + ("third", "Third"), + ("last", "Last"), + ] + ) + repeat_weekday = fields.Selection( + [ + ("mon", "Monday"), + ("tue", "Tuesday"), + ("wed", "Wednesday"), + ("thu", "Thursday"), + ("fri", "Friday"), + ("sat", "Saturday"), + ("sun", "Sunday"), + ], + string="Day Of The Week", + readonly=False, + ) + repeat_month = fields.Selection( + [ + ("january", "January"), + ("february", "February"), + ("march", "March"), + ("april", "April"), + ("may", "May"), + ("june", "June"), + ("july", "July"), + ("august", "August"), + ("september", "September"), + ("october", "October"), + ("november", "November"), + ("december", "December"), + ] + ) + + @api.constrains("repeat_unit", "mon", "tue", "wed", "thu", "fri", "sat", "sun") + def _check_recurrence_days(self): + for timesheet in self.filtered(lambda p: p.repeat_unit == "week"): + if not any([getattr(timesheet, attr_name) for attr_name in DAYS]): + raise ValidationError(_("You should select a least one day")) + + @api.constrains("repeat_interval") + def _check_repeat_interval(self): + if self.filtered(lambda t: t.repeat_interval <= 0): + raise ValidationError(_("The interval should be greater than 0")) + + @api.constrains("repeat_number", "repeat_type") + def _check_repeat_number(self): + if self.filtered(lambda t: t.repeat_type == "after" and t.repeat_number <= 0): + raise ValidationError(_("Should repeat at least once")) + + @api.constrains("repeat_type", "repeat_until") + def _check_repeat_until_date(self): + today = fields.Date.today() + if self.filtered(lambda t: t.repeat_type == "until" and t.repeat_until < today): + raise ValidationError(_("The end date should be in the future")) + + @api.constrains( + "repeat_unit", "repeat_on_month", "repeat_day", "repeat_type", "repeat_until" + ) + def _check_repeat_until_month(self): + if self.filtered( + lambda r: r.repeat_type == "until" + and r.repeat_unit == "month" + and r.repeat_until + and r.repeat_on_month == "date" + and int(r.repeat_day) > r.repeat_until.day + and monthrange(r.repeat_until.year, r.repeat_until.month)[1] + != r.repeat_until.day + ): + raise ValidationError( + _( + "The end date should be after the day of " + "the month or the last day of the month" + ) + ) + + @api.constrains("repeat_day", "repeat_month") + def _check_repeat_day_or_month(self): + if 0 > self.repeat_day or self.repeat_day > 31: + raise ValidationError( + _( + "The number of days in a month cannot be negative " + "or more than 31 days" + ) + ) + + def _get_weekdays(self, n=1): + self.ensure_one() + if self.repeat_unit == "week": + return [fn(n) for day, fn in DAYS.items() if self[day]] + return [DAYS.get(self.repeat_weekday)(n)] + + @api.model + def _get_next_recurring_dates( + self, + date_start, + repeat_interval, + repeat_unit, + repeat_type, + repeat_until, + repeat_on_month, + repeat_on_year, + weekdays, + repeat_day, + repeat_week, + repeat_month, + **kwargs + ): + """Based on the selected parameters returns the following date""" + + count = kwargs.get("count", 1) + rrule_kwargs = {"interval": repeat_interval or 1, "dtstart": date_start} + repeat_day = int(repeat_day) + if repeat_type == "until": + rrule_kwargs["until"] = ( + repeat_until if repeat_until else fields.Date.today() + ) + else: + rrule_kwargs["count"] = count + + if ( + repeat_unit == "week" + or (repeat_unit == "month" and repeat_on_month == "day") + or (repeat_unit == "year" and repeat_on_year == "day") + ): + rrule_kwargs["byweekday"] = weekdays + if repeat_unit == "day": + rrule_kwargs["freq"] = DAILY + elif repeat_unit == "month": + rrule_kwargs["freq"] = MONTHLY + if repeat_on_month == "date": + return self._get_dates_for_next_recurrence( + date_start, + repeat_day, + repeat_interval, + repeat_until, + repeat_type, + count, + ) + elif repeat_unit == "year": + rrule_kwargs["freq"] = YEARLY + month = list(MONTHS.keys()).index(repeat_month) + 1 + rrule_kwargs["bymonth"] = month + if repeat_on_year == "date": + rrule_kwargs["bymonthday"] = min(repeat_day, MONTHS.get(repeat_month)) + rrule_kwargs["bymonth"] = month + else: + rrule_kwargs["freq"] = WEEKLY + rules = rrule(**rrule_kwargs) + return list(rules) if rules else [] + + def _get_dates_for_next_recurrence( + self, date_start, repeat_day, repeat_interval, repeat_until, repeat_type, count + ): + dates = [] + start = date_start - relativedelta(days=1) + start = start.replace( + day=min(repeat_day, monthrange(start.year, start.month)[1]) + ) + if start < date_start: + # Ensure the next recurrence is in the future + start += relativedelta(months=repeat_interval) + start = start.replace( + day=min(repeat_day, monthrange(start.year, start.month)[1]) + ) + can_generate_date = ( + (lambda: start <= repeat_until) + if repeat_type == "until" + else (lambda: len(dates) < count) + ) + while can_generate_date(): + dates.append(start) + start += relativedelta(months=repeat_interval) + start = start.replace( + day=min(repeat_day, monthrange(start.year, start.month)[1]) + ) + return dates + + def _set_next_recurrence_date(self): + today = fields.Date.today() + tomorrow = today + relativedelta(days=1) + for recurrence in self.filtered( + lambda r: r.repeat_type == "after" + and r.recurrence_left >= 0 + or r.repeat_type == "until" + and r.repeat_until >= today + or r.repeat_type == "forever" + ): + if recurrence.repeat_type == "after" and recurrence.recurrence_left == 0: + recurrence.next_recurrence_date = False + else: + next_date = self._get_next_recurring_dates( + tomorrow, + recurrence.repeat_interval, + recurrence.repeat_unit, + recurrence.repeat_type, + recurrence.repeat_until, + recurrence.repeat_on_month, + recurrence.repeat_on_year, + recurrence._get_weekdays(), + recurrence.repeat_day, + recurrence.repeat_week, + recurrence.repeat_month, + count=1, + ) + recurrence.next_recurrence_date = next_date[0] if next_date else False + + def _create_purchase_order(self): + for item in self.mapped("task_ids"): + timesheet = item.timesheet_sheet_ids.filtered( + lambda t: not t.purchase_order_id and t.state == "done" + ) + if not timesheet: + continue + timesheet = timesheet[0] + timesheet.action_create_purchase_order() + if item.is_send_po: + email_act = timesheet.purchase_order_id.action_rfq_send() + email_ctx = email_act.get("context", {}) + timesheet.purchase_order_id.with_context( + **email_ctx + ).message_post_with_template(email_ctx.get("default_template_id")) + + @api.model + def _cron_generate_auto_po(self): + today = fields.Date.today() + recurring_today = self.search([("next_recurrence_date", "<=", today)]) + recurring_today._create_purchase_order() + for recurrence in recurring_today.filtered(lambda r: r.repeat_type == "after"): + recurrence.recurrence_left -= 1 + recurring_today._set_next_recurrence_date() + + @api.model + def create(self, vals): + if vals.get("repeat_number"): + vals["recurrence_left"] = vals.get("repeat_number") + res = super().create(vals) + res._set_next_recurrence_date() + return res + + def write(self, vals): + if vals.get("repeat_number"): + vals["recurrence_left"] = vals.get("repeat_number") + res = super().write(vals) + if "next_recurrence_date" not in vals: + self._set_next_recurrence_date() + return res diff --git a/hr_timesheet_purchase_order/readme/DESCRIPTION.rst b/hr_timesheet_purchase_order/readme/DESCRIPTION.rst index 93403f956..2e46394df 100644 --- a/hr_timesheet_purchase_order/readme/DESCRIPTION.rst +++ b/hr_timesheet_purchase_order/readme/DESCRIPTION.rst @@ -2,5 +2,4 @@ Create purchase orders from timesheet sheets ============================================ -This module allows you to create Purchase Orders based on the employee timesheet sheet. -This might be usefull for subcontrating and outsourcing. +This module allows you to create Purchase Orders based on the employee timesheet sheet, both manually and automatically. This can be useful for subcontrating and outsourcing. diff --git a/hr_timesheet_purchase_order/readme/USAGE.rst b/hr_timesheet_purchase_order/readme/USAGE.rst index b8dd1b509..48ef2d0f5 100644 --- a/hr_timesheet_purchase_order/readme/USAGE.rst +++ b/hr_timesheet_purchase_order/readme/USAGE.rst @@ -1,5 +1,7 @@ -* Go to Employees app > select an employee > go to HR Settings tab and enable the "Generate Purchase Order from timesheet sheet" checkbox -* Select the Billing partner for which the PO will be created -* Click the *Create Purchase Order* in the *Timesheet Sheet* form to create a new RFQ +Go to Employees app > select an employee > go to HR Settings tab and enable the "Generate POs from timesheet sheet" checkbox +Select the Billing partner which will be the vendor in the created POs +By enabling "Automatic PO generation" user can set the recurrence of PO generation and whether the RFQ report should be sent automatically after creation + +In the Timesheet Sheet form, use the Create Purchase Order button to create a new RFQ. A server action to create POs is also available in tree view. diff --git a/hr_timesheet_purchase_order/security/ir.model.access.csv b/hr_timesheet_purchase_order/security/ir.model.access.csv new file mode 100644 index 000000000..2aab5469f --- /dev/null +++ b/hr_timesheet_purchase_order/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_timesheet_recurrence,hr.timesheet.recurrence,model_hr_timesheet_recurrence,base.group_user,1,1,1,1 diff --git a/hr_timesheet_purchase_order/tests/__init__.py b/hr_timesheet_purchase_order/tests/__init__.py index 397cdf030..d8930920a 100644 --- a/hr_timesheet_purchase_order/tests/__init__.py +++ b/hr_timesheet_purchase_order/tests/__init__.py @@ -1 +1,2 @@ from . import test_create_timesheet_purchase_order +from . import test_create_timesheet_po_recurrence diff --git a/hr_timesheet_purchase_order/tests/test_create_timesheet_po_recurrence.py b/hr_timesheet_purchase_order/tests/test_create_timesheet_po_recurrence.py new file mode 100644 index 000000000..477436c25 --- /dev/null +++ b/hr_timesheet_purchase_order/tests/test_create_timesheet_po_recurrence.py @@ -0,0 +1,595 @@ +from datetime import date, datetime + +from dateutil.rrule import FR, MO, SA, TH +from freezegun import freeze_time + +from odoo import _ +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import Form, SavepointCase + + +class TestTimesheetPOrecurrence(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.product = cls.env["product.product"].create( + { + "name": "Product recurrence", + "default_code": "test", + } + ) + officer_group = cls.env.ref("hr.group_hr_user") + multi_company_group = cls.env.ref("base.group_multi_company") + sheet_user_group = cls.env.ref("hr_timesheet.group_hr_timesheet_user") + project_user_group = cls.env.ref("project.group_project_user") + cls.sheet_model = cls.env["hr_timesheet.sheet"].with_context( + tracking_disable=True + ) + cls.sheet_line_model = cls.env["hr_timesheet.sheet.line"] + cls.project_model = cls.env["project.project"] + cls.task_model = cls.env["project.task"] + cls.aal_model = cls.env["account.analytic.line"] + cls.aaa_model = cls.env["account.analytic.account"] + cls.employee_model = cls.env["hr.employee"] + cls.department_model = cls.env["hr.department"] + cls.hr_timesheet_recurrence_model = cls.env["hr.timesheet.recurrence"] + config_obj = cls.env["res.config.settings"] + config = config_obj.create({"timesheet_product_id": cls.product.id}) + config.execute() + + cls.user = ( + cls.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Test User recurrence", + "login": "test_user_recurrence", + "email": "test_recurrence@oca.com", + "groups_id": [ + ( + 6, + 0, + [ + officer_group.id, + sheet_user_group.id, + project_user_group.id, + multi_company_group.id, + ], + ) + ], + } + ) + ) + + cls.project = cls.project_model.create( + { + "name": "Project", + "allow_timesheets": True, + "user_id": cls.user.id, + } + ) + cls.task = cls.task_model.create( + { + "name": "Task 1", + "project_id": cls.project.id, + } + ) + + def test_create_purchase_order_recurrence_simple(self): + with freeze_time("2020-03-01"): + form = Form(self.employee_model) + form.name = "Test Employee recurrence" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + + form.repeat_interval = 5 + form.repeat_unit = "month" + form.repeat_type = "after" + form.repeat_number = 10 + form.repeat_on_month = "date" + form.repeat_day = "31" + employee = form.save() + + self.assertTrue( + bool(employee.is_auto_po_generate), "should enable a recurrence" + ) + employee.update(dict(repeat_interval=2, repeat_number=11)) + self.assertEqual( + employee.repeat_interval, 2, "recurrence should be updated" + ) + self.assertEqual(employee.repeat_number, 11, "recurrence should be updated") + self.assertEqual( + employee.recurrence_id.recurrence_left, 11, "Must be equal 11" + ) + self.assertEqual( + employee.next_recurrence_date, + date(2020, 3, 31), + "Must be equal {dt}".format(dt=date(2020, 3, 31)), + ) + self.assertEqual( + employee.recurrence_id.next_recurrence_date, + date(2020, 3, 31), + "Must be equal {dt}".format(dt=date(2020, 3, 31)), + ) + self.assertEqual( + employee.next_recurrence_date, + employee.recurrence_id.next_recurrence_date, + "Must be equal {dt}".format(dt=date(2020, 3, 31)), + ) + employee.is_auto_po_generate = False + self.assertFalse( + bool(employee.is_auto_po_generate), "The recurrence should be disabled" + ) + self.assertFalse( + bool(employee.recurrence_id), "The recurrence should be deleted" + ) + # enabled is_auto_po_generate + with Form(employee) as form: + form.is_auto_po_generate = True + form.repeat_interval = 5 + form.repeat_unit = "month" + form.repeat_type = "after" + form.repeat_number = 10 + form.repeat_on_month = "date" + form.repeat_day = "31" + employee = form.save() + + self.assertTrue( + bool(employee.recurrence_id), "The recurrence should be enabled" + ) + sheet_form = Form(self.sheet_model.with_user(self.user)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test1" + timesheet.project_id = self.project + + with sheet_form.timesheet_ids.edit(0) as timesheet: + timesheet.unit_amount = 1.0 + + sheet = sheet_form.save() + self.assertFalse(sheet.purchase_order_id) + sheet.action_timesheet_confirm() + self.assertEqual(sheet.state, "confirm") + sheet.action_timesheet_done() + with freeze_time("2020-02-29"): + self.hr_timesheet_recurrence_model._cron_generate_auto_po() + + def test_onchange_repeat_day(self): + with freeze_time("2020-02-01"): + form = Form(self.employee_model) + form.name = "Test Employee recurrence" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + + form.repeat_interval = 5 + form.repeat_unit = "month" + form.repeat_type = "after" + form.repeat_number = 10 + form.repeat_on_month = "date" + form.repeat_day = -1 + employee = form.save() + self.assertEqual(employee.repeat_day, 1, "Must be equal 1") + + with self.assertRaisesRegex( + ValidationError, + ( + _( + "The number of days in a month cannot be negative " + "or more than 31 days" + ) + ), + ): + employee.recurrence_id.repeat_day = -1 + + def test_recurrence_cron_repeat_after(self): + with freeze_time("2020-01-01"): + form = Form(self.employee_model) + form.name = "Test Employee recurrence cron_repeat_after" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + form.repeat_interval = 1 + form.repeat_unit = "month" + form.repeat_type = "after" + form.repeat_number = 2 + form.repeat_on_month = "date" + form.repeat_day = "15" + employee = form.save() + + self.assertEqual(employee.next_recurrence_date, date(2020, 1, 15)) + + sheet_form = Form(self.sheet_model.with_user(self.user)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test2" + timesheet.project_id = self.project + + with sheet_form.timesheet_ids.edit(0) as timesheet: + timesheet.unit_amount = 1.0 + + sheet = sheet_form.save() + self.assertFalse(sheet.purchase_order_id) + + # cannot create purchase order (sheet not approved) + with self.assertRaises(UserError): + sheet.action_create_purchase_order() + sheet.action_timesheet_confirm() + self.assertEqual(sheet.state, "confirm") + sheet.action_timesheet_done() + self.assertEqual(len(employee.timesheet_sheet_ids), 1) + self.hr_timesheet_recurrence_model._cron_generate_auto_po() + + with freeze_time("2020-01-15"): + self.hr_timesheet_recurrence_model._cron_generate_auto_po() + with freeze_time("2020-02-15"): + self.hr_timesheet_recurrence_model._cron_generate_auto_po() + + def test_recurrence_cron_repeat_until(self): + with freeze_time("2020-01-01"): + form = Form(self.employee_model) + form.name = "test recurring task" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + + form.repeat_interval = 1 + form.repeat_unit = "month" + form.repeat_type = "until" + form.repeat_until = date(2020, 2, 20) + form.repeat_on_month = "date" + form.repeat_day = "15" + employee = form.save() + + sheet_form = Form(self.sheet_model.with_user(self.user)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test until month" + timesheet.project_id = self.project + + with sheet_form.timesheet_ids.edit(0) as timesheet: + timesheet.unit_amount = 1.0 + + sheet = sheet_form.save() + self.assertFalse(sheet.purchase_order_id) + + # cannot create purchase order (sheet not approved) + with self.assertRaises(UserError): + sheet.action_create_purchase_order() + sheet.action_timesheet_confirm() + self.assertEqual(sheet.state, "confirm") + sheet.action_timesheet_done() + self.assertEqual(len(employee.timesheet_sheet_ids), 1) + + self.assertEqual( + employee.recurrence_id.next_recurrence_date, + date(2020, 1, 15), + "Must be equal {dt}".format(dt=date(2020, 1, 15)), + ) + + with freeze_time("2020-01-15"): + self.assertEqual(len(employee.timesheet_sheet_ids), 1) + self.hr_timesheet_recurrence_model._cron_generate_auto_po() + self.assertEqual( + employee.recurrence_id.next_recurrence_date, + date(2020, 2, 15), + "Must be equal {dt}".format(dt=date(2020, 2, 15)), + ) + + with freeze_time("2020-02-15"): + self.hr_timesheet_recurrence_model._cron_generate_auto_po() + self.assertFalse( + employee.recurrence_id.next_recurrence_date, + "Must be equal False", + ) + + def test_recurrence_week_day(self): + with self.assertRaisesRegex( + ValidationError, (_("You should select a least one day")) + ): + form = Form(self.employee_model) + form.name = "Test Employee recurrence week_day" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + form.repeat_interval = 1 + form.repeat_unit = "week" + form.repeat_type = "after" + form.repeat_number = 2 + form.mon = False + form.tue = False + form.wed = False + form.thu = False + form.fri = False + form.sat = False + form.sun = False + form.save() + + def test_recurrence_repeat_interval(self): + with self.assertRaisesRegex( + ValidationError, (_("The interval should be greater than 0")) + ): + form = Form(self.employee_model) + form.name = "Test Employee recurrence week_day" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + form.repeat_interval = 0 + form.repeat_type = "after" + form.save() + + def test_repeat_number(self): + with self.assertRaisesRegex( + ValidationError, (_("Should repeat at least once")) + ): + form = Form(self.employee_model) + form.name = "Test Employee recurrence" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + form.repeat_interval = 1 + form.repeat_type = "after" + form.repeat_number = 0 + form.mon = True + form.tue = False + form.wed = False + form.thu = False + form.fri = False + form.sat = False + form.sun = False + form.save() + + def test_repeat_until_date(self): + with freeze_time("2023-08-03"): + with self.assertRaisesRegex( + ValidationError, (_("The end date should be in the future")) + ): + form = Form(self.employee_model) + form.name = "Test Employee recurrence" + form.user_id = self.user + form.billing_partner_id = self.user.partner_id + form.allow_generate_purchase_order = True + form.is_auto_po_generate = True + form.repeat_interval = 1 + form.repeat_type = "until" + form.repeat_until = "2023-08-01" + form.mon = False + form.tue = False + form.wed = False + form.thu = True + form.fri = False + form.sat = False + form.sun = False + form.save() + + def test_recurrence_next_dates_week(self): + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2020, 1, 1), + repeat_interval=1, + repeat_unit="week", + repeat_type=False, + repeat_until=False, + repeat_on_month=False, + repeat_on_year=False, + weekdays=False, + repeat_day=False, + repeat_week=False, + repeat_month=False, + count=5, + ) + + self.assertEqual(dates[0], datetime(2020, 1, 6, 0, 0)) + self.assertEqual(dates[1], datetime(2020, 1, 13, 0, 0)) + self.assertEqual(dates[2], datetime(2020, 1, 20, 0, 0)) + self.assertEqual(dates[3], datetime(2020, 1, 27, 0, 0)) + self.assertEqual(dates[4], datetime(2020, 2, 3, 0, 0)) + + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2020, 1, 1), + repeat_interval=3, + repeat_unit="week", + repeat_type="until", + repeat_until=date(2020, 2, 1), + repeat_on_month=False, + repeat_on_year=False, + weekdays=[MO, FR], + repeat_day=False, + repeat_week=False, + repeat_month=False, + count=100, + ) + + self.assertEqual(len(dates), 3) + self.assertEqual(dates[0], datetime(2020, 1, 3, 0, 0)) + self.assertEqual(dates[1], datetime(2020, 1, 20, 0, 0)) + self.assertEqual(dates[2], datetime(2020, 1, 24, 0, 0)) + + def test_recurrence_next_dates_month(self): + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2020, 1, 15), + repeat_interval=1, + repeat_unit="month", + repeat_type=False, # Forever + repeat_until=False, + repeat_on_month="date", + repeat_on_year=False, + weekdays=False, + repeat_day=31, + repeat_week=False, + repeat_month=False, + count=12, + ) + + # should take the last day of each month + self.assertEqual(dates[0], date(2020, 1, 31)) + self.assertEqual(dates[1], date(2020, 2, 29)) + self.assertEqual(dates[2], date(2020, 3, 31)) + self.assertEqual(dates[3], date(2020, 4, 30)) + self.assertEqual(dates[4], date(2020, 5, 31)) + self.assertEqual(dates[5], date(2020, 6, 30)) + self.assertEqual(dates[6], date(2020, 7, 31)) + self.assertEqual(dates[7], date(2020, 8, 31)) + self.assertEqual(dates[8], date(2020, 9, 30)) + self.assertEqual(dates[9], date(2020, 10, 31)) + self.assertEqual(dates[10], date(2020, 11, 30)) + self.assertEqual(dates[11], date(2020, 12, 31)) + + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2020, 2, 20), + repeat_interval=3, + repeat_unit="month", + repeat_type=False, # Forever + repeat_until=False, + repeat_on_month="date", + repeat_on_year=False, + weekdays=False, + repeat_day=29, + repeat_week=False, + repeat_month=False, + count=5, + ) + + self.assertEqual(dates[0], date(2020, 2, 29)) + self.assertEqual(dates[1], date(2020, 5, 29)) + self.assertEqual(dates[2], date(2020, 8, 29)) + self.assertEqual(dates[3], date(2020, 11, 29)) + self.assertEqual(dates[4], date(2021, 2, 28)) + + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2020, 1, 10), + repeat_interval=1, + repeat_unit="month", + repeat_type="until", + repeat_until=datetime(2020, 5, 31), + repeat_on_month="day", + repeat_on_year=False, + weekdays=[ + SA(4), + ], # 4th Saturday + repeat_day=29, + repeat_week=False, + repeat_month=False, + count=6, + ) + + self.assertEqual(len(dates), 5) + self.assertEqual(dates[0], datetime(2020, 1, 25)) + self.assertEqual(dates[1], datetime(2020, 2, 22)) + self.assertEqual(dates[2], datetime(2020, 3, 28)) + self.assertEqual(dates[3], datetime(2020, 4, 25)) + self.assertEqual(dates[4], datetime(2020, 5, 23)) + + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=datetime(2020, 1, 10), + repeat_interval=6, # twice a year + repeat_unit="month", + repeat_type="until", + repeat_until=datetime(2021, 1, 11), + repeat_on_month="date", + repeat_on_year=False, + weekdays=[TH(+1)], + repeat_day=3, # the 3rd of the month + repeat_week=False, + repeat_month=False, + count=1, + ) + + self.assertEqual(len(dates), 2) + self.assertEqual(dates[0], datetime(2020, 7, 3)) + self.assertEqual(dates[1], datetime(2021, 1, 3)) + + # Should generate a date at the last day of the current month + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2022, 2, 26), + repeat_interval=1, + repeat_unit="month", + repeat_type="until", + repeat_until=date(2022, 2, 28), + repeat_on_month="date", + repeat_on_year=False, + weekdays=False, + repeat_day=31, + repeat_week=False, + repeat_month=False, + count=5, + ) + + self.assertEqual(len(dates), 1) + self.assertEqual(dates[0], date(2022, 2, 28)) + + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2022, 11, 26), + repeat_interval=3, + repeat_unit="month", + repeat_type="until", + repeat_until=date(2024, 2, 29), + repeat_on_month="date", + repeat_on_year=False, + weekdays=False, + repeat_day=25, + repeat_week=False, + repeat_month=False, + count=5, + ) + + self.assertEqual(len(dates), 5) + self.assertEqual(dates[0], date(2023, 2, 25)) + self.assertEqual(dates[1], date(2023, 5, 25)) + self.assertEqual(dates[2], date(2023, 8, 25)) + self.assertEqual(dates[3], date(2023, 11, 25)) + self.assertEqual(dates[4], date(2024, 2, 25)) + + # Use the exact same parameters than the previous test + # but with a repeat_day that is not passed yet + # So we generate an additional date in the current month + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2022, 11, 26), + repeat_interval=3, + repeat_unit="month", + repeat_type="until", + repeat_until=date(2024, 2, 29), + repeat_on_month="date", + repeat_on_year=False, + weekdays=False, + repeat_day=31, + repeat_week=False, + repeat_month=False, + count=5, + ) + + self.assertEqual(len(dates), 6) + self.assertEqual(dates[0], date(2022, 11, 30)) + self.assertEqual(dates[1], date(2023, 2, 28)) + self.assertEqual(dates[2], date(2023, 5, 31)) + self.assertEqual(dates[3], date(2023, 8, 31)) + self.assertEqual(dates[4], date(2023, 11, 30)) + self.assertEqual(dates[5], date(2024, 2, 29)) + + def test_recurrence_next_dates_year(self): + dates = self.hr_timesheet_recurrence_model._get_next_recurring_dates( + date_start=date(2020, 12, 1), + repeat_interval=1, + repeat_unit="year", + repeat_type="until", + repeat_until=datetime(2026, 1, 1), + repeat_on_month=False, + repeat_on_year="date", + weekdays=False, + repeat_day=31, + repeat_week=False, + repeat_month="november", + count=10, + ) + + self.assertEqual(len(dates), 5) + self.assertEqual(dates[0], datetime(2021, 11, 30)) + self.assertEqual(dates[1], datetime(2022, 11, 30)) + self.assertEqual(dates[2], datetime(2023, 11, 30)) + self.assertEqual(dates[3], datetime(2024, 11, 30)) + self.assertEqual(dates[4], datetime(2025, 11, 30)) diff --git a/hr_timesheet_purchase_order/views/hr_employee_view.xml b/hr_timesheet_purchase_order/views/hr_employee_view.xml index b7ad87021..2972ecae2 100644 --- a/hr_timesheet_purchase_order/views/hr_employee_view.xml +++ b/hr_timesheet_purchase_order/views/hr_employee_view.xml @@ -15,6 +15,133 @@ name="billing_partner_id" attrs="{'required': [('allow_generate_purchase_order', '=', True)], 'invisible': [('allow_generate_purchase_order', '=', False)]}" /> + + + + + + + + + + +