chore(plating): de-dash shipped code + intake-neutral customer emails

Replace em-dashes and en-dashes with hyphens across 789 shipped source
files (py/xml/js/scss) so the delivered module reads as human-written;
em-dashes had become a recognizable AI-generated tell. Internal .md dev
notes are excluded. The WO-sticker mojibake strippers keep their dash
search targets (now written — / –). No logic changes: comments
and display strings only; validated with py_compile + lxml parse.

Rewrite the 7 customer notification emails to be intake-neutral
(ship-in / drop-off / pickup) and repair-aware, and fix the Shipped
email documents line (packing slip vs bill of lading; certificate only
when issued). Subjects use a hyphen separator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-05 00:16:19 -04:00
parent c9eb61ee0c
commit 8c76a16366
789 changed files with 4692 additions and 4692 deletions

View File

@@ -17,7 +17,7 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# 2026-05-24 Shop Floor live-step fix (19.0.10.24.0):
# 2026-05-24 - Shop Floor live-step fix (19.0.10.24.0):
# The legacy `_STEP_KIND_TO_AREA` dict that lived here was removed.
# fp.step.kind now self-declares its area_kind, so the kind taxonomy
# IS the source of truth for Shop Floor column routing.
@@ -27,7 +27,7 @@ _logger = logging.getLogger(__name__)
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
# ===== Sub 13 sequential enforcement (recipe + per-step) =============
# ===== Sub 13 - sequential enforcement (recipe + per-step) =============
# Decision matrix for whether button_start must verify predecessors:
#
# recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | block?
@@ -56,7 +56,7 @@ class FpJobStep(models.Model):
return True
# Partial-flow short-circuit (2026-06-02 partial-order handling).
# Once REAL parts have physically arrived at this step (a move
# parked them here), the predecessor lock is moot the parts are
# parked them here), the predecessor lock is moot - the parts are
# on the floor at this station, so the step is startable
# regardless of whether upstream steps are fully done. This is
# what lets a partial group "light up" the next stage while the
@@ -68,12 +68,12 @@ class FpJobStep(models.Model):
recipe_seq = self.job_id.enforce_sequential
if recipe_seq:
return not self.parallel_start
# Free-flow recipe only the legacy per-step flag still gates.
# Free-flow recipe - only the legacy per-step flag still gates.
return bool(self.requires_predecessor_done)
def _fp_has_real_incoming(self):
"""True when real parts have physically arrived at this step via
a move an incoming move from a DIFFERENT step with qty_moved > 0.
a move - an incoming move from a DIFFERENT step with qty_moved > 0.
Distinct from the qty_at_step first-step seed (a notional UI hint
with no backing move) and from self-loop measurement moves
@@ -131,7 +131,7 @@ class FpJobStep(models.Model):
)
step.can_start = not bool(blocking)
# ===== 2026-05-23 plant-view redesign area_kind + activity =========
# ===== 2026-05-23 plant-view redesign - area_kind + activity =========
area_kind = fields.Selection(
[
('receiving', 'Receiving'),
@@ -171,22 +171,22 @@ class FpJobStep(models.Model):
Priority chain (non-gating steps):
1. step-NAME override for unambiguous de-rack / de-mask / bake
steps (2026-06-03) their recipe kind and/or work-centre is
steps (2026-06-03) - their recipe kind and/or work-centre is
frequently wrong (tagged 'racking'/'mask', a shared station, or
left blank), scattering cards across the Racking / Masking /
Plating columns. The operator-facing NAME is unambiguous, so it
wins OUTRIGHT even over an explicit work-centre. Bake/oven
wins OUTRIGHT - even over an explicit work-centre. Bake/oven
steps that merely mention "de-rack" stay in Baking. See spec
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
2. work_centre.area_kind (explicit operator setup)
3. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
4. catch-all 'plating' (data integrity issue if we land here)
Gating/marker steps (kind `code == 'gating'` the "Ready for X"
Gating/marker steps (kind `code == 'gating'` - the "Ready for X"
steps) have NO physical location; the taxonomy maps them to
'receiving', which made a mid-recipe gate snap the job's card back
to the first column (Racking -> "Ready for processing" jumped to
Receiving, so the job looked like it vanished 2026-06-02). A
Receiving, so the job looked like it vanished - 2026-06-02). A
gating step FALLS FORWARD to the next non-gating step's column
(it's "ready for [that stage]"), keeping the card moving
left->right. If nothing real follows, it falls back to the last
@@ -196,7 +196,7 @@ class FpJobStep(models.Model):
step.area_kind = step._fp_resolve_area_kind()
def _fp_raw_area_kind(self):
"""Area from this step's OWN name / work_centre / kind only no
"""Area from this step's OWN name / work_centre / kind only - no
look-ahead and no dependence on the computed `area_kind` field (so
the gating fall-forward below can't recurse).
@@ -261,7 +261,7 @@ class FpJobStep(models.Model):
x = (name or '').strip().lower()
if not x:
return None
# bake / oven first a "post de-rack" oven bake IS a bake
# bake / oven first - a "post de-rack" oven bake IS a bake
if 'oven' in x or 'bake' in x:
if any(w in x for w in (
'processing', 'inspect', 'check', 'qc',
@@ -306,7 +306,7 @@ class FpJobStep(models.Model):
_logger.debug("last_activity_at stamp on message_post failed: %s", exc)
return res
# Gate visualizer drives the OWL GateViz component on the tablet.
# Gate visualizer - drives the OWL GateViz component on the tablet.
# Returns kind of blocker + human reason + optional (model, id) jump
# target. Reuses _fp_should_block_predecessors so this stays in sync
# with can_start as a single source of truth.
@@ -349,7 +349,7 @@ class FpJobStep(models.Model):
step.blocker_jump_target_id = 0
continue
# Predecessor gate same policy as _compute_can_start
# Predecessor gate - same policy as _compute_can_start
if step._fp_should_block_predecessors():
earlier_open = step.job_id.step_ids.filtered(lambda x: (
x.id != step.id
@@ -376,7 +376,7 @@ class FpJobStep(models.Model):
step.blocker_jump_target_id = 0
# ==================================================================
# Shop-Floor auto-pause cron (Phase 2 tablet redesign)
# Shop-Floor auto-pause cron (Phase 2 - tablet redesign)
# ==================================================================
@api.model
def _cron_autopause_stale_steps(self):
@@ -386,7 +386,7 @@ class FpJobStep(models.Model):
fp.shopfloor.autopause_threshold_hours (default 8.0)
Recipes can opt out per node via
fusion.plating.process.node.long_running (Phase 2 P2.1)
fusion.plating.process.node.long_running (Phase 2 - P2.1)
Fixes the 411-hour ghost timer that bit us on the original tablet
when an operator started a step and never tapped Finish. Posts an
@@ -422,7 +422,7 @@ class FpJobStep(models.Model):
paused += 1
except Exception:
_logger.exception(
"Auto-pause failed for step %s skipping", step.id,
"Auto-pause failed for step %s - skipping", step.id,
)
if paused:
_logger.info(
@@ -448,7 +448,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' only in-progress steps can pause."
"Step '%s' is in state '%s' - only in-progress steps can pause."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
@@ -465,7 +465,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state not in ('pending', 'ready'):
raise UserError(_(
"Step '%s' is in state '%s' only pending/ready steps can be skipped."
"Step '%s' is in state '%s' - only pending/ready steps can be skipped."
) % (step.name, step.state))
step.state = 'skipped'
return True
@@ -477,7 +477,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state == 'done':
raise UserError(_(
"Step '%s' is done cannot cancel."
"Step '%s' is done - cannot cancel."
) % step.name)
if step.state == 'cancelled':
raise UserError(_(
@@ -487,7 +487,7 @@ class FpJobStep(models.Model):
return True
def button_reset(self):
"""Reset a step back to 'ready' so it can be redone operator
"""Reset a step back to 'ready' so it can be redone - operator
self-serve for a mistake, an accidental skip, or a customer redo
request. Clears the finish + sign-off stamps and closes any open
timelog so the redo re-captures them; KEEPS the first-start audit
@@ -529,7 +529,7 @@ class FpJobStep(models.Model):
"""Post a chatter trail on the parent JOB whenever an active
step gets reassigned. The step itself already tracks
assigned_user_id (tracking=True) but supervisors don't open
each step's chatter they read the job. Without a job-level
each step's chatter - they read the job. Without a job-level
post the takeover is invisible.
Only fires for steps in active states (in_progress / paused)
@@ -593,7 +593,7 @@ class FpJobStep(models.Model):
@api.model
def _cron_nudge_stale_in_progress(self, threshold_hours=8):
"""Cron nudge for steps stuck in `in_progress` longer than
threshold. Default 8 hours operator started, walked away,
threshold. Default 8 hours - operator started, walked away,
timelog accumulating phantom hours.
"""
return self._cron_nudge_stale_steps(
@@ -610,7 +610,7 @@ class FpJobStep(models.Model):
Finds every fp.job.step in any of `states` with date_started
older than N hours. Schedules a 'todo' mail.activity on the
parent job for the job's manager_id (falls back to the user
who started the step). Idempotent won't double-schedule if
who started the step). Idempotent - won't double-schedule if
an open activity with the same summary already exists.
"""
from datetime import timedelta as _td
@@ -689,7 +689,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state not in ('in_progress', 'paused'):
raise UserError(_(
"Step '%s' is in state '%s' only in_progress / "
"Step '%s' is in state '%s' - only in_progress / "
"paused steps can be aborted for retry."
) % (step.name, step.state))
old_tank = step.tank_id.display_name or '(no tank set)'
@@ -715,7 +715,7 @@ class FpJobStep(models.Model):
'Reason: <em>%s</em><br/>'
'Equipment: tank=%s, bath=%s%s<br/>'
'Partial work captured: %.2f min in %d timelog(s). '
'Step is back in <b>ready</b> state operator can '
'Step is back in <b>ready</b> state - operator can '
'restart when the issue is resolved.'
)) % (
step.name, self.env.user.name, reason,
@@ -725,7 +725,7 @@ class FpJobStep(models.Model):
return True
def action_recompute_duration_from_timelogs(self):
"""Manual button re-sum duration_actual + post to chatter
"""Manual button - re-sum duration_actual + post to chatter
for audit. Use case: supervisor adjusts a timelog row and
wants an explicit audit trail of the recompute. The
automatic version called from timelog hooks is
@@ -743,7 +743,7 @@ class FpJobStep(models.Model):
return True
def _fp_resum_duration_actual(self):
"""Quiet re-sum used by automatic triggers (timelog
"""Quiet re-sum - used by automatic triggers (timelog
create/write/unlink hooks). No chatter post. Skips no-op
updates so writes are minimised."""
for step in self:
@@ -753,7 +753,7 @@ class FpJobStep(models.Model):
return True
def action_finish_and_advance(self):
"""Steelhead-style "Finish & Next" finish this step then auto-
"""Steelhead-style "Finish & Next" - finish this step then auto-
start the next pending/ready step in sequence. Single click
replaces the prior Finish-then-Move-wizard dance.
@@ -765,10 +765,10 @@ class FpJobStep(models.Model):
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' start it before clicking Finish."
"Step '%s' is in state '%s' - start it before clicking Finish."
) % (self.name, self.state))
# Contract Review (QA-005) routing when the recipe step is
# Contract Review (QA-005) routing - when the recipe step is
# flagged as a contract-review step, the operator should land on
# the part's QA-005 form rather than the generic measurement
# wizard. Once the review is complete or dismissed we fall
@@ -798,7 +798,7 @@ class FpJobStep(models.Model):
fp_skip_predecessor_check=True,
).button_start()
self.job_id.message_post(body=_(
'Step "%(prev)s" finished auto-started next step "%(next)s".'
'Step "%(prev)s" finished - auto-started next step "%(next)s".'
) % {'prev': self.name, 'next': next_step.name})
return True
@@ -817,31 +817,31 @@ class FpJobStep(models.Model):
parts (2026-06-02 partial-order handling).
Called by the Move controller after a bulk move commits. When the
last parts leave an in_progress step it should close itself one
last parts leave an in_progress step it should close itself - one
fewer tap for the operator. But finishing runs the full gate chain
(required inputs, sign-off, contract review, receiving, and the
post-shop close gates on the last step). If any gate isn't
satisfied we must NOT fail the move that already succeeded so we
satisfied we must NOT fail the move that already succeeded - so we
swallow the UserError and leave the step in_progress for the
operator to finish manually (the board will show it "running, 0
here", which reads as "finish me").
Fires for any step that actually moved parts OUT and drained to
zero INCLUDING the first/seeded stage (its qty comes from the
zero - INCLUDING the first/seeded stage (its qty comes from the
qty_at_step seed, not a real incoming move). Returns True if the
step finished.
"""
self.ensure_one()
if self.state != 'in_progress':
return False
# qty_at_step is a non-stored compute off the move rows force a
# qty_at_step is a non-stored compute off the move rows - force a
# re-read so we see the just-committed outgoing move.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step != 0:
return False
# Guard: only auto-finish a step that genuinely moved parts OUT (a
# real outgoing move, excluding self-loop measurement moves). The
# earlier guard checked _fp_has_real_incoming() the WRONG
# earlier guard checked _fp_has_real_incoming() - the WRONG
# direction: the first/seeded stage (e.g. Racking) is fed by the
# qty_at_step seed, not an incoming move, so it never auto-finished
# when all its parts were sent forward. Checking for a real
@@ -853,7 +853,7 @@ class FpJobStep(models.Model):
self.button_finish()
return True
except UserError:
# Gates still pending (missing prompts / sign-off / etc.)
# Gates still pending (missing prompts / sign-off / etc.) -
# leave the step in_progress for a manual finish. The move
# itself stands.
return False
@@ -863,7 +863,7 @@ class FpJobStep(models.Model):
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
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).
@@ -876,7 +876,7 @@ class FpJobStep(models.Model):
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
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).
"""
@@ -888,9 +888,9 @@ class FpJobStep(models.Model):
# Master switch (Sub 12d): when the recipe node opts OUT of
# measurement collection, the Record-Inputs wizard returns ZERO
# rows (fp_job_step_input_wizard.default_get). The finish gate MUST
# agree otherwise required prompts are demanded with no way to
# agree - otherwise required prompts are demanded with no way to
# enter them and the step is permanently stuck (bake nodes with
# collect_measurements=False but required prompts WO-30098 + 63
# collect_measurements=False but required prompts - WO-30098 + 63
# others on entech). Honour the switch here so gate <=> wizard.
if ('collect_measurements' in node._fields
and not node.collect_measurements):
@@ -920,10 +920,10 @@ class FpJobStep(models.Model):
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
Finish - that pre-sets signoff_user_id and this helper becomes a
no-op.
Idempotent never overwrites an existing signoff_user_id, so a
Idempotent - never overwrites an existing signoff_user_id, so a
manager pre-signing via action_signoff is preserved through the
operator's Finish click.
"""
@@ -960,7 +960,7 @@ class FpJobStep(models.Model):
continue
step.job_id.message_post(body=Markup(_(
'Sign-off gate bypassed on step "<b>%s</b>" by %s. '
'Documented deviation no signer recorded.'
'Documented deviation - no signer recorded.'
)) % (step.name, self.env.user.name))
return
for step in self:
@@ -969,7 +969,7 @@ class FpJobStep(models.Model):
if step.signoff_user_id:
continue
raise UserError(_(
'Step "%(step)s" cannot be finished sign-off required '
'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 '
@@ -977,13 +977,13 @@ class FpJobStep(models.Model):
) % {'step': step.name})
def action_signoff(self):
"""Explicit sign-off action sets signoff_user_id = env.user.id
"""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
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.
@@ -991,7 +991,7 @@ class FpJobStep(models.Model):
for step in self:
if not step.requires_signoff:
raise UserError(_(
'Step "%s" does not require sign-off nothing to sign.'
'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:
@@ -1013,7 +1013,7 @@ class FpJobStep(models.Model):
per-step data trail; finishing a step with missing prompts breaks
the audit chain.
2026-05-24: also blocks orphaned steps (recipe_node_id NULL
2026-05-24: also blocks orphaned steps (recipe_node_id NULL -
happens when the source recipe was deleted, e.g. a per-part clone
cleanup). Without a recipe link there's no way to verify required
prompts; defaulting to "let it through" was a silent compliance
@@ -1028,19 +1028,19 @@ class FpJobStep(models.Model):
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.'
'Documented deviation - review the step\'s prompts.'
)) % (step.name, self.env.user.name))
return
for step in self:
# Orphan-step block NULL recipe_node means we can't list
# Orphan-step block - NULL recipe_node means we can't list
# required prompts, so we conservatively refuse to finish.
if not step.recipe_node_id:
raise UserError(_(
'Step "%(step)s" cannot be finished this step has '
'Step "%(step)s" cannot be finished - this step has '
'no recipe link (the source recipe was deleted or the '
'job was created before recipes were assigned). '
'Required-input verification is impossible without '
'the recipe. Escalate to a manager they can bypass '
'the recipe. Escalate to a manager - they can bypass '
'with an audit-chatter entry.'
) % {'step': step.name})
missing = step._fp_missing_required_step_inputs()
@@ -1048,7 +1048,7 @@ class FpJobStep(models.Model):
continue
names = ', '.join('"%s"' % (p.name or '').strip() for p in missing)
raise UserError(_(
'Step "%(step)s" cannot be finished %(n)s required '
'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. '
@@ -1066,7 +1066,7 @@ class FpJobStep(models.Model):
Replaces the form-view-based wizard with a custom OWL Dialog
component (fp_record_inputs_dialog.js). The dialog renders
each prompt as a proper card with semantic HTML no more
each prompt as a proper card with semantic HTML - no more
list-cell-as-card CSS hacks.
When advance_after is True, the dialog's Save button commits
@@ -1084,11 +1084,11 @@ class FpJobStep(models.Model):
}
# NB: action_open_input_wizard is defined further down (line ~829)
# that one stays as the per-row "Record" button entry-point.
# - that one stays as the per-row "Record" button entry-point.
# _fp_open_input_wizard above adds the advance_after pathway used
# only by action_finish_and_advance.
# NOTE the earlier duplicate `button_finish` definition that held
# 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,
@@ -1096,16 +1096,16 @@ class FpJobStep(models.Model):
# era. Don't re-introduce a second button_finish here.
# ==================================================================
# Phase 2 multi-serial auto-promote serials on step transitions
# Phase 2 multi-serial - auto-promote serials on step transitions
# ==================================================================
def _fp_promote_serials_on_start(self):
"""When this step transitions to in_progress, lift any serial
attached to the parent SO line out of `received` / `racked` and
into `in_process`. Idempotent already-promoted serials are
into `in_process`. Idempotent - already-promoted serials are
skipped.
"""
for step in self:
# sudo() technicians lack sale.order ACL (Rule 13m).
# sudo() - technicians lack sale.order ACL (Rule 13m).
job = step.sudo().job_id
if not job.sale_order_line_ids:
continue
@@ -1124,9 +1124,9 @@ class FpJobStep(models.Model):
"""When the LAST step of this step's job finishes (sequenced
terminal step OR an explicit inspect/final-inspect kind), bump
in-flight serials to `inspected` so the shipper sees them ready
for packing. Conservative only promotes from `in_process`."""
for packing. Conservative - only promotes from `in_process`."""
for step in self:
# sudo() technicians lack sale.order ACL (Rule 13m).
# sudo() - technicians lack sale.order ACL (Rule 13m).
job = step.sudo().job_id
if not job.sale_order_line_ids:
continue
@@ -1150,7 +1150,7 @@ class FpJobStep(models.Model):
) % (step.name, self.env.user.name))
# ==================================================================
# Policy B (2026-04-28) Contract Review enforcement
# Policy B (2026-04-28) - Contract Review enforcement
# ==================================================================
# When a recipe author drops a "Contract Review" step into a recipe,
# button_start opens the QA-005 audit form for the linked part (auto-
@@ -1158,7 +1158,7 @@ class FpJobStep(models.Model):
# the form is `complete` AND the current user is on the recipe's
# contract_review_user_ids approver list (when configured).
#
# Detection case-insensitive match on the step name OR
# Detection - case-insensitive match on the step name OR
# recipe_node_id mapped from a step.template with default_kind ==
# 'contract_review' (the simple-editor library entry).
def _fp_is_contract_review_step(self):
@@ -1182,7 +1182,7 @@ class FpJobStep(models.Model):
Falls through to None when no part can be resolved (no SO line,
SO line without x_fc_part_catalog_id, etc.)."""
self.ensure_one()
# sudo() technicians lack sale.order ACL (Rule 13m).
# sudo() - technicians lack sale.order ACL (Rule 13m).
for so_line in self.sudo().job_id.sale_order_line_ids:
if (so_line.x_fc_part_catalog_id
and 'fp.contract.review' in self.env):
@@ -1199,7 +1199,7 @@ class FpJobStep(models.Model):
return None
Review = self.env.get('fp.contract.review')
if Review is None:
return None # quality module not installed skip
return None # quality module not installed - skip
review = part.x_fc_contract_review_id
if not review:
review = Review.sudo().create({
@@ -1223,7 +1223,7 @@ class FpJobStep(models.Model):
'res_id': review.id,
'view_mode': 'form',
'target': 'current',
'name': _('Contract Review %s') % (
'name': _('Contract Review - %s') % (
part.display_name or part.part_number or ''
),
}
@@ -1247,7 +1247,7 @@ class FpJobStep(models.Model):
review.state if review else _('not started')
)
raise UserError(_(
'Contract Review for %(part)s is %(state)s must be '
'Contract Review for %(part)s is %(state)s - must be '
'"complete" before this step can finish. Open the '
'QA-005 form (smart button on the part), get both '
'sections signed off, then retry. Manager bypass: '
@@ -1272,7 +1272,7 @@ class FpJobStep(models.Model):
) % ', '.join(approvers.mapped('name')))
# ==================================================================
# Sub 8 follow-up (2026-04-28) Racking Inspection enforcement
# Sub 8 follow-up (2026-04-28) - Racking Inspection enforcement
# ==================================================================
# When the recipe-side "Racking" step starts, auto-promote the linked
# fp.racking.inspection from draft → inspecting and route the operator
@@ -1325,13 +1325,13 @@ class FpJobStep(models.Model):
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection %s') % self.job_id.name,
'name': _('Racking Inspection - %s') % self.job_id.name,
}
def _fp_check_racking_inspection_complete(self):
"""Soft gate block button_finish on a Racking step until the
"""Soft gate - block button_finish on a Racking step until the
linked inspection is in a terminal state. discrepancy_flagged
counts as complete (the operator finished but flagged issues
counts as complete (the operator finished but flagged issues -
the discrepancy activity will route to the manager separately)."""
if self.env.context.get('fp_skip_racking_inspection_gate'):
return
@@ -1340,7 +1340,7 @@ class FpJobStep(models.Model):
continue
ri = step.job_id.racking_inspection_id
if not ri:
# No inspection at all still let it finish, but log a
# No inspection at all - still let it finish, but log a
# chatter warning so the manager sees the gap.
step.job_id.message_post(body=_(
'⚠️ Racking step "%s" finished without a racking '
@@ -1352,7 +1352,7 @@ class FpJobStep(models.Model):
state_label = dict(ri._fields['state'].selection).get(
ri.state, ri.state)
raise UserError(_(
'Racking inspection for %(job)s is "%(state)s" must '
'Racking inspection for %(job)s is "%(state)s" - must '
'be Done or Discrepancy Flagged before this step can '
'finish. Click the Racking Insp. smart button on the '
'job, complete the line check-off, then retry. '
@@ -1365,7 +1365,7 @@ class FpJobStep(models.Model):
def _fp_check_receiving_gate(self):
"""Block step transitions until parts are physically received.
Applied to every step EXCEPT Contract Review (paperwork doesn't
Applied to every step EXCEPT Contract Review (paperwork - doesn't
need parts on the floor). Fires from both button_start and
button_finish so an operator can't begin OR complete physical
work before the receiving record is closed.
@@ -1384,13 +1384,13 @@ class FpJobStep(models.Model):
for step in self:
if step._fp_is_contract_review_step():
continue
# sudo() technicians don't have sale.order ACL but the
# sudo() - technicians don't have sale.order ACL but the
# gate's purpose is checking a denormalized state field.
# Rule 13m: cross-module reads in tablet/floor controllers
# must sudo() the source recordset.
so = step.sudo().job_id.sale_order_id
if not so:
# Internal rework / no SO gate doesn't apply.
# Internal rework / no SO - gate doesn't apply.
continue
if 'x_fc_receiving_status' not in so._fields:
# Defensive: configurator module not installed.
@@ -1403,7 +1403,7 @@ class FpJobStep(models.Model):
so.x_fc_receiving_status or 'unknown',
)
raise UserError(_(
'Step "%(step)s" cannot proceed parts not received '
'Step "%(step)s" cannot proceed - parts not received '
'yet (SO %(so)s receiving status: %(status)s).\n\n'
'Close the receiving record (Sales > %(so)s > '
'Receiving) before starting or finishing work on '
@@ -1491,7 +1491,7 @@ class FpJobStep(models.Model):
return result
def button_finish(self):
"""Canonical button_finish gates first, then super(), then
"""Canonical button_finish - gates first, then super(), then
post-finish side effects.
Gates (raise UserError, blocking finish):
@@ -1502,8 +1502,8 @@ class FpJobStep(models.Model):
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
- 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.)
@@ -1530,7 +1530,7 @@ class FpJobStep(models.Model):
# ----- Post-shop gate (spec 2026-05-25 D12) ---------------------
# When finishing the LAST open step on an in_progress job, run
# the bake/qty/QC gates that used to live in button_mark_done.
# Failure raises UserError on THIS click operator fixes
# Failure raises UserError on THIS click - operator fixes
# (qty, bake, QC) and retries the finish. Without this the
# auto-advance helper would silently fail with no error path.
for step in self:
@@ -1545,7 +1545,7 @@ class FpJobStep(models.Model):
and s.state not in ('done', 'skipped', 'cancelled')
)
if siblings_open:
continue # not the last open step skip the gates
continue # not the last open step - skip the gates
job._fp_check_finish_gates()
result = super().button_finish()
@@ -1569,13 +1569,13 @@ class FpJobStep(models.Model):
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> '
'⚠️ <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
# 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
@@ -1608,7 +1608,7 @@ class FpJobStep(models.Model):
bath = Bath.sudo().search([], limit=1)
if not bath:
_logger.warning(
'Step %s: bake-window auto-spawn skipped no bath '
'Step %s: bake-window auto-spawn skipped - no bath '
'configured.', step.name,
)
continue
@@ -1622,7 +1622,7 @@ class FpJobStep(models.Model):
'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 '
'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
@@ -1675,11 +1675,11 @@ class FpJobStep(models.Model):
Only valid for kind='gating' steps in state in (ready, pending,
paused). NOOPs on already-terminal steps for idempotency. Raises
UserError if called on a non-gating step (defensive UI dispatcher
UserError if called on a non-gating step (defensive - UI dispatcher
only renders Mark Passed for gating kinds).
Bypasses the S21 required-inputs gate (gating steps have no
required inputs by design they're admin gates).
required inputs by design - they're admin gates).
Spec: 2026-05-24-workspace-step-actions-design.md Change 5.
"""
@@ -1735,7 +1735,7 @@ class FpJobStep(models.Model):
if not part:
_logger.warning(
"Contract-review step '%s' on job %s has no part_catalog_id "
" cannot redirect to QA-005 form, falling through to "
"- cannot redirect to QA-005 form, falling through to "
"standard wizard.",
self.name, self.job_id.name,
)
@@ -1746,7 +1746,7 @@ class FpJobStep(models.Model):
return part.action_start_contract_review()
# ------------------------------------------------------------------
# Live duration helper view binds to a non-stored compute that
# Live duration helper - view binds to a non-stored compute that
# ticks each time the form re-reads. For a true live ticking clock
# we'd need an OWL widget; this gives "minutes since start" that's
# accurate at every record refresh, which is good enough for a
@@ -1781,13 +1781,13 @@ class FpJobStep(models.Model):
step.duration_running_minutes = step.duration_actual or 0.0
# ------------------------------------------------------------------
# Sub 12d Step Details Quick-Look modal
# Sub 12d - Step Details Quick-Look modal
# ------------------------------------------------------------------
# Three computed/related fields that power the read-only manager
# quick-look modal. The modal is bound via context= on the parent
# job form's <field name="step_ids"/> no TransientModel needed.
# job form's <field name="step_ids"/> - no TransientModel needed.
# Job-level context for the quick-look modal restored after commit
# Job-level context for the quick-look modal - restored after commit
# b0070afc accidentally removed these while still referencing them in
# fp_job_step_quick_look_views.xml (entech caught the mismatch during
# the 2026-05-22 Phase 1-4 deploy).
@@ -1871,7 +1871,7 @@ class FpJobStep(models.Model):
def action_open_quick_look(self):
"""Open the read-only Step Details quick-look modal.
Bound to the row-button on the embedded step list explicit
Bound to the row-button on the embedded step list - explicit
trigger needed because editable="bottom" intercepts row clicks
for inline editing rather than opening the form view.
"""
@@ -1900,7 +1900,7 @@ class FpJobStep(models.Model):
step in one move. Paperwork / first steps don't physically
hold parts per-piece.
- real qty == 1 + downstream: record move(1).
- real qty > 1 + downstream: raise operator must use
- real qty > 1 + downstream: raise - operator must use
Complete 1 → Next (streaming) or Move… (batched).
- real qty > 1 + last step: allow (qty_done auto-tick Phase 2).
Called from action_finish_and_advance just before button_finish.
@@ -1921,7 +1921,7 @@ class FpJobStep(models.Model):
# - real incoming, qty == 1 (streaming flow last part):
# same as Complete 1 → Next's tail call
# - real incoming, qty > 1 (batched flow): one click moves
# everything forward operators with small parts don't
# everything forward - operators with small parts don't
# have to click Complete 1 → Next repeatedly
# Complete 1 → Next is still available for one-by-one flow.
self.env['fp.job.step.move'].create({