feat(fusion_plating): partial order handling on the shop floor
Operators can now see and advance a job's parts across multiple stages
at once (e.g. 10 Masking / 20 Plating / 20 Baking on one 50-part job).
Tracking model C (fluid per-stage quantities + existing hold/scrap/
rework records for exceptions); board option 2 (a card per occupied
stage); wait-to-reconverge close. Additive only — no new model, no
migration, no change to the close/cert/ship lifecycle.
Board (fusion_plating_shopfloor/controllers/plant_kanban.py):
- One card PER (job, stage), composite key "{job_id}:{area}". Unsplit
jobs render exactly as before. _job_presences/_render_presence;
primary presence keeps full job card_state, secondary presences
derive state from their focus step.
Card (plant_card.js/.xml/.scss):
- "20 of 50 here" badge; tap opens the workspace focused on that
stage's step (focus_step_id, already accepted by the workspace).
Move + light-up (move_controller.py, fusion_plating_jobs/fp_job_step.py):
- Availability/pre-fill now from qty_at_step (step had no qty_done/
qty_scrapped fields — the old read was always 0, dead path).
- Forward move auto-flips destination pending->ready (no auto-start;
labour timer stays explicit) and auto-finishes a drained source
(best-effort). Predecessor gate is qty-aware: a step with real
arrived parts is startable regardless of upstream completion
(_fp_has_real_incoming, single source of truth for can_start /
blocker / button_start / move blockers).
Operator advance (job_workspace.js):
- "Send -> <next>" action on in_progress/paused steps opens the slimmed
Move dialog (qty steppers, no keyboard; advanced fields collapsed).
Was only wired into the deprecated shopfloor_tablet before.
Close (fp_job.py):
- button_mark_done counts move-based scrap (_fp_scrapped_via_moves) into
qty_scrapped and derives qty_done = qty - scrapped (was blindly
= job.qty, over-counting). Reconciliation gate unchanged.
Static-validated: pyflakes (py), lxml parse (xml), node --check (js).
Dynamic tests + browser check need an installed env (entech/trial) —
plating modules can't install on the local Community DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,12 +54,37 @@ class FpJobStep(models.Model):
|
||||
# leak permissive behaviour through a related-field None.
|
||||
if not self.job_id:
|
||||
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
|
||||
# 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
|
||||
# rest of the batch is still being processed upstream. Single
|
||||
# source of truth: every caller (can_start, blocker, button_start,
|
||||
# the Move dialog's _blockers_for_move) inherits this behaviour.
|
||||
if self._fp_has_real_incoming():
|
||||
return False
|
||||
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.
|
||||
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.
|
||||
|
||||
Distinct from the qty_at_step first-step seed (a notional UI hint
|
||||
with no backing move) and from self-loop measurement moves
|
||||
(from_step == to_step, used by the Record Inputs wizard). Mirrors
|
||||
the has_real_incoming test in core button_finish's qty gate.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return bool(self.incoming_move_ids.filtered(
|
||||
lambda m: m.from_step_id != self and (m.qty_moved or 0) > 0
|
||||
))
|
||||
|
||||
def _fp_has_unfinished_predecessors(self):
|
||||
"""True when an earlier-sequence step on the same job is not yet
|
||||
in a terminal state. Composes with _fp_should_block_predecessors
|
||||
@@ -86,6 +111,10 @@ class FpJobStep(models.Model):
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state',
|
||||
'job_id.step_ids.sequence',
|
||||
# Partial-flow: arriving parts clear the predecessor gate
|
||||
# (_fp_has_real_incoming), so can_start must recompute on move.
|
||||
'incoming_move_ids.qty_moved',
|
||||
'incoming_move_ids.from_step_id',
|
||||
)
|
||||
def _compute_can_start(self):
|
||||
for step in self:
|
||||
@@ -217,6 +246,9 @@ class FpJobStep(models.Model):
|
||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
||||
# Partial-flow: arriving parts clear the predecessor gate.
|
||||
'incoming_move_ids.qty_moved',
|
||||
'incoming_move_ids.from_step_id',
|
||||
)
|
||||
def _compute_blocker(self):
|
||||
for step in self:
|
||||
@@ -652,6 +684,42 @@ class FpJobStep(models.Model):
|
||||
).sorted('sequence')
|
||||
return candidates[:1] or self.env['fp.job.step']
|
||||
|
||||
def _fp_try_autofinish_on_drain(self):
|
||||
"""Best-effort auto-finish when a step has drained to zero parked
|
||||
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
|
||||
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
|
||||
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").
|
||||
|
||||
Only fires for steps that had REAL incoming parts — never an
|
||||
untouched first-step seed. Returns True if the step finished.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
return False
|
||||
if not self._fp_has_real_incoming():
|
||||
return False
|
||||
# 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
|
||||
try:
|
||||
self.button_finish()
|
||||
return True
|
||||
except UserError:
|
||||
# Gates still pending (missing prompts / sign-off / etc.) —
|
||||
# leave the step in_progress for a manual finish. The move
|
||||
# itself stands.
|
||||
return False
|
||||
|
||||
def _fp_has_uncaptured_step_inputs(self):
|
||||
"""True when the recipe step has REQUIRED step_input prompts
|
||||
whose values haven't been recorded yet.
|
||||
|
||||
Reference in New Issue
Block a user