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:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.18.15.11',
|
'version': '19.0.18.15.12',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from . import fp_parent_numbered_mixin
|
||||||
from . import fp_process_category
|
from . import fp_process_category
|
||||||
from . import fp_process_type
|
from . import fp_process_type
|
||||||
from . import fp_facility
|
from . import fp_facility
|
||||||
|
|||||||
102
fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
Normal file
102
fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
Normal 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
|
||||||
Reference in New Issue
Block a user