feat(numbering): immutable name/doc_index + unlink block on issued docs

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>
This commit is contained in:
gsinghpal
2026-05-12 13:39:03 -04:00
parent 95cb73d91a
commit 78d633f63f
2 changed files with 57 additions and 1 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.18.15.13', 'version': '19.0.18.15.14',
'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': """

View File

@@ -100,3 +100,59 @@ class FpParentNumberedMixin(models.AbstractModel):
'Issued <strong>%s</strong> to %s #%s.' 'Issued <strong>%s</strong> to %s #%s.'
) % (new_name, self._name, self.id)) ) % (new_name, self._name, self.id))
return True 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()