From 78d633f63fd048022dcd82b7a2b24dae79c0d0de Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 12 May 2026 13:39:03 -0400 Subject: [PATCH] feat(numbering): immutable name/doc_index + unlink block on issued docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../models/fp_parent_numbered_mixin.py | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 99fb7be1..68143908 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.18.15.13', + 'version': '19.0.18.15.14', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py index 0b666615..ab6d859f 100644 --- a/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py +++ b/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py @@ -100,3 +100,59 @@ class FpParentNumberedMixin(models.AbstractModel): 'Issued %s 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()