diff --git a/base_sequence_template/README.rst b/base_sequence_template/README.rst new file mode 100644 index 00000000000..fc52688e4af --- /dev/null +++ b/base_sequence_template/README.rst @@ -0,0 +1,114 @@ +====================== +Base Sequence Template +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:569cc0e4c7a9c546f6269203c966cd7cc1348847adfd34fd3b0063ce7bd070b8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/base_sequence_template + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-base_sequence_template + :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/server-tools&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a wizard to generate company-specific sequences +from reusable sequence templates. It is designed for multi-company +environments where each company requires its own ``ir.sequence`` records +but shares a common configuration pattern. The wizard allows defining +sequence templates that include dynamic placeholders referencing company +fields. + +During generation, placeholders of the form ``%(company_id.)s`` +are automatically replaced with the corresponding values from each +company. Other placeholders (for example ``%(year)s``) are preserved and +evaluated later by the standard sequence engine. The module also +validates that all referenced company fields exist and checks that +required company values are not empty. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module, follow these steps: + +1. Create one or more sequence templates and define the desired prefix + and suffix, with company placeholders if desired. You can use + placeholders like: + + - ``%(company_id.id)s`` + - ``%(company_id.name)s`` + - ``%(company_id.vat)s`` + +2. Select the templates you want to use. +3. Trigger the server action that opens the wizard to generate sequences + for companies. +4. In the wizard, select the target companies. +5. Confirm to generate the sequences. + +The wizard will: + +- Read the required company fields used in the templates. +- Validate that those fields exist and are not empty. +- Create one ``ir.sequence`` per selected company and template with the + resolved values. + +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 +------- + +* ForgeFlow + +Contributors +------------ + +- Laura Cazorla + +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/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_sequence_template/__init__.py b/base_sequence_template/__init__.py new file mode 100644 index 00000000000..027e166e6bd --- /dev/null +++ b/base_sequence_template/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import wizard diff --git a/base_sequence_template/__manifest__.py b/base_sequence_template/__manifest__.py new file mode 100644 index 00000000000..10c30008010 --- /dev/null +++ b/base_sequence_template/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Base Sequence Template", + "summary": "Create a sequence template that can generate sequences for companies", + "category": "Tools", + "version": "18.0.1.0.0", + "website": "https://github.com/OCA/server-tools", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["base_setup"], + "data": [ + "views/ir_sequence_template_views.xml", + "wizard/generate_company_sequences.xml", + "security/ir.model.access.csv", + ], + "installable": True, + "auto_install": False, +} diff --git a/base_sequence_template/models/__init__.py b/base_sequence_template/models/__init__.py new file mode 100644 index 00000000000..c36d5d408f4 --- /dev/null +++ b/base_sequence_template/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import ir_sequence +from . import ir_sequence_template diff --git a/base_sequence_template/models/ir_sequence.py b/base_sequence_template/models/ir_sequence.py new file mode 100644 index 00000000000..28f96049d60 --- /dev/null +++ b/base_sequence_template/models/ir_sequence.py @@ -0,0 +1,10 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class IrSequence(models.Model): + _inherit = "ir.sequence" + + template_id = fields.Many2one("ir.sequence.template", ondelete="set null") diff --git a/base_sequence_template/models/ir_sequence_template.py b/base_sequence_template/models/ir_sequence_template.py new file mode 100644 index 00000000000..6f784716c7f --- /dev/null +++ b/base_sequence_template/models/ir_sequence_template.py @@ -0,0 +1,62 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class IrSequenceTemplate(models.Model): + _name = "ir.sequence.template" + _description = "Sequence Template" + _order = "name" + _allow_sudo_commands = False + + active = fields.Boolean(default=True) + + name = fields.Char(required=True) + code = fields.Char(string="Sequence Code") + implementation = fields.Selection( + [("standard", "Standard"), ("no_gap", "No gap")], + required=True, + default="standard", + ) + prefix = fields.Char(help="Prefix value of the record for the sequence", trim=False) + suffix = fields.Char(help="Suffix value of the record for the sequence", trim=False) + number_increment = fields.Integer(string="Step", required=True, default=1) + padding = fields.Integer(string="Sequence Size", required=True, default=0) + + sequence_ids = fields.One2many("ir.sequence", "template_id") + sequence_count = fields.Integer(compute="_compute_sequence_count") + + @api.depends("sequence_ids") + def _compute_sequence_count(self): + for record in self: + record.sequence_count = len(record.sequence_ids) + + def action_view_sequences(self): + self.ensure_one() + return { + "name": "Sequences", + "type": "ir.actions.act_window", + "res_model": "ir.sequence", + "view_mode": "list,form", + "domain": [("template_id", "=", self.id)], + "context": {"default_template_id": self.id}, + } + + def action_generate_sequences(self): + return self.open_generate_sequences_wizard() + + def open_generate_sequences_wizard(self): + return { + "name": "Generate Sequences", + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "generate.company.sequences.wizard", + "target": "new", + "view_id": self.env.ref( + "base_sequence_template.view_generate_company_sequences_wizard_form" + ).id, + "context": { + "default_template_ids": self.ids, + }, + } diff --git a/base_sequence_template/pyproject.toml b/base_sequence_template/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/base_sequence_template/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_sequence_template/readme/CONTRIBUTORS.md b/base_sequence_template/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..f826ce12996 --- /dev/null +++ b/base_sequence_template/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Laura Cazorla \<\> diff --git a/base_sequence_template/readme/DESCRIPTION.md b/base_sequence_template/readme/DESCRIPTION.md new file mode 100644 index 00000000000..fb9065abb35 --- /dev/null +++ b/base_sequence_template/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +This module provides a wizard to generate company-specific sequences from +reusable sequence templates. It is designed for multi-company environments +where each company requires its own `ir.sequence` records but shares a common +configuration pattern. The wizard allows defining sequence templates that +include dynamic placeholders referencing company fields. + +During generation, placeholders of the form `%(company_id.)s` are +automatically replaced with the corresponding values from each company. Other +placeholders (for example `%(year)s`) are preserved and evaluated later by the +standard sequence engine. The module also validates that all referenced company +fields exist and checks that required company values are not empty. + diff --git a/base_sequence_template/readme/USAGE.md b/base_sequence_template/readme/USAGE.md new file mode 100644 index 00000000000..bc88cc0313e --- /dev/null +++ b/base_sequence_template/readme/USAGE.md @@ -0,0 +1,19 @@ +To use this module, follow these steps: + +1. Create one or more sequence templates and define the desired prefix and + suffix, with company placeholders if desired. You can use placeholders like: + * `%(company_id.id)s` + * `%(company_id.name)s` + * `%(company_id.vat)s` +2. Select the templates you want to use. +3. Trigger the server action that opens the wizard to generate sequences for + companies. +4. In the wizard, select the target companies. +5. Confirm to generate the sequences. + +The wizard will: +- Read the required company fields used in the templates. +- Validate that those fields exist and are not empty. +- Create one `ir.sequence` per selected company and template with the resolved + values. + diff --git a/base_sequence_template/security/ir.model.access.csv b/base_sequence_template/security/ir.model.access.csv new file mode 100644 index 00000000000..5ad285ec9f0 --- /dev/null +++ b/base_sequence_template/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ir_sequence_template,access_ir_sequence_template,model_ir_sequence_template,base.group_user,1,0,0,0 +access_ir_sequence_template_system,access_ir_sequence_template_system,model_ir_sequence_template,base.group_system,1,1,1,1 +access_generate_company_sequences_wizard,access_generate_company_sequences_wizard,model_generate_company_sequences_wizard,base.group_system,1,1,1,1 diff --git a/base_sequence_template/static/description/index.html b/base_sequence_template/static/description/index.html new file mode 100644 index 00000000000..520b5c7f986 --- /dev/null +++ b/base_sequence_template/static/description/index.html @@ -0,0 +1,461 @@ + + + + + +Base Sequence Template + + + +
+

Base Sequence Template

+ + +

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

+

This module provides a wizard to generate company-specific sequences +from reusable sequence templates. It is designed for multi-company +environments where each company requires its own ir.sequence records +but shares a common configuration pattern. The wizard allows defining +sequence templates that include dynamic placeholders referencing company +fields.

+

During generation, placeholders of the form %(company_id.<field>)s +are automatically replaced with the corresponding values from each +company. Other placeholders (for example %(year)s) are preserved and +evaluated later by the standard sequence engine. The module also +validates that all referenced company fields exist and checks that +required company values are not empty.

+

Table of contents

+ +
+

Usage

+

To use this module, follow these steps:

+
    +
  1. Create one or more sequence templates and define the desired prefix +and suffix, with company placeholders if desired. You can use +placeholders like:
      +
    • %(company_id.id)s
    • +
    • %(company_id.name)s
    • +
    • %(company_id.vat)s
    • +
    +
  2. +
  3. Select the templates you want to use.
  4. +
  5. Trigger the server action that opens the wizard to generate sequences +for companies.
  6. +
  7. In the wizard, select the target companies.
  8. +
  9. Confirm to generate the sequences.
  10. +
+

The wizard will:

+
    +
  • Read the required company fields used in the templates.
  • +
  • Validate that those fields exist and are not empty.
  • +
  • Create one ir.sequence per selected company and template with the +resolved values.
  • +
+
+
+

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

+
    +
  • ForgeFlow
  • +
+
+
+

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/server-tools project on GitHub.

+

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

+
+
+
+ + diff --git a/base_sequence_template/tests/__init__.py b/base_sequence_template/tests/__init__.py new file mode 100644 index 00000000000..c8fdb0badae --- /dev/null +++ b/base_sequence_template/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_base_sequence_template diff --git a/base_sequence_template/tests/test_base_sequence_template.py b/base_sequence_template/tests/test_base_sequence_template.py new file mode 100644 index 00000000000..0998d4f685b --- /dev/null +++ b/base_sequence_template/tests/test_base_sequence_template.py @@ -0,0 +1,117 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestBaseSequenceTemplate(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.model_company = cls.env["res.company"] + cls.model_sequence_template = cls.env["ir.sequence.template"] + cls.model_sequence = cls.env["ir.sequence"] + cls.model_wizard = cls.env["generate.company.sequences.wizard"] + + cls.company_a = cls.model_company.create( + { + "name": "Company A", + "phone": "12345", + } + ) + cls.company_b = cls.model_company.create( + { + "name": "Company B", + "phone": "67890", + } + ) + cls.template = cls.model_sequence_template.create( + { + "name": "Test Template", + "code": "test.sequence", + "prefix": "/%(company_id.phone)s/", + "suffix": "/%(year)s", + "padding": 5, + "number_increment": 1, + "implementation": "no_gap", + } + ) + + def _create_wizard(self, companies, templates): + return self.model_wizard.create( + { + "company_ids": [(6, 0, companies.ids)], + "template_ids": [(6, 0, templates.ids)], + } + ) + + def test_extract_placeholders(self): + # Extract only company_id placeholders + wizard = self._create_wizard(self.company_a, self.template) + placeholders = wizard._extract_placeholders("/%(company_id.phone)s/%(year)s") + self.assertEqual(placeholders, {"company_id.phone"}) + + def test_replace_company_placeholders(self): + # Replace only company_id placeholders + wizard = self._create_wizard(self.company_a, self.template) + template = "/%(company_id.phone)s/%(year)s" + values = {"company_id.phone": "12345"} + result = wizard.replace_company_placeholders(template, values) + self.assertEqual(result, "/12345/%(year)s") + + def test_action_generate_success(self): + # Generate different companies' sequences, with the phone number + wizard = self._create_wizard(self.company_a | self.company_b, self.template) + wizard.action_generate() + sequences = self.model_sequence.search( + [ + ("code", "=", "test.sequence"), + ("company_id", "in", (self.company_a | self.company_b).ids), + ] + ) + self.assertEqual(len(sequences), 2) + seq_a = sequences.filtered(lambda s: s.company_id == self.company_a) + seq_b = sequences.filtered(lambda s: s.company_id == self.company_b) + # Phone number is correctly set in the prefix + self.assertEqual(seq_a.prefix, "/12345/") + self.assertEqual(seq_b.prefix, "/67890/") + self.assertEqual(seq_a.suffix, "/%(year)s") + self.assertEqual(seq_b.suffix, "/%(year)s") + + def test_missing_company_field_value(self): + # Check that sequences are not generated if we use a company field + # which is empty in any company + template = self.model_sequence_template.create( + { + "name": "Missing Phone Template", + "code": "missing.phone.seq", + "prefix": "/%(company_id.phone)s/", + "suffix": "", + "padding": 3, + "number_increment": 1, + "implementation": "no_gap", + } + ) + company = self.model_company.create({"name": "No Phone Company"}) + wizard = self._create_wizard(company, template) + with self.assertRaises(ValidationError): + wizard.action_generate() + + def test_invalid_company_field_placeholder(self): + # Check the sequences are not generated if we try to use an invalid + # company field + template = self.model_sequence_template.create( + { + "name": "Invalid Field Template", + "code": "invalid.field.seq", + "prefix": "/%(company_id.nonexistent_field)s/", + "suffix": "", + "padding": 3, + "number_increment": 1, + "implementation": "no_gap", + } + ) + wizard = self._create_wizard(self.company_a, template) + with self.assertRaises(ValidationError): + wizard.action_generate() diff --git a/base_sequence_template/views/ir_sequence_template_views.xml b/base_sequence_template/views/ir_sequence_template_views.xml new file mode 100644 index 00000000000..511f96d75eb --- /dev/null +++ b/base_sequence_template/views/ir_sequence_template_views.xml @@ -0,0 +1,162 @@ + + + + + view.ir.sequence.template.list + ir.sequence.template + + +
+
+ + + + + + +
+
+
+ + view.ir.sequence.template.form + ir.sequence.template + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + Current Year with Century: %%(year)s + Current Year without Century: %%(y)s + Month: %%(month)s + Day: %%(day)s + + + Day of the Year: %%(doy)s + Week of the Year: %%(woy)s + Day of the Week (0:Monday): %%(weekday)s + + + Hour 00->24: %%(h24)s + Hour 00->12: %%(h12)s + Minute: %%(min)s + Second: %%(sec)s + + + + + + Company Fields + + You can use any company flat field in your prefix or suffix + + + Company ID: %%(company_id.id)s + Company Name: %%(company_id.name)s + Company Registry: %%(company_id.company_registry)s + + + + +
+
+
+
+ + + Sequence Templates + ir.sequence.template + list,form + + + + Generate Sequences + + + list,form + code + + if records: + action = records.open_generate_sequences_wizard() + + + + +
diff --git a/base_sequence_template/wizard/__init__.py b/base_sequence_template/wizard/__init__.py new file mode 100644 index 00000000000..6aefeac9a9a --- /dev/null +++ b/base_sequence_template/wizard/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import generate_company_sequences diff --git a/base_sequence_template/wizard/generate_company_sequences.py b/base_sequence_template/wizard/generate_company_sequences.py new file mode 100644 index 00000000000..651a83e63fb --- /dev/null +++ b/base_sequence_template/wizard/generate_company_sequences.py @@ -0,0 +1,102 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class GenerateCompanySequencesWizard(models.TransientModel): + _name = "generate.company.sequences.wizard" + _description = "Generate Sequences for Company" + + company_ids = fields.Many2many( + "res.company", + required=True, + string="Companies", + domain="[('id', 'in', allowed_company_ids)]", + ) + template_ids = fields.Many2many( + "ir.sequence.template", required=True, string="Sequence Templates" + ) + + @api.model + def _extract_placeholders(self, template_str): + if not template_str: + return set() + all_placeholders = re.findall(r"%\(([\w\.]+)\)s", template_str) + return {p for p in all_placeholders if p.startswith("company_id.")} + + @api.model + def replace_company_placeholders(self, template, company_values): + if not template: + return "" + + def repl(match): + key = match.group(1) + if key.startswith("company_id."): + return str(company_values.get(key, match.group(0))) + return match.group(0) + + return re.sub(r"%\(([\w\.]+)\)s", repl, template) + + @api.model + def _read_company_data(self, fields_to_read): + field_names = {p.split(".", 1)[1] for p in fields_to_read} + company_data = {} + try: + for company in self.company_ids: + data = company.read(list(field_names))[0] + company_data[company.id] = { + f"company_id.{k}": v for k, v in data.items() + } + except ValueError as e: + raise ValidationError( + f"One or more fields used in the templates does not exist for " + f"the model. Check that all placeholders match actual company " + f"fields.\nDetails: {str(e)}." + ) from e + return company_data + + @api.model + def _prepare_sequence_vals(self, tmpl, company, prefix, suffix): + return { + "name": f"{company.name} {tmpl.name}", + "code": tmpl.code, + "prefix": prefix, + "suffix": suffix, + "padding": tmpl.padding, + "company_id": company.id, + "number_increment": tmpl.number_increment, + "implementation": tmpl.implementation, + "template_id": tmpl.id, + } + + def action_generate(self): + fields_to_read = set() + for tmpl in self.template_ids: + fields_to_read |= self._extract_placeholders(tmpl.prefix) + fields_to_read |= self._extract_placeholders(tmpl.suffix) + company_data = self._read_company_data(fields_to_read) + for company in self.company_ids: + values = company_data[company.id] + missing = [f for f in fields_to_read if not values.get(f)] + if missing: + raise ValidationError( + self.env._( + f"Company '{company.name}' has empty or invalid values " + f"for the following fields used in templates: " + f"{', '.join(missing)}. Please fill them before " + f"generating sequences." + ) + ) + for tmpl in self.template_ids: + prefix = self.replace_company_placeholders(tmpl.prefix, values) + suffix = self.replace_company_placeholders(tmpl.suffix, values) + seq_vals = self._prepare_sequence_vals(tmpl, company, prefix, suffix) + self.env["ir.sequence"].create(seq_vals) + return { + "type": "ir.actions.client", + "tag": "reload", + } diff --git a/base_sequence_template/wizard/generate_company_sequences.xml b/base_sequence_template/wizard/generate_company_sequences.xml new file mode 100644 index 00000000000..c1773e65c61 --- /dev/null +++ b/base_sequence_template/wizard/generate_company_sequences.xml @@ -0,0 +1,48 @@ + + + + + generate.company.sequences.wizard.form + generate.company.sequences.wizard + +
+ + + + + +
+
+
+
+
+ + Generate Company Sequences + generate.company.sequences.wizard + form + + new + +