From 105909470fcededd47afab381dc0b9f2bddf6d0c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 12 May 2026 13:09:17 -0400 Subject: [PATCH] feat(numbering): add fp.parent.numbered.mixin abstract model Atomic counter via SELECT FOR UPDATE on the parent SO row. Composes child names as PREFIX-PARENT (bare for first) or PREFIX-PARENT-NN (zero-padded 2-digit, then unpadded past 99). Subclasses implement three hooks: _fp_parent_sale_order, _fp_name_prefix, _fp_parent_counter_field. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../fusion_plating/models/__init__.py | 1 + .../models/fp_parent_numbered_mixin.py | 102 ++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index f72a5f61..7d76609b 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.18.15.11', + 'version': '19.0.18.15.12', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 1ba46997..2f6476c0 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -3,6 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +from . import fp_parent_numbered_mixin from . import fp_process_category from . import fp_process_type from . import fp_facility diff --git a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py new file mode 100644 index 00000000..0b666615 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Abstract mixin: derive a record's name from its parent sale order. + +Every model that 1:1 links to an SO inherits this mixin. The mixin +owns the atomic counter logic so race conditions and counter drift +are impossible. Subclasses implement three small hooks and call +``self._fp_assign_parent_name()`` from their ``create()`` override. + +See docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md +for the design rationale. +""" +from odoo import fields, models +from odoo.exceptions import UserError +from odoo.tools.translate import _ + + +class FpParentNumberedMixin(models.AbstractModel): + _name = 'fp.parent.numbered.mixin' + _description = 'Fusion Plating - Parent-Number-Derived Naming' + + x_fc_doc_index = fields.Integer( + string='Parent Doc Index', + readonly=True, + copy=False, + index=True, + help='1-based position within this parent SO. 1 = the first ' + 'child of this type for the SO; subsequent siblings get 2, ' + '3, etc. The first sibling renders its name bare; later ' + 'siblings get a zero-padded "-NN" suffix.', + ) + + # ------------------------------------------------------------------ + # Hooks subclasses must override + # ------------------------------------------------------------------ + def _fp_parent_sale_order(self): + """Return the linked sale.order recordset (or empty).""" + return self.env['sale.order'] + + def _fp_name_prefix(self): + """Return the model's prefix (e.g. 'WO', 'IN', 'CoC').""" + raise NotImplementedError( + 'Subclass must define _fp_name_prefix()' + ) + + def _fp_parent_counter_field(self): + """Return the counter field on sale.order for THIS model.""" + raise NotImplementedError( + 'Subclass must define _fp_parent_counter_field()' + ) + + # ------------------------------------------------------------------ + # Core: atomic counter + name composition + # ------------------------------------------------------------------ + def _fp_compose_name(self, parent_number, index): + """Pure helper: compose the name string per the design's rules.""" + prefix = self._fp_name_prefix() + if index <= 1: + return f'{prefix}-{parent_number}' + if index <= 99: + return f'{prefix}-{parent_number}-{index:02d}' + return f'{prefix}-{parent_number}-{index}' + + def _fp_assign_parent_name(self): + """Lock the parent SO, bump the counter, set name + doc index. + + Returns True if assignment succeeded; False if no parent SO is + linked (caller falls back to the model's own legacy sequence). + """ + self.ensure_one() + so = self._fp_parent_sale_order() + if not so or not so.x_fc_parent_number: + return False + counter_field = self._fp_parent_counter_field() + # SELECT FOR UPDATE - locks the SO row until commit, so a + # concurrent create on the same SO blocks here and reads the + # updated counter after we release. No race, no drift. + self.env.cr.execute( + f'SELECT {counter_field} FROM sale_order WHERE id = %s FOR UPDATE', + (so.id,), + ) + row = self.env.cr.fetchone() + current = (row and row[0]) or 0 + new_index = current + 1 + self.env.cr.execute( + f'UPDATE sale_order SET {counter_field} = %s WHERE id = %s', + (new_index, so.id), + ) + so.invalidate_recordset([counter_field]) + new_name = self._fp_compose_name(so.x_fc_parent_number, new_index) + # Raw SQL update bypasses the immutability write() guard added + # in Task 11 (since this IS the legitimate assignment path). + self.env.cr.execute( + f'UPDATE {self._table} SET name = %s, x_fc_doc_index = %s WHERE id = %s', + (new_name, new_index, self.id), + ) + self.invalidate_recordset(['name', 'x_fc_doc_index']) + so.message_post(body=_( + 'Issued %s to %s #%s.' + ) % (new_name, self._name, self.id)) + return True