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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user