write() override raises UserError if name or x_fc_doc_index is in vals and differs from the stored value (bypass: context flag fp_allow_name_rename=True for the SO-confirm rename + bulk WO creation paths). unlink() override raises UserError for records that have been issued a name; applies to all users including admins — cancellation must go through the state machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
7.0 KiB
Python
159 lines
7.0 KiB
Python
# -*- 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
|
|
|
|
# ------------------------------------------------------------------
|
|
# Immutability: name + x_fc_doc_index can't change post-issuance.
|
|
# Bypass: context flag fp_allow_name_rename=True. Used ONLY by:
|
|
# 1. sale.order.action_confirm (Q -> SO rename, one-time)
|
|
# 2. Bulk WO creation mid-create (sets names explicitly)
|
|
# 3. Legacy-sequence fallback path in child create() overrides
|
|
# Compliance: once issued, an audit-trail number can never change.
|
|
# ------------------------------------------------------------------
|
|
FP_IMMUTABLE_FIELDS = ('name', 'x_fc_doc_index')
|
|
|
|
def write(self, vals):
|
|
if not self.env.context.get('fp_allow_name_rename'):
|
|
for f in self.FP_IMMUTABLE_FIELDS:
|
|
if f in vals:
|
|
for rec in self:
|
|
current = rec[f]
|
|
if current and current != vals[f]:
|
|
raise UserError(_(
|
|
'Field "%(field)s" on %(model)s "%(name)s" '
|
|
'is immutable. Once issued, it cannot be '
|
|
'changed - this preserves the compliance '
|
|
'audit trail. (Attempted: %(old)r -> %(new)r)'
|
|
) % {
|
|
'field': f, 'model': self._description,
|
|
'name': rec.display_name,
|
|
'old': current, 'new': vals[f],
|
|
})
|
|
return super().write(vals)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Unlink block: issued documents can't be hard-deleted.
|
|
# Cancellation must go through the state machine so the audit trail
|
|
# keeps the issued number tied to its cancellation reason. Hard
|
|
# delete would leave a phantom gap in the counter. Applies to ALL
|
|
# users including admins — no group bypass.
|
|
# ------------------------------------------------------------------
|
|
def unlink(self):
|
|
for rec in self:
|
|
# Records still in their initial 'New' state (no number
|
|
# ever issued) are fine to delete — they're not yet in
|
|
# the audit trail. Once x_fc_doc_index is non-zero OR
|
|
# name is something other than 'New' / '/', the record
|
|
# has been issued and is permanent.
|
|
issued = rec.x_fc_doc_index or (
|
|
rec.name and rec.name not in (False, '', 'New', '/')
|
|
)
|
|
if issued:
|
|
raise UserError(_(
|
|
'Document "%(name)s" cannot be deleted - it is '
|
|
'part of the compliance audit trail. Cancel it '
|
|
'instead (use the state machine\'s Cancel action). '
|
|
'This rule applies to all users including '
|
|
'administrators.'
|
|
) % {'name': rec.display_name})
|
|
return super().unlink()
|