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