Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py
gsinghpal 2645db40a2 fix(receiving): propagate qty_received to fp.job + drop duplicate carrier field
Bug surfaced on WO-30043 (2026-05-20): operator walked every step
including a fully closed receiving record, then hit
"Quantity Received is blank — close the receiving record for
SO SO-30043 before completing this job." Receiving WAS closed.

Root cause: the 2026-05-18 cert-creation gate
(fp.job.button_mark_done) blocks on job.qty_received but nothing
populated it. fp.receiving carried the qty on its line records,
fp.job stayed at 0 indefinitely. Two disconnected records on the
same SO.

Fix: when fp.receiving._update_so_receiving_status runs (i.e. on
every state transition — counted / staged / closed / accepted /
resolved), also mirror each line's received_qty onto the matching
fp.job by (sale_order_id + part_catalog_id). Single-part SOs map
1-to-1; multi-part SOs spawn one job per line so the same join
still works.

Two defensive guards in the hook:
- Skip silently when fusion_plating_jobs not installed
  (Job = env.get('fp.job') returns None).
- Skip silently when fp.job doesn't yet carry part_catalog_id /
  qty_received (test scope, unusual install topology).

Drive-by during cleanup:
- fp_parent_numbered_mixin._fp_assign_parent_name: guard
  so.x_fc_parent_number access with field-existence check. The
  column lives in fusion_plating_jobs; downstream modules that
  inherit the mixin (receiving) but don't depend on jobs were
  hitting AttributeError on every fp.receiving.create at test
  time. Falls through to the legacy sequence when the column
  isn't there.

- fp_receiving_views.xml: legacy carrier_name Char field rendered
  as a second carrier row labeled "Legacy Carrier" alongside the
  proper x_fc_carrier_id M2O — operators saw two carrier fields
  and got confused. Hide the legacy display (data stays in DB for
  audit; migration 19.0.3.10.0 already matched it to a real
  delivery.carrier).

Migration 19.0.3.19.0/post-migrate.py backfills qty_received from
closed receiving lines for any job stuck at 0 — fixes WO-30043
and two sibling jobs on entech.

Modules: fusion_plating 19.0.20.2.0, fusion_plating_receiving
19.0.3.19.0, fusion_plating_jobs 19.0.10.15.0.

All 19 tests green (TestCarrierFields 6, TestQtyReceivedPropagation 5
new, TestReceivingGate 8). Direct verification on entech: WO-30043
qty_received = 1, mark_done succeeds, delivery + cert auto-created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:15:46 -04:00

187 lines
8.5 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.
"""
import re
from markupsafe import Markup
from odoo import fields, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
# Whitelist regex for counter-field names. The mixin interpolates the
# returned name into raw SQL, so a future subclass that read this from
# a context value or Selection field would otherwise open a SQL-injection
# surface. Enforce: must look like one of our x_fc_pn_*_count counters
# (lowercase letters / underscores only).
_FP_COUNTER_FIELD_RE = re.compile(r'^x_fc_pn_[a-z_]+_count$')
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()
# Defensive: the parent-number column lives in fusion_plating_jobs;
# downstream modules (e.g. fusion_plating_receiving) inherit the
# mixin but don't depend on jobs, so so.x_fc_parent_number can
# raise AttributeError at test time. hasattr keeps the mixin safe
# in either install topology — falls through to the legacy
# sequence when the column isn't there.
if not so or 'x_fc_parent_number' not in so._fields:
return False
if not so.x_fc_parent_number:
return False
counter_field = self._fp_parent_counter_field()
# Whitelist check — the field name is interpolated directly into
# SQL below, so we never trust an arbitrary string. All current
# subclasses return a literal; this guard exists so a future
# subclass that reads the field name from context / Selection /
# user input can't smuggle a SQL fragment in.
if not _FP_COUNTER_FIELD_RE.match(counter_field or ''):
raise UserError(_(
'Invalid parent-counter field name %r — must match '
'pattern x_fc_pn_*_count.'
) % 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=Markup(_(
'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()