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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-12 13:09:17 -04:00
parent 6e67fc5ce3
commit 105909470f
3 changed files with 104 additions and 1 deletions

View File

@@ -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': """

View File

@@ -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

View File

@@ -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 <strong>%s</strong> to %s #%s.'
) % (new_name, self._name, self.id))
return True