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:
gsinghpal
2026-06-02 00:32:52 -04:00
parent 249adf8145
commit ca44461b6f
16 changed files with 544 additions and 100 deletions

View File

@@ -2074,11 +2074,27 @@ class FpJob(models.Model):
# the operator reconciles by hand. Mirrors the receiving
# `_update_job_qty_received` pattern: server fills the
# obvious default, operator owns the edge cases.
if (not job.qty_done and not job.qty_scrapped
# Partial-order handling (2026-06-02): surface scrap that
# was recorded through the Move log (transfer_type='scrap')
# into qty_scrapped, so the reconciliation + cert qty stay
# honest even when scrap was done from the tablet Move
# dialog rather than the qty_scrapped field. Only when the
# field hasn't been set by hand.
scrap_moves = job._fp_scrapped_via_moves()
if scrap_moves and not job.qty_scrapped:
job.qty_scrapped = scrap_moves
# Clean-close auto-fill: derive the good (done) count from
# what physically came in minus scrap, instead of blindly
# assuming the whole order completed (which over-counts when
# parts were scrapped mid-line). Skips when the operator
# already typed qty_done, or when visual rejects make the
# split non-obvious — then the gate below makes them
# reconcile by hand.
if (not job.qty_done
and not (job.qty_visual_inspection_rejects or 0)
and job.qty_received
and abs(job.qty_received - job.qty) < 0.0001):
job.qty_done = job.qty
job.qty_done = job.qty - (job.qty_scrapped or 0)
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
if abs(accounted - job.qty) > 0.0001:
raise UserError(_(
@@ -2439,6 +2455,19 @@ class FpJob(models.Model):
fp_skip_step_gate=True,
).button_mark_done()
def _fp_scrapped_via_moves(self):
"""Total parts scrapped through the Move log (transfer_type=
'scrap') for this job. Lets button_mark_done's reconciliation
count scrap done via the tablet Move dialog, not just the
qty_scrapped field (partial-order handling, 2026-06-02)."""
self.ensure_one()
Move = self.env['fp.job.step.move']
moves = Move.sudo().search([
('job_id', '=', self.id),
('transfer_type', '=', 'scrap'),
])
return int(sum(m.qty_moved or 0 for m in moves))
def _fp_check_advance_post_shop(self):
"""Auto-advance in_progress jobs whose recipe steps are all
terminal. Called from fp.job.step.button_finish post-super().

View File

@@ -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.