diff --git a/partner_identification_automation/README.rst b/partner_identification_automation/README.rst new file mode 100644 index 00000000000..132e770ccd2 --- /dev/null +++ b/partner_identification_automation/README.rst @@ -0,0 +1,101 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================================= +Partner Identification Numbers Automation +========================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1ff2104a773f91d520cc486a6b94e2386746cb247025e01995e352456c450b94 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fpartner--contact-lightgray.png?logo=github + :target: https://github.com/OCA/partner-contact/tree/19.0/partner_identification_automation + :alt: OCA/partner-contact +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/partner-contact-19-0/partner-contact-19-0-partner_identification_automation + :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/partner-contact&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the partner identification numbers functionality to +provide: + +- Automatic default values for identification numbers based on category + configuration +- Scheduled status updates (open, pending, close) based on validity + dates +- Flexible configuration for validity periods and renewal lead times + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +After installation, you can configure default values for each +identification category: + +- Default issuer partner +- Default validity duration (number and unit) +- Renewal lead time (number and unit) to mark ID numbers as "To Renew" + before expiry + +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 +------- + +* OBS Solutions Netherlands + +Contributors +------------ + +- OCA https://odoo-community.org/ +- Odoo Community Association (OCA) https://odoo-community.org/ +- Emiel van Bokhoven - OBS SOLUTIONS NETHERLANDS + https://obs-solutions.com/nl/ + +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. + +This module is part of the `OCA/partner-contact `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/partner_identification_automation/__init__.py b/partner_identification_automation/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/partner_identification_automation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/partner_identification_automation/__manifest__.py b/partner_identification_automation/__manifest__.py new file mode 100644 index 00000000000..e8a7c9300b2 --- /dev/null +++ b/partner_identification_automation/__manifest__.py @@ -0,0 +1,23 @@ +{ + "name": "Partner Identification Numbers Automation", + "summary": "Automate partner identification numbers status updates " + "and default values", + "version": "19.0.1.0.0", + "category": "Customer Relationship Management", + "license": "AGPL-3", + "author": "OBS Solutions Netherlands, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/partner-contact", + "depends": [ + "base", + "partner_identification", + ], + "data": [ + "security/ir.model.access.csv", + "views/res_partner_id_category_view.xml", + "data/cron.xml", + ], + "demo": [], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/partner_identification_automation/data/cron.xml b/partner_identification_automation/data/cron.xml new file mode 100644 index 00000000000..d3977fe838e --- /dev/null +++ b/partner_identification_automation/data/cron.xml @@ -0,0 +1,16 @@ + + + + + Automatic Partner Identification Numbers Status Update + + code + model._run_automatic_status_update() + + 1 + days + True + + diff --git a/partner_identification_automation/i18n/.gitkeep b/partner_identification_automation/i18n/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/partner_identification_automation/i18n/de.po b/partner_identification_automation/i18n/de.po new file mode 100644 index 00000000000..3bc01b46cd8 --- /dev/null +++ b/partner_identification_automation/i18n/de.po @@ -0,0 +1,47 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_identification_automation +# +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Odoo Server\n" + +#. module: partner_identification_automation +#: model:ir.model,name:partner_identification_automation.model_res_partner_id_category +msgid "Partner ID Category" +msgstr "Partner-ID-Kategorie" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_issuer_id +msgid "Default Issuer" +msgstr "Standard-Aussteller" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_number +msgid "Default Validity Duration" +msgstr "Standard-Gültigkeitsdauer" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_unit +msgid "Default Validity Unit" +msgstr "Standard-Gültigkeitsdauer (Einheit)" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_number +msgid "Renewal Lead Number" +msgstr "Vorlaufzeit für Verlängerung (Zahl)" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_unit +msgid "Renewal Lead Unit" +msgstr "Vorlaufzeit für Verlängerung (Einheit)" + +#. module: partner_identification_automation +#: model:ir.actions.server,name:partner_identification_automation.ir_cron_run_automatic_status_update_ir_actions_server +#: model:ir.cron,cron_name:partner_identification_automation.ir_cron_run_automatic_status_update +msgid "Automatic Partner Identification Numbers Status Update" +msgstr "Automatische Aktualisierung des Partner-Identifikationsnummern-Status" \ No newline at end of file diff --git a/partner_identification_automation/i18n/es.po b/partner_identification_automation/i18n/es.po new file mode 100644 index 00000000000..97663f364d5 --- /dev/null +++ b/partner_identification_automation/i18n/es.po @@ -0,0 +1,47 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_identification_automation +# +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Odoo Server\n" + +#. module: partner_identification_automation +#: model:ir.model,name:partner_identification_automation.model_res_partner_id_category +msgid "Partner ID Category" +msgstr "Categoría de identificación del contacto" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_issuer_id +msgid "Default Issuer" +msgstr "Emisor predeterminado" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_number +msgid "Default Validity Duration" +msgstr "Duración de validez predeterminada" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_unit +msgid "Default Validity Unit" +msgstr "Unidad de validez predeterminada" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_number +msgid "Renewal Lead Number" +msgstr "Número de período previo a renovación" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_unit +msgid "Renewal Lead Unit" +msgstr "Unidad de período previo a renovación" + +#. module: partner_identification_automation +#: model:ir.actions.server,name:partner_identification_automation.ir_cron_run_automatic_status_update_ir_actions_server +#: model:ir.cron,cron_name:partner_identification_automation.ir_cron_run_automatic_status_update +msgid "Automatic Partner Identification Numbers Status Update" +msgstr "Actualización automática del estado de números de identificación de contactos" \ No newline at end of file diff --git a/partner_identification_automation/i18n/fr.po b/partner_identification_automation/i18n/fr.po new file mode 100644 index 00000000000..5d65e28ed28 --- /dev/null +++ b/partner_identification_automation/i18n/fr.po @@ -0,0 +1,47 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_identification_automation +# +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Odoo Server\n" + +#. module: partner_identification_automation +#: model:ir.model,name:partner_identification_automation.model_res_partner_id_category +msgid "Partner ID Category" +msgstr "Catégorie d'identité du partenaire" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_issuer_id +msgid "Default Issuer" +msgstr "Émetteur par défaut" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_number +msgid "Default Validity Duration" +msgstr "Durée de validité par défaut" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_unit +msgid "Default Validity Unit" +msgstr "Unité de validité par défaut" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_number +msgid "Renewal Lead Number" +msgstr "Délai avant renouvellement (nombre)" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_unit +msgid "Renewal Lead Unit" +msgstr "Unité de délai avant renouvellement" + +#. module: partner_identification_automation +#: model:ir.actions.server,name:partner_identification_automation.ir_cron_run_automatic_status_update_ir_actions_server +#: model:ir.cron,cron_name:partner_identification_automation.ir_cron_run_automatic_status_update +msgid "Automatic Partner Identification Numbers Status Update" +msgstr "Mise à jour automatique du statut des numéros d'identification des partenaires" \ No newline at end of file diff --git a/partner_identification_automation/i18n/it.po b/partner_identification_automation/i18n/it.po new file mode 100644 index 00000000000..927f72519e7 --- /dev/null +++ b/partner_identification_automation/i18n/it.po @@ -0,0 +1,47 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_identification_automation +# +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Odoo Server\n" + +#. module: partner_identification_automation +#: model:ir.model,name:partner_identification_automation.model_res_partner_id_category +msgid "Partner ID Category" +msgstr "Categoria identificativo partner" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_issuer_id +msgid "Default Issuer" +msgstr "Ente emittente predefinito" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_number +msgid "Default Validity Duration" +msgstr "Durata validità predefinita" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_unit +msgid "Default Validity Unit" +msgstr "Unità validità predefinita" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_number +msgid "Renewal Lead Number" +msgstr "Numero periodo preavviso rinnovo" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_unit +msgid "Renewal Lead Unit" +msgstr "Unità periodo preavviso rinnovo" + +#. module: partner_identification_automation +#: model:ir.actions.server,name:partner_identification_automation.ir_cron_run_automatic_status_update_ir_actions_server +#: model:ir.cron,cron_name:partner_identification_automation.ir_cron_run_automatic_status_update +msgid "Automatic Partner Identification Numbers Status Update" +msgstr "Aggiornamento automatico stato numeri identificativi partner" \ No newline at end of file diff --git a/partner_identification_automation/i18n/nl.po b/partner_identification_automation/i18n/nl.po new file mode 100644 index 00000000000..d9883e32c89 --- /dev/null +++ b/partner_identification_automation/i18n/nl.po @@ -0,0 +1,47 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_identification_automation +# +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Odoo Server\n" + +#. module: partner_identification_automation +#: model:ir.model,name:partner_identification_automation.model_res_partner_id_category +msgid "Partner ID Category" +msgstr "Partner ID-categorie" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_issuer_id +msgid "Default Issuer" +msgstr "Standaard uitgever" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_number +msgid "Default Validity Duration" +msgstr "Standaard geldigheidsduur" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_unit +msgid "Default Validity Unit" +msgstr "Standaard geldigheidsduur (eenheid)" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_number +msgid "Renewal Lead Number" +msgstr "Voorlooptijd vernieuwing (getal)" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_unit +msgid "Renewal Lead Unit" +msgstr "Voorlooptijd vernieuwing (eenheid)" + +#. module: partner_identification_automation +#: model:ir.actions.server,name:partner_identification_automation.ir_cron_run_automatic_status_update_ir_actions_server +#: model:ir.cron,cron_name:partner_identification_automation.ir_cron_run_automatic_status_update +msgid "Automatic Partner Identification Numbers Status Update" +msgstr "Automatische bijwerking partner identificatienummer status" \ No newline at end of file diff --git a/partner_identification_automation/i18n/partner_identification_automation.pot b/partner_identification_automation/i18n/partner_identification_automation.pot new file mode 100644 index 00000000000..d5154de7e72 --- /dev/null +++ b/partner_identification_automation/i18n/partner_identification_automation.pot @@ -0,0 +1,47 @@ +# Translation of Odoo Server. +# This file contains the translation templates of Odoo Server. +# You should generate this file from the server using the "export" function of the Translation menu. +# +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: \n" +"Generated-By: Odoo Server\n" + +#. module: partner_identification_automation +#: model:ir.model,name:partner_identification_automation.model_res_partner_id_category +msgid "Partner ID Category" +msgstr "" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_issuer_id +msgid "Default Issuer" +msgstr "" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_number +msgid "Default Validity Duration" +msgstr "" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__default_validity_unit +msgid "Default Validity Unit" +msgstr "" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_number +msgid "Renewal Lead Number" +msgstr "" + +#. module: partner_identification_automation +#: model:ir.model.fields,field_description:partner_identification_automation.field_res_partner_id_category__renewal_lead_unit +msgid "Renewal Lead Unit" +msgstr "" + +#. module: partner_identification_automation +#: model:ir.actions.server,name:partner_identification_automation.ir_cron_run_automatic_status_update_ir_actions_server +#: model:ir.cron,cron_name:partner_identification_automation.ir_cron_run_automatic_status_update +msgid "Automatic Partner Identification Numbers Status Update" +msgstr "" \ No newline at end of file diff --git a/partner_identification_automation/models/__init__.py b/partner_identification_automation/models/__init__.py new file mode 100644 index 00000000000..8565aee8e45 --- /dev/null +++ b/partner_identification_automation/models/__init__.py @@ -0,0 +1,2 @@ +from . import id_category +from . import res_partner_id_number diff --git a/partner_identification_automation/models/id_category.py b/partner_identification_automation/models/id_category.py new file mode 100644 index 00000000000..25bf65dc4a6 --- /dev/null +++ b/partner_identification_automation/models/id_category.py @@ -0,0 +1,40 @@ +from odoo import fields, models + + +class ResPartnerIdCategory(models.Model): + _inherit = "res.partner.id_category" + + default_issuer_id = fields.Many2one( + comodel_name="res.partner", + string="Default Issuer", + help="Default issuer for identification numbers of this category", + ) + default_validity_number = fields.Integer( + default=1, + help="Default validity duration number for this category", + ) + default_validity_unit = fields.Selection( + [ + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ("years", "Years"), + ], + default="years", + help="Default validity duration unit for this category", + ) + + renewal_lead_number = fields.Integer( + default=1, + help='Number of time units before expiry to mark document as "To Renew"', + ) + renewal_lead_unit = fields.Selection( + [ + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ("years", "Years"), + ], + default="months", + help="Time unit for renewal lead time", + ) diff --git a/partner_identification_automation/models/res_partner_id_number.py b/partner_identification_automation/models/res_partner_id_number.py new file mode 100644 index 00000000000..eb30c40c257 --- /dev/null +++ b/partner_identification_automation/models/res_partner_id_number.py @@ -0,0 +1,150 @@ +from collections import defaultdict + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +def _get_new_date(base_date, number, unit): + """Helper function to calculate new date based on number and unit.""" + if number is None or not unit: + return base_date + if unit == "days": + return base_date + relativedelta(days=number) + if unit == "weeks": + return base_date + relativedelta(weeks=number) + if unit == "months": + return base_date + relativedelta(months=number) + if unit == "years": + return base_date + relativedelta(years=number) + return base_date + + +class ResPartnerIdNumber(models.Model): + _inherit = "res.partner.id_number" + + @api.model_create_multi + def create(self, vals_list): + """Extend create to calculate validity end date based on category defaults""" + for vals in vals_list: + # If valid_from is provided and no valid_until is specified, + # calculate it from category defaults + if ( + vals.get("valid_from") + and not vals.get("valid_until") + and vals.get("category_id") + ): + category = self.env["res.partner.id_category"].browse( + vals["category_id"] + ) + if ( + category.default_validity_number is not None + and category.default_validity_unit + ): + start_date = fields.Date.from_string(vals["valid_from"]) + end_date = _get_new_date( + start_date, + category.default_validity_number, + category.default_validity_unit, + ) + vals["valid_until"] = end_date + + records = super().create(vals_list) + return records + + @api.onchange("category_id", "valid_from") + def _onchange_category_defaults(self): + """Set default values based on category configuration""" + if self.category_id: + # Set default issuer if configured + if self.category_id.default_issuer_id: + self.partner_issued_id = self.category_id.default_issuer_id + + # Set default validity end date if valid_from is set + if ( + self.valid_from + and self.category_id.default_validity_number is not None + and self.category_id.default_validity_unit + ): + start_date = self.valid_from + end_date = _get_new_date( + start_date, + self.category_id.default_validity_number, + self.category_id.default_validity_unit, + ) + self.valid_until = end_date # Use valid_until instead of validity_end + + def _run_automatic_status_update(self): + """Run automatic status updates for identification documents.""" + today = fields.Date.context_today(self) + today_str = fields.Date.to_string(today) + + # Priority 1: Expired documents - documents with valid_until < today + docs_to_expire = self.search( + [["status", "!=", "close"], ["valid_until", "<", today_str]] + ) + if docs_to_expire: + docs_to_expire.write({"status": "close"}) + + # Priority 2: To Renew documents - documents that are in the renewal window + # Fetch only documents from categories with renewal settings + docs_to_set_pending = self.env[self._name].browse() + + # Get categories that have renewal settings configured + categories_with_renewal = self.env["res.partner.id_category"].search( + [ + ("renewal_lead_number", ">", 0), + ("renewal_lead_unit", "!=", False), + ] + ) + + # Group categories by renewal settings to reduce number of search operations + categories_by_renewal_key = defaultdict( + lambda: self.env["res.partner.id_category"] + ) + for category in categories_with_renewal: + key = (category.renewal_lead_number, category.renewal_lead_unit) + categories_by_renewal_key[key] |= category + + # Perform one search per group of categories with the same renewal settings + for (number, unit), categories in categories_by_renewal_key.items(): + renewal_expiry_upper_bound = _get_new_date(today, number, unit) + renewal_expiry_upper_bound_str = fields.Date.to_string( + renewal_expiry_upper_bound + ) + + # Search for documents for all categories with the same renewal settings + category_docs_to_renew = self.search( + [ + ["category_id", "in", categories.ids], + ["status", "in", ["draft", "open"]], + [ + "valid_until", + ">", + today_str, + ], # Not expired (today < valid_until) + [ + "valid_until", + "<", + renewal_expiry_upper_bound_str, + ], # valid_until < (today + renewal_period) + ["valid_from", "<=", today_str], # Has started + ] + ) + + docs_to_set_pending |= category_docs_to_renew + + if docs_to_set_pending: + docs_to_set_pending.write({"status": "pending"}) + + # Priority 3: Documents that should be opened (valid_from <= today <= + # valid_until) + docs_to_open = self.search( + [ + ["status", "not in", ("open", "pending", "close")], + ["valid_from", "<=", today_str], + ["valid_until", ">=", today_str], + ] + ) + if docs_to_open: + docs_to_open.write({"status": "open"}) diff --git a/partner_identification_automation/pyproject.toml b/partner_identification_automation/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/partner_identification_automation/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/partner_identification_automation/readme/CONFIGURE.md b/partner_identification_automation/readme/CONFIGURE.md new file mode 100644 index 00000000000..e5830b7aafe --- /dev/null +++ b/partner_identification_automation/readme/CONFIGURE.md @@ -0,0 +1,5 @@ +After installation, you can configure default values for each identification category: + +* Default issuer partner +* Default validity duration (number and unit) +* Renewal lead time (number and unit) to mark ID numbers as "To Renew" before expiry diff --git a/partner_identification_automation/readme/CONTRIBUTORS.md b/partner_identification_automation/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..7bfad9bd7d3 --- /dev/null +++ b/partner_identification_automation/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +* OCA +* Odoo Community Association (OCA) +* Emiel van Bokhoven - OBS SOLUTIONS NETHERLANDS diff --git a/partner_identification_automation/readme/DESCRIPTION.md b/partner_identification_automation/readme/DESCRIPTION.md new file mode 100644 index 00000000000..8c3b8047436 --- /dev/null +++ b/partner_identification_automation/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module extends the partner identification numbers functionality to provide: + +* Automatic default values for identification numbers based on category configuration +* Scheduled status updates (open, pending, close) based on validity dates +* Flexible configuration for validity periods and renewal lead times diff --git a/partner_identification_automation/security/ir.model.access.csv b/partner_identification_automation/security/ir.model.access.csv new file mode 100644 index 00000000000..1f96346632b --- /dev/null +++ b/partner_identification_automation/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_res_partner_id_category_automation_public","res.partner.id_category automation public","model_res_partner_id_category","base.group_public",1,0,0,0 +"access_res_partner_id_category_automation_portal","res.partner.id_category automation portal","model_res_partner_id_category","base.group_portal",1,0,0,0 diff --git a/partner_identification_automation/static/description/icon.png b/partner_identification_automation/static/description/icon.png new file mode 100644 index 00000000000..1dcc49c24f3 Binary files /dev/null and b/partner_identification_automation/static/description/icon.png differ diff --git a/partner_identification_automation/static/description/index.html b/partner_identification_automation/static/description/index.html new file mode 100644 index 00000000000..7123733c531 --- /dev/null +++ b/partner_identification_automation/static/description/index.html @@ -0,0 +1,452 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Partner Identification Numbers Automation

+ +

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

+

This module extends the partner identification numbers functionality to +provide:

+
    +
  • Automatic default values for identification numbers based on category +configuration
  • +
  • Scheduled status updates (open, pending, close) based on validity +dates
  • +
  • Flexible configuration for validity periods and renewal lead times
  • +
+

Table of contents

+ +
+

Configuration

+

After installation, you can configure default values for each +identification category:

+
    +
  • Default issuer partner
  • +
  • Default validity duration (number and unit)
  • +
  • Renewal lead time (number and unit) to mark ID numbers as “To Renew” +before expiry
  • +
+
+
+

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

+
    +
  • OBS Solutions Netherlands
  • +
+
+
+

Contributors

+ +
+
+

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.

+

This module is part of the OCA/partner-contact project on GitHub.

+

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

+
+
+
+
+ + diff --git a/partner_identification_automation/tests/__init__.py b/partner_identification_automation/tests/__init__.py new file mode 100644 index 00000000000..1e81ac414ff --- /dev/null +++ b/partner_identification_automation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_identification_automation diff --git a/partner_identification_automation/tests/test_identification_automation.py b/partner_identification_automation/tests/test_identification_automation.py new file mode 100644 index 00000000000..67fd8a864b4 --- /dev/null +++ b/partner_identification_automation/tests/test_identification_automation.py @@ -0,0 +1,1007 @@ +from datetime import datetime, timedelta + +from freezegun import freeze_time + +from odoo.tests.common import tagged + +from odoo.addons.base.tests.common import BaseCommon + + +@tagged("post_install", "-at_install") +class TestIdentificationAutomation(BaseCommon): + def setUp(self): + super().setUp() + + # Create a test partner for issuer + self.test_issuer = self.env["res.partner"].create( + { + "name": "Test Issuer", + } + ) + + # Create a test partner + self.test_partner = self.env["res.partner"].create( + { + "name": "Test Partner", + } + ) + + # Create a category with default values + self.category = self.env["res.partner.id_category"].create( + { + "code": "test_license", + "name": "Test License", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 30, + "default_validity_unit": "days", + "renewal_lead_number": 5, + "renewal_lead_unit": "days", + } + ) + + # Create identification number record + self.identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": self.category.id, + "name": "TEST123456", + "valid_from": "2023-01-01", + } + ) + + def test_onchange_defaults(self): + """Test that the onchange method sets default values correctly""" + # Test the onchange method by simulating form interaction + identification = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": self.category.id, + "name": "TEST987654", + "valid_from": "2023-01-01", + } + ) + + # Simulate onchange + identification._onchange_category_defaults() + + # Check that default issuer is set + self.assertEqual(identification.partner_issued_id, self.test_issuer) + + # Check that validity end is calculated correctly (30 days from start) + expected_end = datetime(2023, 1, 1) + timedelta(days=30) + self.assertEqual(identification.valid_until, expected_end.date()) + + def test_status_expired(self): + """Test that expired documents are properly marked as expired""" + # Set validity end to a past date + self.identification.write( + { + "valid_until": "2022-12-31", + "status": "open", # Use 'open' instead of 'running' + } + ) + + # Run status update with frozen time in the future + with freeze_time("2023-02-01"): + self.identification._run_automatic_status_update() + + # Check that the document is marked as expired + self.identification = self.identification.browse(self.identification.id) + self.assertEqual(self.identification.status, "close") # Use 'close' not 'exp' + + def test_status_to_renew(self): + """Test that documents approaching expiry are marked as to_renew""" + # Category has renewal_lead of 5 days, validity is 30 days from start + # So anything within 5 days of expiry should be marked as pending + # Start date is 2023-01-01, end date = 2023-01-31 (30 days later) + # So anything 5 days before end date (2023-01-26) and before today = 'pending' + + # Set valid_until to allow expiry in 3 days from "today" (2023-01-28) + self.identification.write( + { + "valid_from": "2023-01-01", + "valid_until": "2023-01-28", # Only 3 days from "today" (2023-01-25) + "status": "open", # Use 'open' instead of 'running' + } + ) + + # Run status update - should trigger 'pending' within 5 days of expiry + with freeze_time("2023-01-25"): + self.identification._run_automatic_status_update() + + # Check that the document is marked as pending + self.identification = self.identification.browse(self.identification.id) + self.assertEqual(self.identification.status, "pending") # Use 'pend' not 'tor' + + def test_status_running(self): + """Test that valid documents are marked as running""" + # Set validity period that is currently valid + self.identification.write( + {"valid_from": "2023-01-01", "valid_until": "2023-12-31", "status": "draft"} + ) + + # Run status update with current date in the valid range + with freeze_time("2023-06-01"): + self.identification._run_automatic_status_update() + + # Check that the document is marked as running (open) + self.identification = self.identification.browse(self.identification.id) + self.assertEqual(self.identification.status, "open") # Use 'open' not 'running' + + def test_status_not_updated_for_final_states(self): + """Test that final states like expired and cancelled aren't changed auto""" + # Mark document as expired (close) + self.identification.write( + { + "valid_until": "2022-01-01", + "status": "close", # Use 'close' instead of 'expired' + } + ) + + # Run status update + with freeze_time("2023-06-01"): + self.identification._run_automatic_status_update() + + # Check that status remains close + self.identification = self.identification.browse(self.identification.id) + self.assertEqual(self.identification.status, "close") + + # Mark doc as cancelled - orig module doesn't have 'cancelled' status, + # test the actual final state which is 'close' + self.identification.write({"status": "close"}) + + # Run status update + with freeze_time("2023-06-01"): + self.identification._run_automatic_status_update() + + # Check that status remains close + self.identification = self.identification.browse(self.identification.id) + self.assertEqual(self.identification.status, "close") + + def test_onchange_defaults_without_validity_start(self): + """Test onchange when no validity start is provided""" + identification = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": self.category.id, + "name": "TEST987655", + "valid_from": False, + } + ) + + # Simulate onchange + identification._onchange_category_defaults() + + # Check that default issuer is set even without valid_from + self.assertEqual(identification.partner_issued_id, self.test_issuer) + # But validity_until should not be set since valid_from is False + self.assertFalse(identification.valid_until) + + def test_onchange_defaults_without_category(self): + """Test onchange when no category is provided""" + identification = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": False, + "name": "TEST987657", + "valid_from": "2023-01-01", + } + ) + + # Simulate onchange + identification._onchange_category_defaults() + + # Check that no defaults are set when category is False + self.assertFalse(identification.partner_issued_id) + # The valid_from field is stored as a date object, not string + expected_date = datetime(2023, 1, 1).date() + self.assertEqual(identification.valid_from, expected_date) + + def test_onchange_defaults_with_weeks_unit(self): + """Test onchange with weeks validity unit""" + # Create category with weeks unit + category_weeks = self.env["res.partner.id_category"].create( + { + "code": "test_license_weeks", + "name": "Test License Weeks", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 2, + "default_validity_unit": "weeks", + "renewal_lead_number": 1, + "renewal_lead_unit": "days", + } + ) + + identification = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_weeks.id, + "name": "TEST987658", + "valid_from": "2023-01-01", + } + ) + + # Simulate onchange + identification._onchange_category_defaults() + + # Check that default issuer is set + self.assertEqual(identification.partner_issued_id, self.test_issuer) + + # Check that validity end is calculated correctly (2 weeks from start) + expected_end = datetime(2023, 1, 1).date() + timedelta(weeks=2) + self.assertEqual(identification.valid_until, expected_end) + + def test_onchange_defaults_with_months_unit(self): + """Test onchange with months validity unit and edge cases""" + # Create category with months unit + category_months = self.env["res.partner.id_category"].create( + { + "code": "test_license_months", + "name": "Test License Months", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 1, + "default_validity_unit": "months", + "renewal_lead_number": 1, + "renewal_lead_unit": "days", + } + ) + + # Test with Jan 31 - edge case for month overflow + identification = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_months.id, + "name": "TEST987659", + "valid_from": "2023-01-31", # Jan 31 + } + ) + + # Simulate onchange - Jan 31 + 1 month should become Feb 28 + identification._onchange_category_defaults() + + # Check that default issuer is set + self.assertEqual(identification.partner_issued_id, self.test_issuer) + + # Expected end date should be Feb 28, 2023 + expected_end = datetime(2023, 2, 28).date() + self.assertEqual(identification.valid_until, expected_end) + + def test_onchange_defaults_with_years_unit(self): + """Test onchange with years validity unit and leap year handling""" + # Create category with years unit + category_years = self.env["res.partner.id_category"].create( + { + "code": "test_license_years", + "name": "Test License Years", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 1, + "default_validity_unit": "years", + "renewal_lead_number": 1, + "renewal_lead_unit": "days", + } + ) + + # Test with Feb 29 leap year - should handle leap year edge case + identification_leap = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_years.id, + "name": "TEST987660", + "valid_from": "2024-02-29", # Leap year Feb 29 + } + ) + + # Simulate onchange - Feb 29, 2024 + 1 year should become Feb 28, 2025 + identification_leap._onchange_category_defaults() + + # Check that default issuer is set + self.assertEqual(identification_leap.partner_issued_id, self.test_issuer) + + # Expected end date should be Feb 28, 2025 (since 2025 is not a leap year) + expected_end = datetime(2025, 2, 28).date() + self.assertEqual(identification_leap.valid_until, expected_end) + + # Test normal case without leap year issues + identification_normal = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_years.id, + "name": "TEST987661", + "valid_from": "2023-03-15", # Normal date + } + ) + + identification_normal._onchange_category_defaults() + + # Expected end date should be Mar 15, 2024 + expected_end_normal = datetime(2024, 3, 15).date() + self.assertEqual(identification_normal.valid_until, expected_end_normal) + + def test_onchange_defaults_with_zero_duration(self): + """Test onchange when validity duration is zero (edge case)""" + # Create category with zero duration - this tests the case where + # validity calculation results in no change to the start date + category_zero = self.env["res.partner.id_category"].create( + { + "code": "test_license_zero", + "name": "Test License Zero", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 0, # Zero duration + "default_validity_unit": "days", # Using days with zero + "renewal_lead_number": 1, + "renewal_lead_unit": "days", + } + ) + + # Create an identification with valid_until already set and test what happens + # when we call onchange + identification = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_zero.id, + "name": "TEST987662", + "valid_from": datetime(2023, 1, 1).date(), + "valid_until": datetime( + 2023, 1, 15 + ).date(), # Initially set to a different date + } + ) + + # Call the onchange method to populate the defaults + identification._onchange_category_defaults() + + # Check that default issuer is set + self.assertEqual(identification.partner_issued_id, self.test_issuer) + + # With 0 days duration, valid_until should be set to valid_from (2023-01-01), + # since 0 days validity means it expires the same day it starts + self.assertEqual(identification.valid_until, datetime(2023, 1, 1).date()) + + def test_onchange_defaults_with_no_validity_dates(self): + """Test onchange when no validity dates are specified""" + # Create category normally + category_normal = self.env["res.partner.id_category"].create( + { + "code": "test_license_normal", + "name": "Test License Normal", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 10, + "default_validity_unit": "days", + "renewal_lead_number": 1, + "renewal_lead_unit": "days", + } + ) + + # Create identification without validity start date + identification = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_normal.id, + "name": "TEST987663", + "valid_from": False, # No start date + } + ) + + # Simulate onchange - it should not try to calculate end date without start date + identification._onchange_category_defaults() + + # Check that default issuer is set + self.assertEqual(identification.partner_issued_id, self.test_issuer) + + # End date should not be set since there's no start date + self.assertFalse(identification.valid_until) + + def test_renewal_with_different_units(self): + """Test renewal calculation with different time units""" + # Test with weeks + category_weeks = self.env["res.partner.id_category"].create( + { + "code": "test_license_weeks", + "name": "Test License Weeks", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 1, + "default_validity_unit": "weeks", + "renewal_lead_number": 1, + "renewal_lead_unit": "weeks", + } + ) + + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_weeks.id, + "name": "TEST_WEEKS", + "valid_from": "2023-01-01", + "valid_until": "2023-01-08", # 1 week from start + } + ) + + # Simulate time just before renewal period (end - 1 week = 2023-01-01, + # so 2023-01-02 should be in renewal window) + # Actually, the renewal cutoff would be 2023-01-08 - 1 week = 2023-01-01 + # So if we set time to 2023-01-02, it should be pending since today > cutoff + # and today < expiry + + with freeze_time("2023-01-02"): + identification._run_automatic_status_update() + + # Refresh the record and check status + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "pending") + + def test_renewal_calculation_months_edge_case(self): + """Test renewal calculation edge case with months""" + # Create category with month-based renewal + category_months = self.env["res.partner.id_category"].create( + { + "code": "test_license_months", + "name": "Test License Months", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 1, + "default_validity_unit": "months", + "renewal_lead_number": 1, + "renewal_lead_unit": "months", + } + ) + + # Create ID that expires on Feb 28 (edge cases when subtracting months) + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_months.id, + "name": "TEST_MONTHS", + "valid_from": "2023-01-31", + "valid_until": "2023-02-28", # Feb 2023 only has 28 days + } + ) + + # The renewal cutoff would be 2023-02-28 - 1 month = 2023-01-28 + # So if we're after Jan 28 but before Feb 28, it should be pending + with freeze_time("2023-02-20"): + identification._run_automatic_status_update() + + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "pending") + + def test_expired_but_already_pending(self): + """Test that expired identification with pending status gets closed""" + # Create an ID that should be marked as pending + self.identification.write( + { + "valid_from": "2023-01-01", + "valid_until": "2023-01-30", + "status": "pending", # Already pending + } + ) + + # Simulate time after expiration + with freeze_time("2023-02-01"): + self.identification._run_automatic_status_update() + + # Refresh the record and check status - should be 'close' now + self.identification = self.identification.browse(self.identification.id) + self.assertEqual(self.identification.status, "close") + + def test_running_to_pending_transition(self): + """Test transition from open to pending when entering renewal window""" + # Create an ID that is currently valid (should be 'open') + self.identification.write( + { + "valid_from": "2023-01-01", + "valid_until": "2023-02-10", + "status": "open", # Currently running + } + ) + + # Set time to when it should become pending (within renewal window) + # Category has renewal_lead_number: 5 and renewal_lead_unit: 'days' + # So renewal cutoff is 2023-02-10 - 5 days = 2023-02-05 + # With time 2023-02-06, it should be pending + # (2023-02-05 < 2023-02-06 < 2023-02-10) + with freeze_time("2023-02-06"): + self.identification._run_automatic_status_update() + + # Refresh the record and check status - should be 'pending' + self.identification = self.identification.browse(self.identification.id) + self.assertEqual(self.identification.status, "pending") + + def test_no_renewal_settings(self): + """Test behavior when category has no renewal settings""" + # Create category with no renewal settings + # Explicitly set renewal lead to 0 to indicate no renewal functionality + category_no_renewal = self.env["res.partner.id_category"].create( + { + "code": "test_license_no_renewal", + "name": "Test License No Renewal", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 30, + "default_validity_unit": "days", + # Explicitly disable renewal settings + "renewal_lead_number": 0, + "renewal_lead_unit": "days", + } + ) + + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_no_renewal.id, + "name": "TEST_NO_RENEWAL", + "valid_from": "2023-01-01", + "valid_until": "2023-02-10", + "status": "open", + } + ) + + # Run status update - should handle gracefully without renewal settings + # In this case, _calculate_renewal_cutoff will return valid_until_date + # So renewal_cutoff (2023-02-10) < today (2023-02-05) is False + # So it won't be marked as pending and will remain open (if it's in valid range) + with freeze_time("2023-02-05"): # Within validity but before expiry + identification._run_automatic_status_update() + + identification = identification.browse(identification.id) + # Should be 'open' since 2023-02-05 is within validity range + # and not in renewal window (renewal_cutoff = expiry when no renewal settings) + self.assertEqual(identification.status, "open") + + def test_no_validity_dates(self): + """Test behavior when identification has no validity dates""" + # Create identification without validity dates + identification_no_dates = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": self.category.id, + "name": "TEST_NO_DATES", + "valid_from": False, + "valid_until": False, + "status": "draft", + } + ) + + # Run status update - should not cause errors + with freeze_time("2023-01-01"): + identification_no_dates._run_automatic_status_update() + + # Refresh and check: should remain in original status since no validity dates + identification_no_dates = identification_no_dates.browse( + identification_no_dates.id + ) + self.assertEqual(identification_no_dates.status, "draft") + + def test_renewal_cutoff_calculation_months_edge_cases(self): + """Test renewal cutoff calculation for months with year underflow""" + # Test the month underflow logic: month <= 0, year -= 1, month += 12 + category_months = self.env["res.partner.id_category"].create( + { + "code": "test_renewal_months", + "name": "Test Renewal Months", + "default_issuer_id": self.test_issuer.id, + "renewal_lead_number": 15, # 15 months + "renewal_lead_unit": "months", # Subtracting from expiry + } + ) + + # Create identification with expiry in early year so subtracting 15 months + # goes to previous year + # Expiry on 2023-02-15, minus 15 months = 1 month in previous year + # = 2022-11-15 + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_months.id, + "name": "TEST_RENEWAL_MONTHS", + "valid_from": "2022-01-01", + "valid_until": "2023-02-15", # Feb 15, 2023 + "status": "open", + } + ) + + # Run status update + with freeze_time("2022-12-01"): # After the calculated renewal cutoff date + identification._run_automatic_status_update() + + # Refresh and check status - should be 'pending' because renewal cutoff is + # 2023-02-15 minus 15 months = May 15, 2022. So 2022-12-01 > 2022-05-15 + # and < 2023-02-15 + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "pending") + + def test_renewal_cutoff_calculation_years_edge_cases(self): + """Test renewal cutoff calculation for years with leap year handling""" + # Test the year calculation with leap year edge case + category_years = self.env["res.partner.id_category"].create( + { + "code": "test_renewal_years", + "name": "Test Renewal Years", + "default_issuer_id": self.test_issuer.id, + "renewal_lead_number": 1, # 1 year + "renewal_lead_unit": "years", # Subtracting from expiry + } + ) + + # Create identification expiring on Feb 29 in a leap year + # This should test the leap year handling in renewal cutoff calculation + identification_leap = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_years.id, + "name": "TEST_RENEWAL_LEAP", + "valid_from": "2020-01-01", + "valid_until": "2024-02-29", # Feb 29, 2024 (leap year) + "status": "open", + } + ) + + # The renewal cutoff will be: 2024-02-29 minus 1 year = 2023-02-29 + # But 2023 is not a leap year, so it should become 2023-02-28 + # So if we check after 2023-02-28, it should be pending + with freeze_time("2023-03-01"): # After the renewal cutoff date + identification_leap._run_automatic_status_update() + + # Refresh and check: should be 'pending' because today is after renewal cutoff + identification_leap = identification_leap.browse(identification_leap.id) + self.assertEqual(identification_leap.status, "pending") + + # Also test a normal case without leap year issues + identification_normal = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_years.id, + "name": "TEST_RENEWAL_NORMAL", + "valid_from": "2022-01-01", + "valid_until": "2024-03-15", # March 15, 2024 (not a leap year issue) + "status": "open", + } + ) + + # Renewal cutoff: 2024-03-15 minus 1 year = 2023-03-15 + with freeze_time("2023-03-20"): # After the renewal cutoff + identification_normal._run_automatic_status_update() + + identification_normal = identification_normal.browse(identification_normal.id) + self.assertEqual(identification_normal.status, "pending") + + def test_renewal_calculation_with_category_without_settings(self): + """Test renewal calculation when category doesn't have renewal settings""" + # Create category without renewal lead configurations + category_no_renewal = self.env["res.partner.id_category"].create( + { + "code": "test_no_renewal_settings", + "name": "Test No Renewal Settings", + "default_issuer_id": self.test_issuer.id, + # Deliberately not setting renewal_lead_number and renewal_lead_unit + } + ) + + # Create identification with this category + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_no_renewal.id, + "name": "TEST_NO_RENEWAL_SETTINGS", + "valid_from": "2023-01-01", + "valid_until": "2023-12-31", # Future date + "status": "open", + } + ) + + # Run status update - should handle gracefully when no renewal settings exist + with freeze_time("2023-06-01"): + identification._run_automatic_status_update() + + # Refresh and check: should still be open since no renewal window is defined + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "open") + + def test_status_transitions_priority_correctness(self): + """Test that status transitions happen in correct priority order""" + # Create category with short renewal window to ensure the document will be + # pending + category_short_renewal = self.env["res.partner.id_category"].create( + { + "code": "test_short_renewal", + "name": "Test Short Renewal", + "default_issuer_id": self.test_issuer.id, + "renewal_lead_number": 30, # 30 days before expiry + "renewal_lead_unit": "days", + } + ) + + # Create identification that is currently valid but within renewal window + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_short_renewal.id, + "name": "TEST_PRIORITY", + "valid_from": "2023-01-01", + "valid_until": "2023-06-15", # Expires in near future + "status": "open", + } + ) + + # Run status update with time after renewal cutoff but before expiry + # Renewal cutoff: 2023-06-15 - 30 days = 2023-05-16 + # Current time: 2023-06-01 (after cutoff, before expiry) + with freeze_time("2023-06-01"): + identification._run_automatic_status_update() + + # Should be 'pending' because in renewal window and not expired + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "pending") + + # Change time to after expiry - should now be 'close' regardless of previous + # 'pending' + with freeze_time("2023-07-01"): + identification._run_automatic_status_update() + + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "close") + + def test_running_status_when_not_in_renewal_window(self): + """Test that valid IDs remain 'open' when not in renewal window""" + # Create identification that is currently valid and not in renewal window + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": self.category.id, # Using default category + "name": "TEST_RUNNING", + "valid_from": "2023-01-01", + "valid_until": "2025-12-31", # Far future date + "status": "draft", # Starting with draft status + } + ) + + # Run status update - should transition from draft to open + with freeze_time("2023-06-01"): + identification._run_automatic_status_update() + + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "open") + + # Run again - should remain open since not in renewal window or expired + with freeze_time("2023-06-02"): + identification._run_automatic_status_update() + + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "open") + + def test_category_with_default_validity_settings_years(self): + """Test that category default validity settings in years work correctly""" + # Create a category with default validity in years + category_years = self.env["res.partner.id_category"].create( + { + "name": "Test Category Years", + "code": "test_years", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 2, # 2 years + "default_validity_unit": "years", # Years unit + } + ) + + # Create an identification with valid_from date + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_years.id, + "name": "TEST_DEFAULT_YEARS", + "valid_from": "2023-01-01", + } + ) + + # Verify that valid_until is set correctly (2 years from 2023-01-01) + expected_valid_until = datetime(2025, 1, 1).date() + self.assertEqual(identification.valid_until, expected_valid_until) + + def test_category_with_default_validity_settings_months(self): + """Test that category default validity settings in months work correctly""" + # Create a category with default validity in months + category_months = self.env["res.partner.id_category"].create( + { + "name": "Test Category Months", + "code": "test_months", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 6, # 6 months + "default_validity_unit": "months", # Months unit + } + ) + + # Create an identification with valid_from date + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_months.id, + "name": "TEST_DEFAULT_MONTHS", + "valid_from": "2023-01-31", # End of January to test month edge cases + } + ) + + # Verify that valid_until is calculated correctly (Jan 31 + 6 months = July 31) + expected_valid_until = datetime(2023, 7, 31).date() + self.assertEqual(identification.valid_until, expected_valid_until) + + def test_renewal_calculation_with_months_lead_time(self): + """Test renewal calculation with months as lead time unit""" + # Create category with renewal lead of 2 months + category_months_lead = self.env["res.partner.id_category"].create( + { + "name": "Test Category Months Lead", + "code": "test_months_lead", + "default_issuer_id": self.test_issuer.id, + "renewal_lead_number": 2, # 2 months before expiry + "renewal_lead_unit": "months", # Months unit for renewal lead + } + ) + + # Create identification expiring in 3 months + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_months_lead.id, + "name": "TEST_RENEWAL_MONTHS_LEAD", + "valid_from": "2023-01-01", + "valid_until": "2023-04-01", # April 1, 2023 + } + ) + + # Run status update after renewal cutoff (April 1 - 2 months = Feb 1) + # So if current date is after Feb 1, it should be pending + with freeze_time("2023-03-01"): # After Feb 1, before Apr 1 + identification._run_automatic_status_update() + + # Verify that the status was updated correctly by the automated system + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "pending") + + def test_renewal_calculation_with_weeks_lead_time(self): + """Test renewal calculation with weeks as lead time unit""" + # Create category with renewal lead of 3 weeks + category_weeks_lead = self.env["res.partner.id_category"].create( + { + "name": "Test Category Weeks Lead", + "code": "test_weeks_lead", + "default_issuer_id": self.test_issuer.id, + "renewal_lead_number": 3, # 3 weeks before expiry + "renewal_lead_unit": "weeks", # Weeks unit for renewal lead + } + ) + + # Create identification expiring in 4 weeks + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_weeks_lead.id, + "name": "TEST_RENEWAL_WEEKS_LEAD", + "valid_from": "2023-01-01", + "valid_until": "2023-01-29", # 29 days from start (approx. 4 weeks) + } + ) + + # Run status update with time after renewal cutoff (Jan 29 - 3 weeks = Jan 8) + # So if current date is after Jan 8, it should trigger pending status + with freeze_time("2023-01-15"): # After Jan 8, before Jan 29 + identification._run_automatic_status_update() + + # Verify that the status was updated correctly by the automated system + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "pending") + + def test_renewal_calculation_with_days_lead_time(self): + """Test renewal calculation with days as lead time unit""" + # Create category with renewal lead of 10 days + category_days_lead = self.env["res.partner.id_category"].create( + { + "name": "Test Category Days Lead", + "code": "test_days_lead", + "default_issuer_id": self.test_issuer.id, + "renewal_lead_number": 10, # 10 days before expiry + "renewal_lead_unit": "days", # Days unit for renewal lead + } + ) + + # Create identification expiring in 15 days + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_days_lead.id, + "name": "TEST_RENEWAL_DAYS_LEAD", + "valid_from": "2023-01-01", + "valid_until": "2023-01-16", # 15 days from start + } + ) + + # Run status update with time after renewal cutoff (Jan 16 - 10 days = Jan 6) + # So if current date is after Jan 6, it should trigger pending status + with freeze_time("2023-01-12"): # After Jan 6, before Jan 16 + identification._run_automatic_status_update() + + # Verify that the status was updated correctly by the automated system + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "pending") + + def test_no_renewal_calculation_when_lead_number_is_zero(self): + """Test that no renewal calculation happens when lead number is zero""" + # Create category with renewal lead number as 0 (disabled) + category_no_renewal = self.env["res.partner.id_category"].create( + { + "name": "Test Category No Renewal", + "code": "test_no_renewal", + "default_issuer_id": self.test_issuer.id, + "renewal_lead_number": 0, # Zero disables renewal automation + "renewal_lead_unit": "days", # Unit doesn't matter when number is 0 + } + ) + + # Create identification that should normally trigger renewal + identification = self.env["res.partner.id_number"].create( + { + "partner_id": self.test_partner.id, + "category_id": category_no_renewal.id, + "name": "TEST_NO_RENEWAL_CALC", + "valid_from": "2023-01-01", + "valid_until": "2023-01-05", # Expires in 5 days + } + ) + + # Ensure status is initially 'draft' or 'open' to test renewal logic + identification.write({"status": "open"}) + + # Run status update that would normally trigger renewal + # if renewal wasn't disabled + with freeze_time("2023-01-04"): # Close to expiry + identification._run_automatic_status_update() + + # Verify that the status remains unchanged because + # renewal is disabled (lead number is 0) + identification = identification.browse(identification.id) + self.assertEqual(identification.status, "open") # Should remain open + + def test_onchange_method_with_different_validity_units(self): + """Test onchange method works with all validity units""" + # First test with days + category_days = self.env["res.partner.id_category"].create( + { + "name": "Test Category Days", + "code": "test_cat_days", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 5, + "default_validity_unit": "days", + } + ) + + identification_days = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_days.id, + "name": "TEST_ONCHANGE_DAYS", + "valid_from": "2023-01-01", + } + ) + + identification_days._onchange_category_defaults() + expected_date = datetime(2023, 1, 6).date() # 5 days after Jan 1 + self.assertEqual(identification_days.valid_until, expected_date) + + # Test with weeks + category_weeks = self.env["res.partner.id_category"].create( + { + "name": "Test Category Weeks", + "code": "test_cat_weeks", + "default_issuer_id": self.test_issuer.id, + "default_validity_number": 2, + "default_validity_unit": "weeks", + } + ) + + identification_weeks = self.env["res.partner.id_number"].new( + { + "partner_id": self.test_partner.id, + "category_id": category_weeks.id, + "name": "TEST_ONCHANGE_WEEKS", + "valid_from": "2023-01-01", + } + ) + + identification_weeks._onchange_category_defaults() + expected_date = datetime(2023, 1, 15).date() # 2 weeks after Jan 1 + self.assertEqual(identification_weeks.valid_until, expected_date) diff --git a/partner_identification_automation/views/res_partner_id_category_view.xml b/partner_identification_automation/views/res_partner_id_category_view.xml new file mode 100644 index 00000000000..bb2f14d604a --- /dev/null +++ b/partner_identification_automation/views/res_partner_id_category_view.xml @@ -0,0 +1,36 @@ + + + + + res.partner.id_category.form.inherit.automation + res.partner.id_category + + + + + + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000000..c67f20fd262 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-partner_identification @ git+https://github.com/OCA/partner-contact.git@refs/pull/2218/head#subdirectory=partner_identification