diff --git a/hr_timesheet_purchase_order/README.rst b/hr_timesheet_purchase_order/README.rst new file mode 100644 index 0000000000..24f955cbf8 --- /dev/null +++ b/hr_timesheet_purchase_order/README.rst @@ -0,0 +1,117 @@ +=========================== +HR Timesheet Purchase Order +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8ee916a67e3d61ac27f7e4f0a2499babf868024f27e3b2a9de46b7c7b7a869e8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Ftimesheet-lightgray.png?logo=github + :target: https://github.com/OCA/timesheet/tree/15.0/hr_timesheet_purchase_order + :alt: OCA/timesheet +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/timesheet-15-0/timesheet-15-0-hr_timesheet_purchase_order + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/timesheet&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +============================================ +Create purchase orders from timesheet sheets +============================================ + +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. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +General Settings > Timesheet > Timesheet Options: in field "Purchase Timesheet Product" select the product that will be used in PO to bill timesheet hours. + +Usage +===== + +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 from timesheet sheets" in Partner > Sales & Purchase tab, 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. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Ooops +* Cetmix + +Contributors +~~~~~~~~~~~~ + +* Ooops404 +* Cetmix + +Other credits +~~~~~~~~~~~~~ + +* Odoo Community Association: `Icon `_. + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-dessanhemrayev| image:: https://github.com/dessanhemrayev.png?size=40px + :target: https://github.com/dessanhemrayev + :alt: dessanhemrayev +.. |maintainer-aleuffre| image:: https://github.com/aleuffre.png?size=40px + :target: https://github.com/aleuffre + :alt: aleuffre +.. |maintainer-renda-dev| image:: https://github.com/renda-dev.png?size=40px + :target: https://github.com/renda-dev + :alt: renda-dev + +Current `maintainers `__: + +|maintainer-dessanhemrayev| |maintainer-aleuffre| |maintainer-renda-dev| + +This module is part of the `OCA/timesheet `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_timesheet_purchase_order/__init__.py b/hr_timesheet_purchase_order/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/hr_timesheet_purchase_order/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_timesheet_purchase_order/__manifest__.py b/hr_timesheet_purchase_order/__manifest__.py new file mode 100644 index 0000000000..0241b7ba20 --- /dev/null +++ b/hr_timesheet_purchase_order/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "HR Timesheet Purchase Order", + "version": "15.0.1.0.0", + "summary": "HR Timesheet Purchase Order", + "author": "Ooops, Cetmix, Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Human Resources", + "website": "https://github.com/OCA/timesheet", + "depends": [ + "hr_timesheet_sheet", + "purchase", + ], + "maintainers": ["dessanhemrayev", "aleuffre", "renda-dev"], + "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/res_partner_view.xml", + "views/hr_timesheet_sheet_view.xml", + "views/purchase_order_view.xml", + "views/res_config_settings_view.xml", + ], + "qweb": [], + "installable": True, + "application": False, +} 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 0000000000..41019a063b --- /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/data/ir_actions_server.xml b/hr_timesheet_purchase_order/data/ir_actions_server.xml new file mode 100644 index 0000000000..958af009d6 --- /dev/null +++ b/hr_timesheet_purchase_order/data/ir_actions_server.xml @@ -0,0 +1,22 @@ + + + + + Create Purchase Order + + + + list + code + +action = records.action_create_purchase_order() + + + + diff --git a/hr_timesheet_purchase_order/i18n/hr_timesheet_purchase_order.pot b/hr_timesheet_purchase_order/i18n/hr_timesheet_purchase_order.pot new file mode 100644 index 0000000000..dce38af9b5 --- /dev/null +++ b/hr_timesheet_purchase_order/i18n/hr_timesheet_purchase_order.pot @@ -0,0 +1,657 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_timesheet_purchase_order +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \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_timesheet_purchase_order +#: model_terms:ir.ui.view,arch_db:hr_timesheet_purchase_order.view_partner_form +msgid "" +" 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): + """Check the repeat day or month is valid""" + month_day = DAYS_IN_MONTHS.get(self.repeat_month) + if 0 > self.repeat_day or self.repeat_day > month_day: + raise ValidationError( + _( + ( + "The number of days in a month cannot be negative " + "or more than %s days" + ) + % self.repeat_day + if self.repeat_day < 0 + else month_day + ) + ) + + def _get_weekdays(self, n=1): + """Returns the weekdays selected for the recurrence + Args: + n (int, optional): Day of the week. Defaults to 1. + Returns: + list: The weekdays selected for the recurrence + """ + 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, **recurrence_data): + """Based on the selected parameters returns the following date + + Args: + date_start (datetime): The starting date for calculating the recurring dates + recurrence_data (dict): The recurrence pattern for calculating the dates + + Returns: + datetime: The following date based on the selected parameters + """ + + count = recurrence_data.get("count", 1) + + rrule_kwargs = { + "interval": recurrence_data.get("repeat_interval", 1), + "dtstart": date_start, + } + repeat_day = int(recurrence_data.get("repeat_day", 0)) + repeat_type = recurrence_data.get("repeat_type", False) + repeat_until = recurrence_data.get("repeat_until", False) + if repeat_type == "until": + rrule_kwargs["until"] = ( + repeat_until if repeat_until else fields.Date.today() + ) + else: + rrule_kwargs["count"] = count + repeat_unit = recurrence_data.get("repeat_unit", False) + repeat_on_month = recurrence_data.get("repeat_on_month", False) + repeat_on_year = recurrence_data.get("repeat_on_year", False) + repeat_month = recurrence_data.get("repeat_month", False) + 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"] = recurrence_data.get("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, + count, + repeat_interval=recurrence_data.get("repeat_interval", False), + repeat_until=repeat_until, + repeat_type=repeat_type, + ) + elif repeat_unit == "year": + rrule_kwargs["freq"] = YEARLY + month = list(DAYS_IN_MONTHS.keys()).index(repeat_month) + 1 + rrule_kwargs["bymonth"] = month + if repeat_on_year == "date": + rrule_kwargs["bymonthday"] = min( + repeat_day, DAYS_IN_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, count, **recurrence + ): + """Based on the selected parameters returns the following date + + Args: + date_start (datetime): The starting date for calculating the recurring dates + repeat_day (int): Repeat day count + count (int): Count repeat dates + recurrence (dict): The recurrence pattern for calculating the dates + + Returns: + list: Dates list + """ + dates = [] + start = date_start - relativedelta(days=1) + start = start.replace( + day=min(repeat_day, monthrange(start.year, start.month)[1]) + ) + repeat_interval = recurrence.get("repeat_interval", 0) + repeat_until = recurrence.get("repeat_until", False) + repeat_type = recurrence.get("repeat_type", False) + 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): + """Set the next recurrence date""" + 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, + repeat_interval=recurrence.repeat_interval, + repeat_unit=recurrence.repeat_unit, + repeat_type=recurrence.repeat_type, + repeat_until=recurrence.repeat_until, + repeat_on_month=recurrence.repeat_on_month, + repeat_on_year=recurrence.repeat_on_year, + weekdays=recurrence._get_weekdays(), + repeat_day=recurrence.repeat_day, + repeat_week=recurrence.repeat_week, + repeat_month=recurrence.repeat_month, + count=1, + ) + recurrence.next_recurrence_date = next_date[0] if next_date else False + + def _create_purchase_order(self): + """Create purchase order for all timesheets of the partner""" + for partner in self.partner_ids: + + timesheets = partner.mapped("employee_ids.timesheet_sheet_ids").filtered( + lambda t: not t.purchase_order_id and t.state == "done" + ) + if not timesheets: + continue + timesheets.action_create_purchase_order() + if partner.is_send_po: + email_act = timesheets[0].purchase_order_id.action_rfq_send() + email_ctx = email_act.get("context", {}) + timesheets[0].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): + """Generate purchase order for all partner with timesheets""" + 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/models/hr_timesheet_sheet.py b/hr_timesheet_purchase_order/models/hr_timesheet_sheet.py new file mode 100644 index 0000000000..eb1192fa12 --- /dev/null +++ b/hr_timesheet_purchase_order/models/hr_timesheet_sheet.py @@ -0,0 +1,163 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class HrTimesheetSheet(models.Model): + _inherit = "hr_timesheet.sheet" + + purchase_order_id = fields.Many2one("purchase.order", readonly=True) + allow_generate_purchase_order = fields.Boolean( + related="employee_id.allow_generate_purchase_order" + ) + + def action_create_purchase_order(self): + """ + Create Purchase Order + """ + purchase_order_obj = self.env["purchase.order"].sudo() + order_count = 0 + group_by_employee = {} + group_by_billing_partner = {} + for record in self: + group_by_employee.setdefault(record.employee_id, []).append(record) + + for employee, timesheets in group_by_employee.items(): + group_by_billing_partner.setdefault(employee.billing_partner_id, []).append( + (employee, timesheets) + ) + + for employee, timesheets in group_by_employee.items(): + if any([timesheet.purchase_order_id for timesheet in timesheets]): + raise UserError( + _( + "One of the Timesheet Sheets selected for employee {} " + "is already related to a PO.", + ).format( + employee.name, + ) + ) + if not self.company_id.timesheet_product_id: + raise UserError( + _( + "You need to set a timesheet billing product" + "in settings in order to create a PO" + ) + ) + if not employee.allow_generate_purchase_order: + raise UserError( + _( + "Employee {} is not enabled for PO creation from Timesheet Sheets." + ).format( + employee.name, + ) + ) + if not employee.billing_partner_id: + raise UserError( + _("Not specified billing partner for the employee: {}.").format( + employee.name, + ) + ) + if not all([timesheet.state == "done" for timesheet in timesheets]): + raise UserError( + _("Timesheet Sheets must be approved to create a PO from them."), + ) + + for billing_partner, emp_data in group_by_billing_partner.items(): + order_line_values, timesheets = self._prepared_order_line_values(emp_data) + order = purchase_order_obj.create( + {"partner_id": billing_partner.id, "order_line": order_line_values} + ) + order.onchange_partner_id() + order_count += 1 + for timesheet in timesheets: + timesheet.purchase_order_id = order.id + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "type": "success", + "message": _( + "%(count)s POs created from timesheet sheet selected " + "for the following employees: %(employees)s" + ) + % { + "count": order_count, + "employees": ", ".join( + employee.name for employee in group_by_employee.keys() + ), + }, + "next": { + "type": "ir.actions.act_window_close", + }, + }, + } + + @api.model + def _prepared_order_line_values(self, data): + """Prepare order lines for purchase order + Args: + data (list): Array of tuples with employee and timesheets + + Returns: + list,list: Array of order lines and array of timesheets + """ + result = [] + timesheets_data = [] + for employee, timesheets in data: + date_timesheet = f"{timesheets[0].date_start} to {timesheets[0].date_end}" + result.append( + ( + 0, + 0, + { + "product_id": employee.company_id.timesheet_product_id.id, + "name": "%s - %s from %s" + % ( + employee.company_id.timesheet_product_id.name, + employee.name, + date_timesheet, + ), + "product_qty": sum( + timesheet.total_time for timesheet in timesheets + ), + "price_unit": employee.timesheet_cost, + }, + ) + ) + timesheets_data.extend(timesheets) + return result, timesheets_data + + def action_open_purchase_order(self): + """ + Return action to open related Purchase Order + """ + self.ensure_one() + if self.purchase_order_id: + action = self.env["ir.actions.act_window"]._for_xml_id( + "purchase.action_rfq_form" + ) + action["res_id"] = self.purchase_order_id.id + action["target"] = "current" + return action + + def action_confirm_purchase_order(self): + """ + Confirm purchase orders + """ + self.filtered(lambda rec: rec.purchase_order_id).mapped( + "purchase_order_id" + ).sudo().button_confirm() + + def action_timesheet_draft(self): + sheets_with_po = self.filtered(lambda sheet: sheet.purchase_order_id) + if sheets_with_po: + raise UserError( + _( + "Cannot set to draft a Timesheet Sheet from which a PO has been created. " + "Please delete the related PO first.", + ), + ) + return super().action_timesheet_draft() diff --git a/hr_timesheet_purchase_order/models/purchase_order.py b/hr_timesheet_purchase_order/models/purchase_order.py new file mode 100644 index 0000000000..3f725f24b6 --- /dev/null +++ b/hr_timesheet_purchase_order/models/purchase_order.py @@ -0,0 +1,40 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + def _compute_timesheet_sheet_count(self): + """ + Compute total timesheet sheets + """ + for order in self: + order.timesheet_sheet_count = len(order.timesheet_sheet_ids) + + timesheet_sheet_ids = fields.One2many( + "hr_timesheet.sheet", "purchase_order_id", string="Timesheet Sheets" + ) + timesheet_sheet_count = fields.Integer(compute="_compute_timesheet_sheet_count") + + def action_open_timesheet_sheet(self): + """ + Open related timesheet sheets + """ + tree_view_ref = self.env.ref( + "hr_timesheet_sheet.hr_timesheet_sheet_tree", raise_if_not_found=False + ) + form_view_ref = self.env.ref( + "hr_timesheet_sheet.hr_timesheet_sheet_form", raise_if_not_found=False + ) + action = { + "name": "Timesheet Sheets", + "type": "ir.actions.act_window", + "res_model": "hr_timesheet.sheet", + "views": [(tree_view_ref.id, "tree"), (form_view_ref.id, "form")], + "domain": [("id", "in", self.timesheet_sheet_ids.ids)], + "target": "current", + } + return action diff --git a/hr_timesheet_purchase_order/models/res_company.py b/hr_timesheet_purchase_order/models/res_company.py new file mode 100644 index 0000000000..978ca3fd91 --- /dev/null +++ b/hr_timesheet_purchase_order/models/res_company.py @@ -0,0 +1,13 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + timesheet_product_id = fields.Many2one( + "product.product", + string="Purchase Timesheet Product", + ) diff --git a/hr_timesheet_purchase_order/models/res_config_settings.py b/hr_timesheet_purchase_order/models/res_config_settings.py new file mode 100644 index 0000000000..0dbf95aa3a --- /dev/null +++ b/hr_timesheet_purchase_order/models/res_config_settings.py @@ -0,0 +1,15 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + timesheet_product_id = fields.Many2one( + "product.product", + related="company_id.timesheet_product_id", + string="Purchase Timesheet Product", + readonly=False, + ) diff --git a/hr_timesheet_purchase_order/models/res_partner.py b/hr_timesheet_purchase_order/models/res_partner.py new file mode 100644 index 0000000000..1f88f4f0ec --- /dev/null +++ b/hr_timesheet_purchase_order/models/res_partner.py @@ -0,0 +1,218 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import api, fields, models + +from .consts import ( + DAYS, + MONTH_SELECTION, + ON_MONTH_SELECTION, + ON_YEAR_SELECTION, + TYPE_SELECTION, + UNIT_SELECTION, + WEEKDAY_SELECTION, + WEEKS_SELECTION, +) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + is_auto_po_generate = fields.Boolean( + string="Automatic PO generation from timesheet sheets" + ) + recurrence_id = fields.Many2one("hr.timesheet.recurrence", copy=False) + employee_ids = fields.One2many( + comodel_name="hr.employee", + inverse_name="billing_partner_id", + ) + 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( + selection=UNIT_SELECTION, + default="week", + compute="_compute_repeat", + readonly=False, + ) + repeat_type = fields.Selection( + selection=TYPE_SELECTION, + 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( + selection=ON_MONTH_SELECTION, + default="date", + compute="_compute_repeat", + readonly=False, + ) + + repeat_on_year = fields.Selection( + selection=ON_YEAR_SELECTION, + default="date", + compute="_compute_repeat", + readonly=False, + ) + + mon = fields.Boolean(compute="_compute_repeat", readonly=False) + tue = fields.Boolean(compute="_compute_repeat", readonly=False) + wed = fields.Boolean(compute="_compute_repeat", readonly=False) + thu = fields.Boolean(compute="_compute_repeat", readonly=False) + fri = fields.Boolean(compute="_compute_repeat", readonly=False) + sat = fields.Boolean(compute="_compute_repeat", readonly=False) + sun = fields.Boolean(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( + selection=WEEKS_SELECTION, + default="first", + compute="_compute_repeat", + readonly=False, + ) + repeat_weekday = fields.Selection( + selection=WEEKDAY_SELECTION, + string="Day Of The Week", + compute="_compute_repeat", + readonly=False, + ) + repeat_month = fields.Selection( + selection=MONTH_SELECTION, + 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/readme/CONFIGURE.rst b/hr_timesheet_purchase_order/readme/CONFIGURE.rst new file mode 100644 index 0000000000..d88152f428 --- /dev/null +++ b/hr_timesheet_purchase_order/readme/CONFIGURE.rst @@ -0,0 +1 @@ +General Settings > Timesheet > Timesheet Options: in field "Purchase Timesheet Product" select the product that will be used in PO to bill timesheet hours. diff --git a/hr_timesheet_purchase_order/readme/CONTRIBUTORS.rst b/hr_timesheet_purchase_order/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..e3a1f2ff2f --- /dev/null +++ b/hr_timesheet_purchase_order/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Ooops404 +* Cetmix diff --git a/hr_timesheet_purchase_order/readme/CREDITS.rst b/hr_timesheet_purchase_order/readme/CREDITS.rst new file mode 100644 index 0000000000..cc056a80d6 --- /dev/null +++ b/hr_timesheet_purchase_order/readme/CREDITS.rst @@ -0,0 +1 @@ +* Odoo Community Association: `Icon `_. diff --git a/hr_timesheet_purchase_order/readme/DESCRIPTION.rst b/hr_timesheet_purchase_order/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..2e46394dfd --- /dev/null +++ b/hr_timesheet_purchase_order/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +============================================ +Create purchase orders from timesheet sheets +============================================ + +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 new file mode 100644 index 0000000000..e09fed379e --- /dev/null +++ b/hr_timesheet_purchase_order/readme/USAGE.rst @@ -0,0 +1,7 @@ +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 from timesheet sheets" in Partner > Sales & Purchase tab, 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 0000000000..2aab5469f0 --- /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/static/description/icon.png b/hr_timesheet_purchase_order/static/description/icon.png new file mode 100755 index 0000000000..3a0328b516 Binary files /dev/null and b/hr_timesheet_purchase_order/static/description/icon.png differ diff --git a/hr_timesheet_purchase_order/static/description/index.html b/hr_timesheet_purchase_order/static/description/index.html new file mode 100644 index 0000000000..bf654fe4c8 --- /dev/null +++ b/hr_timesheet_purchase_order/static/description/index.html @@ -0,0 +1,454 @@ + + + + + +README.rst + + + +
+ + +
+

HR Timesheet Purchase Order

+ +

Beta License: AGPL-3 OCA/timesheet Translate me on Weblate Try me on Runboat

+
+
+

Create purchase orders from timesheet sheets

+

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.

+

Table of contents

+ +
+

Configuration

+

General Settings > Timesheet > Timesheet Options: in field “Purchase Timesheet Product” select the product that will be used in PO to bill timesheet hours.

+
+
+

Usage

+

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 from timesheet sheets” in Partner > Sales & Purchase tab, 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.

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ooops
  • +
  • Cetmix
  • +
+
+ +
+

Other credits

+
    +
  • Odoo Community Association: Icon.
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

dessanhemrayev aleuffre renda-dev

+

This module is part of the OCA/timesheet project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/hr_timesheet_purchase_order/tests/__init__.py b/hr_timesheet_purchase_order/tests/__init__.py new file mode 100644 index 0000000000..4b7a86a872 --- /dev/null +++ b/hr_timesheet_purchase_order/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_create_not_product +from . import test_create_timesheet_purchase_order +from . import test_create_timesheet_po_recurrence diff --git a/hr_timesheet_purchase_order/tests/common_po_recurrence.py b/hr_timesheet_purchase_order/tests/common_po_recurrence.py new file mode 100644 index 0000000000..d8e61bb5e4 --- /dev/null +++ b/hr_timesheet_purchase_order/tests/common_po_recurrence.py @@ -0,0 +1,179 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import SavepointCase + + +class TestTimesheetPOrecurrenceCommon(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", + } + ) + + cls.hr_timesheet_sheet_obj = cls.env["hr_timesheet.sheet"].with_context( + tracking_disable=True + ) + cls.project_obj = cls.env["project.project"] + cls.project_task_obj = cls.env["project.task"] + cls.hr_employee_obj = cls.env["hr.employee"] + cls.hr_timesheet_recurrence_obj = cls.env["hr.timesheet.recurrence"] + cls.aal_model = cls.env["account.analytic.line"] + cls.aaa_model = cls.env["account.analytic.account"] + cls.res_users_obj = cls.env["res.users"].with_context(no_reset_password=True) + cls.res_partner_obj = cls.env["res.partner"] + config_obj = cls.env["res.config.settings"] + config = config_obj.create({"timesheet_product_id": cls.product.id}) + config.execute() + + 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.account_payment_term_30days = cls.env.ref( + "account.account_payment_term_30days" + ) + cls.account_payment_method_manual_out = cls.env.ref( + "account.account_payment_method_manual_out" + ) + + cls.user_1 = cls.res_users_obj.create( + { + "name": "Test User 1", + "login": "test_user_1", + "email": "test_1@oca.com", + "groups_id": [ + ( + 6, + 0, + [ + officer_group.id, + sheet_user_group.id, + project_user_group.id, + multi_company_group.id, + ], + ) + ], + } + ) + + cls.user_2 = cls.res_users_obj.create( + { + "name": "Test User 2", + "login": "test_user_2", + "email": "test_2@oca.com", + "groups_id": [ + ( + 6, + 0, + [ + officer_group.id, + sheet_user_group.id, + project_user_group.id, + multi_company_group.id, + ], + ) + ], + } + ) + cls.user_3 = cls.res_users_obj.create( + { + "name": "Test User 3", + "login": "test_user_3", + "email": "test_3@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_obj.create( + { + "name": "Project #1", + "allow_timesheets": True, + "user_id": cls.user_1.id, + } + ) + cls.task = cls.project_task_obj.create( + { + "name": "Task #1", + "project_id": cls.project.id, + } + ) + + cls.project_2 = cls.project_obj.create( + { + "name": "Project #2", + "allow_timesheets": True, + "user_id": cls.user_2.id, + } + ) + cls.task_2 = cls.project_task_obj.create( + { + "name": "Task #2", + "project_id": cls.project_2.id, + } + ) + + cls.project_3 = cls.project_obj.create( + { + "name": "Project #3", + "allow_timesheets": True, + "user_id": cls.user_3.id, + } + ) + cls.task_3 = cls.project_task_obj.create( + { + "name": "Task #3", + "project_id": cls.project_3.id, + } + ) + + cls.outsourcing_company = cls.res_partner_obj.create( + { + "name": "Outsourcing Company", + "is_company": True, + } + ) + + cls.employee_1 = cls.hr_employee_obj.create( + { + "name": "Test Employee #1", + "user_id": cls.user_1.id, + "billing_partner_id": cls.outsourcing_company.id, + "allow_generate_purchase_order": True, + } + ) + cls.employee_2 = cls.hr_employee_obj.create( + { + "name": "Test Employee #2", + "user_id": cls.user_2.id, + "billing_partner_id": cls.outsourcing_company.id, + "allow_generate_purchase_order": True, + } + ) + + cls.employee_3 = cls.hr_employee_obj.create( + { + "name": "Test Employee #3", + "user_id": cls.user_3.id, + "billing_partner_id": cls.outsourcing_company.id, + "allow_generate_purchase_order": True, + } + ) diff --git a/hr_timesheet_purchase_order/tests/test_create_not_product.py b/hr_timesheet_purchase_order/tests/test_create_not_product.py new file mode 100644 index 0000000000..11af5cf6a3 --- /dev/null +++ b/hr_timesheet_purchase_order/tests/test_create_not_product.py @@ -0,0 +1,66 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import date + +from freezegun import freeze_time + +from odoo.exceptions import UserError +from odoo.tests.common import Form + +from .common_po_recurrence import TestTimesheetPOrecurrenceCommon + + +class TestTimesheetPORecurrenceNotProduct(TestTimesheetPOrecurrenceCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + config_obj = cls.env["res.config.settings"] + config = config_obj.create({"timesheet_product_id": False}) + config.execute() + + def test_recurrence_cron_repeat_until(self): + """ + Test the cron job for the recurrence of the purchase + order when the product is not defined + """ + with freeze_time("2020-01-01"): + form = Form(self.outsourcing_company) + 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" + + form.property_supplier_payment_term_id = self.account_payment_term_30days + form.property_payment_method_id = self.account_payment_method_manual_out + form.receipt_reminder_email = True + form.reminder_date_before_receipt = 3 + + form.save() + + sheet_form = Form(self.hr_timesheet_sheet_obj.with_user(self.user_1)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test until month" + timesheet.project_id = self.project + timesheet.unit_amount = 1.0 + sheet_1 = sheet_form.save() + self.assertFalse(sheet_1.purchase_order_id, msg="Must be equal False") + + # cannot create purchase order (sheet not approved) + with self.assertRaises(UserError): + sheet_1.action_create_purchase_order() + + sheet_1.action_timesheet_confirm() + with self.assertRaises( + UserError, + msg=( + "You need to set a timesheet billing product" + "in settings in order to create a PO" + ), + ): + sheet_1.action_create_purchase_order() 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 0000000000..25e9db09a7 --- /dev/null +++ b/hr_timesheet_purchase_order/tests/test_create_timesheet_po_recurrence.py @@ -0,0 +1,635 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +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 + +from .common_po_recurrence import TestTimesheetPOrecurrenceCommon + + +class TestTimesheetPOrecurrence(TestTimesheetPOrecurrenceCommon): + def test_create_purchase_order_recurrence_simple(self): + """Test the creation of the purchase order with the recurrence of the timesheet""" + with freeze_time("2020-03-01"): + form_employee_1 = Form(self.employee_1) + form_employee_1.billing_partner_id = self.outsourcing_company + form_employee_1.allow_generate_purchase_order = True + form_employee_1.save() + + form_employee_2 = Form(self.employee_2) + form_employee_2.billing_partner_id = self.outsourcing_company + form_employee_2.allow_generate_purchase_order = True + form_employee_2.save() + + form_employee_3 = Form(self.employee_3) + form_employee_3.billing_partner_id = self.outsourcing_company + form_employee_3.allow_generate_purchase_order = True + form_employee_3.save() + + form_billing_partner = Form(self.outsourcing_company) + form_billing_partner.is_auto_po_generate = True + + form_billing_partner.repeat_interval = 5 + form_billing_partner.repeat_unit = "month" + form_billing_partner.repeat_type = "after" + form_billing_partner.repeat_number = 10 + form_billing_partner.repeat_on_month = "date" + form_billing_partner.repeat_day = "31" + billing_partner = form_billing_partner.save() + + self.assertTrue( + bool(billing_partner.is_auto_po_generate), "should enable a recurrence" + ) + billing_partner.update(dict(repeat_interval=2, repeat_number=11)) + self.assertEqual( + billing_partner.repeat_interval, 2, "recurrence should be updated" + ) + self.assertEqual( + billing_partner.repeat_number, 11, "recurrence should be updated" + ) + self.assertEqual( + billing_partner.recurrence_id.recurrence_left, 11, "Must be equal 11" + ) + self.assertEqual( + billing_partner.next_recurrence_date, + date(2020, 3, 31), + "Must be equal {dt}".format(dt=date(2020, 3, 31)), + ) + self.assertEqual( + billing_partner.recurrence_id.next_recurrence_date, + date(2020, 3, 31), + "Must be equal {dt}".format(dt=date(2020, 3, 31)), + ) + self.assertEqual( + billing_partner.next_recurrence_date, + billing_partner.recurrence_id.next_recurrence_date, + "Must be equal {dt}".format(dt=date(2020, 3, 31)), + ) + billing_partner.is_auto_po_generate = False + self.assertFalse( + bool(billing_partner.is_auto_po_generate), + "The recurrence should be disabled", + ) + self.assertFalse( + bool(billing_partner.recurrence_id), "The recurrence should be deleted" + ) + # enabled is_auto_po_generate + with Form(billing_partner) 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" + billing_partner = form.save() + + self.assertTrue( + bool(billing_partner.recurrence_id), "The recurrence should be enabled" + ) + sheet_form = Form(self.hr_timesheet_sheet_obj.with_user(self.user_1)) + 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_obj._cron_generate_auto_po() + + def test_onchange_repeat_day(self): + """Check when the repeat day is set incorrectly""" + with freeze_time("2020-02-01"): + form = Form(self.outsourcing_company) + 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 + billing_partner = form.save() + self.assertEqual(billing_partner.repeat_day, 1, "Must be equal 1") + + with self.assertRaisesRegex( + ValidationError, + ( + ( + "The number of days in a month cannot be negative " + "or more than %s days" + ) + % -1 + ), + ): + billing_partner.recurrence_id.repeat_day = -1 + + def test_recurrence_cron_repeat_after(self): + """Test the cron method of the recurrence is correctly working""" + with freeze_time("2020-01-01"): + form = Form(self.outsourcing_company) + form.name = "Test Employee recurrence cron_repeat_after" + 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" + billing_partner = form.save() + + self.assertEqual(billing_partner.next_recurrence_date, date(2020, 1, 15)) + + sheet_form = Form(self.hr_timesheet_sheet_obj.with_user(self.user_1)) + 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_obj._cron_generate_auto_po() + + with freeze_time("2020-01-15"): + self.hr_timesheet_recurrence_obj._cron_generate_auto_po() + with freeze_time("2020-02-15"): + self.hr_timesheet_recurrence_obj._cron_generate_auto_po() + + def test_recurrence_cron_repeat_until(self): + """Check when the until date is set correctly and create purchase order""" + with freeze_time("2020-01-01"): + form = Form(self.outsourcing_company) + 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" + + form.property_supplier_payment_term_id = self.account_payment_term_30days + form.property_payment_method_id = self.account_payment_method_manual_out + form.receipt_reminder_email = True + form.reminder_date_before_receipt = 3 + + billing_partner = form.save() + + sheet_form = Form(self.hr_timesheet_sheet_obj.with_user(self.user_1)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test until month" + timesheet.project_id = self.project + timesheet.unit_amount = 1.0 + sheet_1 = sheet_form.save() + self.assertFalse(sheet_1.purchase_order_id, msg="Must be equal False") + + sheet_form = Form(self.hr_timesheet_sheet_obj.with_user(self.user_2)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test until month" + timesheet.project_id = self.project_2 + timesheet.unit_amount = 2.0 + sheet_2 = sheet_form.save() + self.assertFalse(sheet_2.purchase_order_id, msg="Must be equal False") + + sheet_form = Form(self.hr_timesheet_sheet_obj.with_user(self.user_3)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test until month" + timesheet.project_id = self.project_3 + timesheet.unit_amount = 2.0 + sheet_3 = sheet_form.save() + self.assertFalse(sheet_3.purchase_order_id, msg="Must be equal False") + + # cannot create purchase order (sheet not approved) + with self.assertRaises(UserError): + sheet_1.action_create_purchase_order() + + with self.assertRaises(UserError): + sheet_2.action_create_purchase_order() + + with self.assertRaises(UserError): + sheet_3.action_create_purchase_order() + + sheet_1.action_timesheet_confirm() + self.assertEqual(sheet_1.state, "confirm", msg="Must be equal confirm") + sheet_1.action_timesheet_done() + + sheet_2.action_timesheet_confirm() + self.assertEqual(sheet_2.state, "confirm", msg="Must be equal confirm") + sheet_2.action_timesheet_done() + + self.assertEqual(len(self.employee_1.timesheet_sheet_ids), 1) + self.assertEqual(len(self.employee_2.timesheet_sheet_ids), 1) + self.assertEqual(len(self.employee_3.timesheet_sheet_ids), 1) + + self.assertEqual( + billing_partner.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(self.employee_1.timesheet_sheet_ids), 1) + self.assertEqual(len(self.employee_2.timesheet_sheet_ids), 1) + + sheet_3.action_timesheet_confirm() + self.assertEqual(sheet_3.state, "confirm", msg="Must be equal confirm") + sheet_3.action_timesheet_done() + + self.assertEqual(len(self.employee_3.timesheet_sheet_ids), 1) + self.hr_timesheet_recurrence_obj._cron_generate_auto_po() + self.assertTrue( + sheet_1.purchase_order_id, msg="Must be create new purchase order" + ) + + self.assertTrue( + sheet_1.purchase_order_id.receipt_reminder_email, + msg="Reminder email must be True", + ) + self.assertEqual( + sheet_1.purchase_order_id.payment_term_id, + self.outsourcing_company.property_supplier_payment_term_id, + msg=f"Must be equal {self.account_payment_term_30days.name}", + ) + self.assertEqual( + sheet_1.purchase_order_id.reminder_date_before_receipt, + self.outsourcing_company.reminder_date_before_receipt, + msg="Must be equal 3", + ) + + self.assertTrue( + sheet_2.purchase_order_id, msg="Must be create new purchase order" + ) + self.assertTrue( + sheet_2.purchase_order_id.receipt_reminder_email, + msg="Reminder email must be True", + ) + self.assertEqual( + sheet_2.purchase_order_id.payment_term_id, + self.outsourcing_company.property_supplier_payment_term_id, + msg=f"Must be equal {self.account_payment_term_30days.name}", + ) + self.assertEqual( + sheet_2.purchase_order_id.reminder_date_before_receipt, + self.outsourcing_company.reminder_date_before_receipt, + msg="Must be equal 3", + ) + + self.assertTrue( + sheet_3.purchase_order_id, msg="Must be create new purchase order" + ) + self.assertTrue( + sheet_3.purchase_order_id.receipt_reminder_email, + msg="Reminder email must be True", + ) + self.assertEqual( + sheet_3.purchase_order_id.payment_term_id, + self.outsourcing_company.property_supplier_payment_term_id, + msg=f"Must be equal {self.account_payment_term_30days.name}", + ) + self.assertEqual( + sheet_3.purchase_order_id.reminder_date_before_receipt, + self.outsourcing_company.reminder_date_before_receipt, + msg="Must be equal 3", + ) + + self.assertEqual( + billing_partner.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_obj._cron_generate_auto_po() + self.assertFalse( + billing_partner.recurrence_id.next_recurrence_date, + "Must be equal False", + ) + + def test_recurrence_week_day(self): + """Check when the repeat interval is set incorrectly""" + with self.assertRaisesRegex( + ValidationError, (_("You should select a least one day")) + ): + form = Form(self.res_partner_obj) + form.name = "Test Partner recurrence week_day" + form.email = "test@partner.com" + 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): + """Check when the repeat interval is set incorrectly""" + with self.assertRaisesRegex( + ValidationError, (_("The interval should be greater than 0")) + ): + form = Form(self.res_partner_obj) + form.name = "Test Partner recurrence week_day" + form.email = "test@partner.com" + form.is_auto_po_generate = True + form.repeat_interval = 0 + form.repeat_type = "after" + form.save() + + def test_repeat_number(self): + """Check when the repeat number is set incorrectly""" + with self.assertRaisesRegex( + ValidationError, (_("Should repeat at least once")) + ): + form = Form(self.res_partner_obj) + form.name = "Test Partner recurrence" + form.email = "test@partner.com" + 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): + """Check when the until date is set incorrectly""" + with freeze_time("2023-08-03"): + with self.assertRaisesRegex( + ValidationError, (_("The end date should be in the future")) + ): + form = Form(self.res_partner_obj) + form.name = "Test Partner recurrence" + form.email = "test@partner.com" + 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): + """Test generate next dates for week""" + dates = self.hr_timesheet_recurrence_obj._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)) + + start = date(2020, 1, 1) + until = date(2020, 2, 1) + dates = self.hr_timesheet_recurrence_obj._get_next_recurring_dates( + date_start=start, + repeat_interval=3, + repeat_unit="week", + repeat_type="until", + repeat_until=until, + 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): + """Test generate next dates for month""" + dates = self.hr_timesheet_recurrence_obj._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_obj._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_obj._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_obj._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_obj._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_obj._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_obj._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): + """Test generate recurring dates for yearly recurrence.""" + dates = self.hr_timesheet_recurrence_obj._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/tests/test_create_timesheet_purchase_order.py b/hr_timesheet_purchase_order/tests/test_create_timesheet_purchase_order.py new file mode 100644 index 0000000000..049532589e --- /dev/null +++ b/hr_timesheet_purchase_order/tests/test_create_timesheet_purchase_order.py @@ -0,0 +1,131 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError +from odoo.tests.common import Form, TransactionCase + + +class TestHrTimesheetPurchaseOrder(TransactionCase): + @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 TEST", + "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"] + 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", + "login": "test_user", + "email": "test@oca.com", + "groups_id": [ + ( + 6, + 0, + [ + officer_group.id, + sheet_user_group.id, + project_user_group.id, + multi_company_group.id, + ], + ) + ], + } + ) + ) + + cls.employee = cls.employee_model.create( + { + "name": "Test Employee", + "user_id": cls.user.id, + "billing_partner_id": cls.user.partner_id.id, + "allow_generate_purchase_order": True, + } + ) + 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(self): + sheet_form = Form(self.sheet_model.with_user(self.user)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test" + 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") + + # cannot create purchase order (sheet not approved) + with self.assertRaises(UserError): + sheet.action_create_purchase_order() + sheet.action_timesheet_done() + self.assertEqual(sheet.state, "done") + + sheet.action_create_purchase_order() + self.assertTrue(sheet.purchase_order_id) + sheet.action_confirm_purchase_order() + self.assertEqual(sheet.purchase_order_id.state, "purchase") + action = sheet.action_open_purchase_order() + self.assertTrue(action) + self.assertTrue(sheet.purchase_order_id.timesheet_sheet_count > 0) + action = sheet.purchase_order_id.action_open_timesheet_sheet() + self.assertTrue(action) + + def test_create_purchase_order_access(self): + sheet_form = Form(self.sheet_model.with_user(self.user)) + with sheet_form.timesheet_ids.new() as timesheet: + timesheet.name = "test" + timesheet.project_id = self.project + with sheet_form.timesheet_ids.edit(0) as timesheet: + timesheet.unit_amount = 1.0 + sheet = sheet_form.save() + self.employee.allow_generate_purchase_order = False + self.assertFalse(sheet.purchase_order_id) + with self.assertRaises(UserError): + sheet.action_create_purchase_order() + self.employee.billing_partner_id = False + with self.assertRaises(UserError): + sheet.action_create_purchase_order() diff --git a/hr_timesheet_purchase_order/views/hr_employee_view.xml b/hr_timesheet_purchase_order/views/hr_employee_view.xml new file mode 100644 index 0000000000..b7ad870210 --- /dev/null +++ b/hr_timesheet_purchase_order/views/hr_employee_view.xml @@ -0,0 +1,22 @@ + + + + + hr.employee.form.view + hr.employee + + + + + + + + + + diff --git a/hr_timesheet_purchase_order/views/hr_timesheet_sheet_view.xml b/hr_timesheet_purchase_order/views/hr_timesheet_sheet_view.xml new file mode 100644 index 0000000000..6b0175e31d --- /dev/null +++ b/hr_timesheet_purchase_order/views/hr_timesheet_sheet_view.xml @@ -0,0 +1,39 @@ + + + + + hr.timesheet.sheet.form.view + hr_timesheet.sheet + + + + + + + + + + + diff --git a/hr_timesheet_purchase_order/views/res_config_settings_view.xml b/hr_timesheet_purchase_order/views/res_config_settings_view.xml new file mode 100644 index 0000000000..d3b30ebd03 --- /dev/null +++ b/hr_timesheet_purchase_order/views/res_config_settings_view.xml @@ -0,0 +1,41 @@ + + + + + res.config.settings.view.form + res.config.settings + + + +
+
+
+
+
+
+
+ +
diff --git a/hr_timesheet_purchase_order/views/res_partner_view.xml b/hr_timesheet_purchase_order/views/res_partner_view.xml new file mode 100644 index 0000000000..eb9016d6d2 --- /dev/null +++ b/hr_timesheet_purchase_order/views/res_partner_view.xml @@ -0,0 +1,131 @@ + + + + res.partner.form + res.partner + + 99 + + + + + + + + + + + + diff --git a/setup/hr_timesheet_purchase_order/odoo/addons/hr_timesheet_purchase_order b/setup/hr_timesheet_purchase_order/odoo/addons/hr_timesheet_purchase_order new file mode 120000 index 0000000000..0283f51d9e --- /dev/null +++ b/setup/hr_timesheet_purchase_order/odoo/addons/hr_timesheet_purchase_order @@ -0,0 +1 @@ +../../../../hr_timesheet_purchase_order \ No newline at end of file diff --git a/setup/hr_timesheet_purchase_order/setup.py b/setup/hr_timesheet_purchase_order/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/hr_timesheet_purchase_order/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)