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