Merge remote-tracking branch 'origin/main'

# Conflicts:
#	fusion_plating/fusion_plating/__manifest__.py
#	fusion_plating/fusion_plating_jobs/__manifest__.py
#	fusion_plating/fusion_plating_jobs/models/fp_job_step.py
#	fusion_plating/fusion_plating_shopfloor/__manifest__.py
#	fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
This commit is contained in:
gsinghpal
2026-06-03 15:37:38 -04:00
187 changed files with 6060 additions and 12550 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:
@@ -129,47 +158,87 @@ class FpJobStep(models.Model):
'work_centre_id.area_kind',
'recipe_node_id.kind_id.area_kind',
'name',
'recipe_node_id.kind_id.code',
'sequence',
'job_id.step_ids.sequence',
'job_id.step_ids.name',
'job_id.step_ids.work_centre_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.area_kind',
'job_id.step_ids.recipe_node_id.kind_id.code',
)
def _compute_area_kind(self):
"""Resolve the plant-view column this step belongs in.
Priority chain:
1. name-based override for unambiguous de-rack / de-mask steps
(2026-06-03): their recipe kind AND/OR work-centre is
frequently wrong (tagged 'racking'/'mask', a shared station,
or left blank), which scattered de-racking cards across the
Racking / Masking / Plating columns. The operator-facing step
NAME is unambiguous for these, so it wins OUTRIGHT — even over
an explicit work-centre. Bake/oven steps that merely mention
"de-rack" in their name are excluded so they stay in Baking.
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
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
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)
The kind taxonomy remains the source of truth for every area
EXCEPT de-rack/de-mask (step 1). See spec
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
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
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
real stage.
"""
for step in self:
# 1. Name override (de-rack/de-mask -> De-Racking, bake/oven ->
# Baking) — unambiguous; the authored kind / work-centre is
# frequently wrong/blank for these. See _fp_area_from_step_name.
name_area = self._fp_area_from_step_name(step.name)
if name_area:
step.area_kind = name_area
continue
# 2. Explicit work_centre setup
if step.work_centre_id and step.work_centre_id.area_kind:
step.area_kind = step.work_centre_id.area_kind
continue
# 3. Kind taxonomy
node = step.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
step.area_kind = node.kind_id.area_kind
continue
# 4. Catch-all — only reached for orphaned steps (no
# work_centre AND no recipe_node).
step.area_kind = 'plating'
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
look-ahead and no dependence on the computed `area_kind` field (so
the gating fall-forward below can't recurse).
Name override (de-rack/de-mask -> De-Racking, bake/oven -> Baking)
wins OUTRIGHT: the authored kind / work-centre is frequently
wrong/blank for these. See _fp_area_from_step_name."""
self.ensure_one()
name_area = self._fp_area_from_step_name(self.name)
if name_area:
return name_area
if self.work_centre_id and self.work_centre_id.area_kind:
return self.work_centre_id.area_kind
node = self.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
return node.kind_id.area_kind
return 'plating'
def _fp_is_gating_step(self):
"""True for a 'Ready for X' marker step (no physical location).
Detected via the STABLE kind code, never the display name."""
self.ensure_one()
node = self.recipe_node_id
return bool(node and node.kind_id and node.kind_id.code == 'gating')
def _fp_resolve_area_kind(self):
"""Column for this step: its own raw area, EXCEPT a gating marker
falls forward to the next non-gating step's column."""
self.ensure_one()
if not self._fp_is_gating_step():
return self._fp_raw_area_kind()
siblings = self.job_id.step_ids
later = siblings.filtered(
lambda s: s.sequence > self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if later:
return later[0]._fp_raw_area_kind()
earlier = siblings.filtered(
lambda s: s.sequence < self.sequence and not s._fp_is_gating_step()
).sorted('sequence')
if earlier:
return earlier[-1]._fp_raw_area_kind()
return self._fp_raw_area_kind()
@staticmethod
def _fp_area_from_step_name(name):
@@ -266,6 +335,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:
@@ -701,6 +773,52 @@ 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").
Fires for any step that actually moved parts OUT and drained to
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
# 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
# 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
# OUTGOING move covers the seeded first stage correctly.
if not self.move_ids.filtered(
lambda m: m.to_step_id != self and (m.qty_moved or 0) > 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.