feat(plating): session 2026-05-23 deploys — F1/F7/S22/S23 + UI fixes

Consolidated commit of session work already deployed to entech and
verified via the deep audit + the persona walk:

S22 — Signoff gate (fp.job.step.requires_signoff was 100% unenforced,
42/42 done steps had NULL signoff_user_id). Three-piece fix:
_fp_autosign_if_required (captures finisher on button_finish),
_fp_check_signoff_complete (raises UserError if NULL after autosign),
action_signoff (explicit supervisor pre-sign). Bypass:
fp_skip_signoff_gate=True.

S23 — Transition-form gate (same dormant-field shape as S22, caught
preventively before recipe authors flipped requires_transition_form
on). Model helpers on fp.job.step.move + controller gate in
move_controller (parts commit) + pre-reject in rack commit.

F7 — Chatter standardization: _fp_create_qc_check_if_needed,
_fp_fire_notification, _fp_create_delivery silent failures now also
post to job chatter instead of only logging to file.

UI fixes:
- Critical Rule 20 documented + applied: OWL templates only expose
  Math as a global. Calling String(d) inside t-on-click throws
  'v2 is not a function'. Fixed pin_pad.xml (string array instead of
  number array with String() coercion). Also swept parseInt/
  parseFloat in recipe_tree_editor + simple_recipe_editor.
- Notes panel HTML escape fix: chatter messages off /fp/workspace/load
  were rendered via t-out, escaping the HTML. Wrap with markup() in
  job_workspace.js refresh() before assigning to state.

Versions:
  fusion_plating         19.0.20.8.0 → 19.0.20.9.0
  fusion_plating_jobs    19.0.10.20.0 → 19.0.10.23.0
  fusion_plating_shopfloor 19.0.30.2.0 → 19.0.30.5.0

All deployed to entech (LXC 111) and verified live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-23 20:37:17 -04:00
parent d6ebcb6233
commit 1a3ca8704e
15 changed files with 597 additions and 142 deletions

View File

@@ -1485,25 +1485,26 @@ class FpJob(models.Model):
def _fp_create_portal_job(self):
"""Create the fusion.plating.portal.job mirror record.
Initial state derived from the fp.job state via the same map
used by write() — so a job that's already 'in_progress' when
the portal mirror is created (e.g. a manual catch-up create)
doesn't reset to 'received'.
Seeded with 'received' then handed to
`fusion.plating.portal.job._fp_recompute_portal_state` — that
helper is the single source of truth for portal state and
derives it from the WO + shipment + invoice signals, so a
catch-up create on an already-in-progress job lands on the
right state rather than stuck on 'received'.
"""
self.ensure_one()
if self.portal_job_id:
return # already exists — idempotent
Portal = self.env['fusion.plating.portal.job'].sudo()
initial_state = self._FP_JOB_STATE_TO_PORTAL_STATE.get(
self.state, 'received',
)
portal = Portal.create({
'name': self.name,
'partner_id': self.partner_id.id,
'state': initial_state,
'state': 'received',
'x_fc_job_id': self.id,
})
self.portal_job_id = portal.id
if hasattr(portal, '_fp_recompute_portal_state'):
portal._fp_recompute_portal_state()
def _fp_create_qc_check_if_needed(self):
"""If customer has x_fc_requires_qc=True, spawn a QC check via
@@ -1528,9 +1529,17 @@ class FpJob(models.Model):
try:
QC.create_for_job(self)
except Exception as e:
# F7 — surface silent failures on the job's chatter so the
# operator sees the gap and creates the QC manually. Logging
# to /var/log/odoo/odoo-server.log alone meant nobody noticed
# (2CM's WH/JOB/00002 silently lost its QC check this way).
_logger.warning(
"Job %s: create_for_job failed: %s", self.name, e,
)
self.message_post(body=_(
'QC check auto-create failed: %(e)s. '
'Create the QC check manually from the Quality menu.'
) % {'e': e})
# ------------------------------------------------------------------
# button_mark_done — Task 2.8
@@ -1745,10 +1754,18 @@ class FpJob(models.Model):
# partner_id is the customer.
Template._dispatch(event, self, partner=self.partner_id)
except Exception as e:
# F7 — surface on chatter. A missed customer notification
# (e.g. "your parts have shipped") is invisible to the
# operator until the customer complains; the chatter post
# gives accounting / sales a recoverable signal.
_logger.warning(
"Job %s: notification %s dispatch failed: %s",
self.name, event, e,
)
self.message_post(body=_(
'Notification dispatch failed for event "%(ev)s": %(e)s. '
'Send manually if the customer expected an update.'
) % {'ev': event, 'e': e})
def _fp_create_delivery(self):
"""Create a draft fusion.plating.delivery linked to this job.
@@ -1787,9 +1804,16 @@ class FpJob(models.Model):
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
except Exception as e:
# F7 — surface on chatter. Without this, the operator sees
# "Job marked done" but no delivery record exists, and the
# next milestone advance fails silently.
_logger.warning(
"Job %s: failed to auto-create delivery: %s", self.name, e,
)
self.message_post(body=_(
'Delivery auto-create failed: %(e)s. '
'Create the delivery manually from the Logistics menu.'
) % {'e': e})
def _fp_resolve_delivery_defaults(self, Delivery):
"""Build the create-vals for a fresh delivery, OR the

View File

@@ -542,29 +542,179 @@ class FpJobStep(models.Model):
return candidates[:1] or self.env['fp.job.step']
def _fp_has_uncaptured_step_inputs(self):
"""True when the recipe step defines step_input prompts AND
the user hasn't already saved values for this step's current
run via the Record Inputs wizard.
"""True when the recipe step has REQUIRED step_input prompts
whose values haven't been recorded yet.
Previously this checked "any move with input values exists since
date_started" — too coarse. Operator clicked Save on the dialog
after filling ONE prompt and the helper went quiet, letting
action_finish_and_advance bypass the dialog re-open even when
4 of 5 required prompts were still empty (WO-30051 / Riya 2026-05-23).
Now we count actual coverage per required input across every
move recorded against this step.
"""
self.ensure_one()
return bool(self._fp_missing_required_step_inputs())
def _fp_missing_required_step_inputs(self):
"""Return the recordset of REQUIRED step_input prompts on this
step's recipe node that have NO value recorded across any move
from this step. Centralised helper — used by both
_fp_has_uncaptured_step_inputs (re-open dialog) and
_fp_check_step_inputs_complete (raise UserError on finish).
"""
self.ensure_one()
node = self.recipe_node_id
Prompt = self.env['fusion.plating.process.node.input']
if not node:
return False
return Prompt
prompts = node.input_ids
if 'kind' in prompts._fields:
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
if not prompts:
return False
# Has the operator already recorded values during this run?
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
# for this step since date_started.
Move = self.env['fp.job.step.move']
already = Move.search_count([
('from_step_id', '=', self.id),
('transfer_type', '=', 'step'),
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
])
return already == 0
if 'collect' in prompts._fields:
prompts = prompts.filtered(lambda i: i.collect)
required_prompts = prompts.filtered(lambda i: i.required)
if not required_prompts:
return Prompt
Value = self.env['fp.job.step.move.input.value']
recorded_input_ids = set(Value.search([
('move_id.from_step_id', '=', self.id),
('node_input_id', 'in', required_prompts.ids),
]).mapped('node_input_id.id'))
return required_prompts.filtered(
lambda p: p.id not in recorded_input_ids
)
def _fp_autosign_if_required(self):
"""Auto-set signoff_user_id to the current user when the step has
requires_signoff=True and no signoff has been recorded yet.
Called from button_finish just before the signoff gate. Captures
WHO finished the step as the signer-of-record. For shops that
need separate operator+supervisor sign-off, call action_signoff()
explicitly from a supervisor session BEFORE the operator clicks
Finish — that pre-sets signoff_user_id and this helper becomes a
no-op.
Idempotent — never overwrites an existing signoff_user_id, so a
manager pre-signing via action_signoff is preserved through the
operator's Finish click.
"""
for step in self:
if not step.requires_signoff:
continue
if step.signoff_user_id:
continue # pre-signed (likely by a supervisor)
# Use sudo because signoff_user_id is readonly=True at field
# level; we still capture env.user.id (not SUPERUSER_ID) so
# the audit trail shows who actually clicked.
step.sudo().write({'signoff_user_id': self.env.user.id})
def _fp_check_signoff_complete(self):
"""Raise UserError if the step has requires_signoff=True and
signoff_user_id IS NULL. Aerospace / Nadcap need a named signer
on every sign-off-required step; an unset signer breaks the
audit chain.
Normally _fp_autosign_if_required (called from button_finish
immediately before this gate) populates signoff_user_id with the
finisher's id, so this gate only fires when:
- The step is being finished via a code path that bypasses
autosign (e.g. a migration script writing state='done').
- The user has no env.user (background cron with no uid set).
Manager bypass via context fp_skip_signoff_gate=True for
documented customer deviations. Bypasses are posted to chatter
naming the user.
"""
if self.env.context.get('fp_skip_signoff_gate'):
for step in self:
if not step.requires_signoff:
continue
step.job_id.message_post(body=Markup(_(
'Sign-off gate bypassed on step "<b>%s</b>" by %s. '
'Documented deviation — no signer recorded.'
)) % (step.name, self.env.user.name))
return
for step in self:
if not step.requires_signoff:
continue
if step.signoff_user_id:
continue
raise UserError(_(
'Step "%(step)s" cannot be finished — sign-off required '
'but no signer recorded. Click "Sign Off" on the step '
'(or have your supervisor sign before you finish). '
'Managers can override via context flag '
'fp_skip_signoff_gate=True for documented deviations.'
) % {'step': step.name})
def action_signoff(self):
"""Explicit sign-off action — sets signoff_user_id = env.user.id
for the calling user. Use case: a supervisor reviews an operator's
work and signs off BEFORE the operator clicks Finish. Once signed,
the operator's Finish click passes the signoff gate without auto-
assigning a different signer.
Idempotent — re-clicking by the same user is a no-op. A DIFFERENT
user re-signing overwrites the prior signer (and chatters the change)
so a senior supervisor can override a junior's premature sign-off
without leaving the audit trail mute.
"""
for step in self:
if not step.requires_signoff:
raise UserError(_(
'Step "%s" does not require sign-off — nothing to sign.'
) % step.name)
prior = step.signoff_user_id
if prior and prior.id == self.env.user.id:
continue # idempotent
step.sudo().write({'signoff_user_id': self.env.user.id})
if prior:
step.job_id.message_post(body=Markup(_(
'Sign-off on step "<b>%s</b>" reassigned from %s to %s.'
)) % (step.name, prior.name, self.env.user.name))
else:
step.job_id.message_post(body=Markup(_(
'Step "<b>%s</b>" signed off by %s.'
)) % (step.name, self.env.user.name))
return True
def _fp_check_step_inputs_complete(self):
"""Raise UserError if the step has REQUIRED step_input prompts
that haven't been recorded yet. AS9100 / Nadcap need a complete
per-step data trail; finishing a step with missing prompts breaks
the audit chain.
Manager bypass via context fp_skip_required_inputs_gate=True
(e.g. paper-form catch-up or documented customer deviation).
Bypasses are posted to chatter naming the user.
"""
if self.env.context.get('fp_skip_required_inputs_gate'):
for step in self:
step.job_id.message_post(body=Markup(_(
'Required-inputs gate bypassed on step "<b>%s</b>" by %s. '
'Documented deviation — review the step\'s prompts.'
)) % (step.name, self.env.user.name))
return
for step in self:
missing = step._fp_missing_required_step_inputs()
if not missing:
continue
names = ', '.join('"%s"' % (p.name or '').strip() for p in missing)
raise UserError(_(
'Step "%(step)s" cannot be finished — %(n)s required '
'input(s) not recorded yet: %(names)s. '
'Click "Record Inputs" on the step row to enter the '
'missing values, then finish. '
'Managers can override via context flag '
'fp_skip_required_inputs_gate=True for documented '
'deviations.'
) % {
'step': step.name,
'n': len(missing),
'names': names,
})
def _fp_open_input_wizard(self, advance_after=False):
"""Open the Record Inputs OWL dialog (Sub 12e v4).
@@ -593,93 +743,12 @@ class FpJobStep(models.Model):
# _fp_open_input_wizard above adds the advance_after pathway used
# only by action_finish_and_advance.
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes
on a recipe that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance). Bake fields live on the
recipe root post-promote-customer-spec.
2) Post a chatter warning when duration_actual exceeds 1.5×
duration_expected — silent overruns are a red flag for
scheduling and costing.
Both actions are idempotent and never block the finish itself.
"""
result = super().button_finish()
BW = self.env['fusion.plating.bake.window']
Bath = self.env['fusion.plating.bath']
for step in self:
if step.state != 'done':
continue
# Duration-overrun chatter alert.
if step.duration_expected and step.duration_actual:
ratio = step.duration_actual / step.duration_expected
if ratio >= 1.5:
step.job_id.message_post(body=Markup(_(
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
'expected %.0f min, actual %.0f min. Investigate: '
'equipment issue, training gap, or recipe time '
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
recipe_root = step.job_id.recipe_id
if not recipe_root:
continue
requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
# Trigger only on the actual plating-out step. We want
# exactly ONE bake.window per job (not one per step that
# happens to have "plate" in the name). Heuristic:
# - step.kind == 'wet' (clean, recipe-authored signal); OR
# - the step name contains "plating" as a word
# Explicit excludes: inspection / bake / mask / rack steps
# whose names might happen to mention plating in passing
# (e.g. "Post-plate Inspection").
name_l = (step.name or '').lower()
kind_match = step.kind == 'wet'
name_match = bool(re.search(r'\bplating\b', name_l))
excluded = any(kw in name_l for kw in (
'inspect', 'inspection', 'bake', 'mask', 'rack',
))
if (not kind_match and not name_match) or excluded:
continue
# Idempotency — only one bake.window per (job, step).
existing = BW.sudo().search([
('part_ref', '=', step.job_id.name),
('lot_ref', '=', f'step-{step.id}'),
], limit=1)
if existing:
continue
# Pick a bath: step.bath_id wins; fall back to the first
# active bath in the facility (best-effort — operator can
# correct on the bake.window record).
bath = step.bath_id or Bath.sudo().search(
[('facility_id', '=', step.facility_id.id)], limit=1,
) if step.facility_id else False
if not bath:
bath = Bath.sudo().search([], limit=1)
if not bath:
_logger.warning(
'Step %s: bake-window auto-spawn skipped — no bath '
'configured.', step.name,
)
continue
bw = BW.sudo().create({
'bath_id': bath.id,
'plate_exit_time': step.date_finished or fields.Datetime.now(),
'window_hours': window_hrs,
'part_ref': step.job_id.name,
'lot_ref': f'step-{step.id}',
'customer_ref': step.job_id.partner_id.display_name or '',
'quantity': int(step.job_id.qty or 0),
})
step.job_id.message_post(body=Markup(_(
'Bake window <b>%s</b> auto-created — %.1fh window from '
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result
# NOTE — the earlier duplicate `button_finish` definition that held
# the duration-overrun + bake.window auto-spawn logic has been merged
# into the canonical button_finish further down (line ~1130). Python
# was silently keeping only the LAST definition in this class body,
# so the bake.window auto-spawn was dead code for the entire WO-30051
# era. Don't re-introduce a second button_finish here.
# ==================================================================
# Phase 2 multi-serial — auto-promote serials on step transitions
@@ -1070,18 +1139,112 @@ class FpJobStep(models.Model):
return result
def button_finish(self):
# Policy B — block until QA-005 complete (when customer requires it).
"""Canonical button_finish — gates first, then super(), then
post-finish side effects.
Gates (raise UserError, blocking finish):
- Required step_input prompts recorded (S21 / WO-30051 fix).
Manager bypass: fp_skip_required_inputs_gate=True.
- Sign-off recorded when recipe step has requires_signoff=True
(S22 / F1 audit fix). Auto-sign captures the finisher when
no supervisor has pre-signed. Manager bypass:
fp_skip_signoff_gate=True.
- Contract Review (QA-005) complete when customer requires it.
- Receiving gate — parts physically on site for this WO.
(Racking-inspection gate removed — racking is a recipe step
now, not a separate workflow. _fp_check_racking_inspection_
complete() is kept as a helper for diagnostics.)
Post-finish (idempotent, never blocks):
- Promote attached serials from in_process -> inspected on the
terminal step of the job.
- Chatter warning when duration_actual >= 1.5x duration_expected.
- Auto-spawn a bake.window for wet plating steps on recipes
flagged requires_bake_relief.
"""
# ----- Gates ----------------------------------------------------
# Order matters: cheapest checks first. Required-inputs is a pure
# ORM query; contract review and receiving may touch related models.
self._fp_check_step_inputs_complete()
# Sign-off: auto-capture the finisher's uid first (no-op when a
# supervisor pre-signed via action_signoff), THEN gate. Gate only
# fires when both autosign and explicit sign-off skipped (e.g.
# migration scripts, background crons).
self._fp_autosign_if_required()
self._fp_check_signoff_complete()
self._fp_check_contract_review_complete()
# Receiving gate — same helper as button_start, exempts CR steps.
self._fp_check_receiving_gate()
# NOTE: racking inspection gate removed — racking is now a recipe
# step, not a separate inspection workflow. _fp_check_racking_
# inspection_complete() is kept as a helper for diagnostics but
# no longer enforced from button_finish.
result = super().button_finish()
# ----- Post-finish side effects --------------------------------
BW = self.env['fusion.plating.bake.window']
Bath = self.env['fusion.plating.bath']
for step in self:
if step.state == 'done':
step._fp_promote_serials_on_finish()
if step.state != 'done':
continue
step._fp_promote_serials_on_finish()
# Duration-overrun chatter alert.
if step.duration_expected and step.duration_actual:
ratio = step.duration_actual / step.duration_expected
if ratio >= 1.5:
step.job_id.message_post(body=Markup(_(
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
'expected %.0f min, actual %.0f min. Investigate: '
'equipment issue, training gap, or recipe time '
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
# Bake-window auto-spawn — wet plating step + recipe flagged
# requires_bake_relief. Heuristic identifies the actual
# plate-out step (kind=wet OR "plating" as a word in name),
# excluding inspection/bake/mask/rack steps that mention
# plating in passing (e.g. "Post-plate Inspection").
recipe_root = step.job_id.recipe_id
if not recipe_root:
continue
requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
name_l = (step.name or '').lower()
kind_match = step.kind == 'wet'
name_match = bool(re.search(r'\bplating\b', name_l))
excluded = any(kw in name_l for kw in (
'inspect', 'inspection', 'bake', 'mask', 'rack',
))
if (not kind_match and not name_match) or excluded:
continue
existing = BW.sudo().search([
('part_ref', '=', step.job_id.name),
('lot_ref', '=', f'step-{step.id}'),
], limit=1)
if existing:
continue
bath = step.bath_id or Bath.sudo().search(
[('facility_id', '=', step.facility_id.id)], limit=1,
) if step.facility_id else False
if not bath:
bath = Bath.sudo().search([], limit=1)
if not bath:
_logger.warning(
'Step %s: bake-window auto-spawn skipped — no bath '
'configured.', step.name,
)
continue
bw = BW.sudo().create({
'bath_id': bath.id,
'plate_exit_time': step.date_finished or fields.Datetime.now(),
'window_hours': window_hrs,
'part_ref': step.job_id.name,
'lot_ref': f'step-{step.id}',
'customer_ref': step.job_id.partner_id.display_name or '',
'quantity': int(step.job_id.qty or 0),
})
step.job_id.message_post(body=Markup(_(
'Bake window <b>%s</b> auto-created — %.1fh window from '
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result
# ==================================================================