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:
@@ -1832,3 +1832,25 @@ When adding a new admin config, drop it into the right Configuration folder:
|
|||||||
- Generic value lists → Reference Data
|
- Generic value lists → Reference Data
|
||||||
|
|
||||||
Don't add new top-level Configuration entries (siblings of the 7 folders) unless absolutely necessary — Settings is the only one allowed.
|
Don't add new top-level Configuration entries (siblings of the 7 folders) unless absolutely necessary — Settings is the only one allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Partial Order Handling — parts fanning across stages (shipped 2026-06-02)
|
||||||
|
|
||||||
|
A 50-part job can have parts at several stages at once (10 Masking, 20 Plating, 20 Baking). The data layer always supported this (`fp.job.step.qty_at_step` = live parked count, computed from `fp.job.step.move` rows); 2026-06-02 made it **visible and operable**. Spec: [`docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md`](docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md). Versions: `fusion_plating 19.0.22.2.0`, `fusion_plating_jobs 19.0.11.6.0`, `fusion_plating_shopfloor 19.0.36.2.0`. Tracking model = **fluid quantities per stage** for normal flow + existing hold/scrap/rework records for exceptions (no new model, no migration). Close behaviour = **wait to reconverge** (the lifecycle is unchanged; the diverged subset keeps the job open via the existing `qty_done + qty_scrapped == qty` gate).
|
||||||
|
|
||||||
|
**Durable gotchas (non-obvious):**
|
||||||
|
|
||||||
|
1. **The plant kanban emits one card PER (job, stage), keyed by a composite `"{job_id}:{area}"`** — NOT one card per job. `cards` is a dict of composite-key → presence payload; a split job lists its key in several `columns[].card_ids`. See `_job_presences` / `_render_presence` in `plant_kanban.py`. A job with all parts at one stage yields exactly ONE presence (identical to the old board). The PRIMARY presence (active-step column) keeps the full job-level `card_state`; SECONDARY presences derive a simpler state from their own focus step (`_secondary_card_state`). Anything reading the board payload must handle composite keys + multi-column jobs.
|
||||||
|
|
||||||
|
2. **`fp.job.step` has NO `qty_done` / `qty_scrapped` fields.** Those live on `fp.job`. The Move controller previously read `from_step.qty_done - from_step.qty_scrapped` for "available to move" → always 0 → the partial-move path was effectively dead. The source of truth for "parts parked here" is **`qty_at_step`** (move preview/commit + rack moves all read it now). Never reintroduce `step.qty_done`.
|
||||||
|
|
||||||
|
3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too.
|
||||||
|
|
||||||
|
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves.
|
||||||
|
|
||||||
|
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream.
|
||||||
|
|
||||||
|
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty − qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
|
||||||
|
|
||||||
|
**Verification:** the plating modules can't be installed on the local Community dev DB (missing enterprise deps — same reason `fusion_plating` shows `installed=0` in `modsdev`/`fusion-dev`). Static checks done: pyflakes (Python), lxml parse (XML), `node --check` as `.mjs` (JS — `node --check` on a `.js` errors with "Cannot use import statement outside a module"; copy to `/tmp/x.mjs` first). Dynamic tests + browser check require an installed env (entech / odoo-trial).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.22.1.0',
|
'version': '19.0.22.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.11.5.0',
|
'version': '19.0.11.6.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -2074,11 +2074,27 @@ class FpJob(models.Model):
|
|||||||
# the operator reconciles by hand. Mirrors the receiving
|
# the operator reconciles by hand. Mirrors the receiving
|
||||||
# `_update_job_qty_received` pattern: server fills the
|
# `_update_job_qty_received` pattern: server fills the
|
||||||
# obvious default, operator owns the edge cases.
|
# 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 not (job.qty_visual_inspection_rejects or 0)
|
||||||
and job.qty_received
|
and job.qty_received
|
||||||
and abs(job.qty_received - job.qty) < 0.0001):
|
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)
|
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
|
||||||
if abs(accounted - job.qty) > 0.0001:
|
if abs(accounted - job.qty) > 0.0001:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
@@ -2439,6 +2455,19 @@ class FpJob(models.Model):
|
|||||||
fp_skip_step_gate=True,
|
fp_skip_step_gate=True,
|
||||||
).button_mark_done()
|
).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):
|
def _fp_check_advance_post_shop(self):
|
||||||
"""Auto-advance in_progress jobs whose recipe steps are all
|
"""Auto-advance in_progress jobs whose recipe steps are all
|
||||||
terminal. Called from fp.job.step.button_finish post-super().
|
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.
|
# leak permissive behaviour through a related-field None.
|
||||||
if not self.job_id:
|
if not self.job_id:
|
||||||
return True
|
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
|
recipe_seq = self.job_id.enforce_sequential
|
||||||
if recipe_seq:
|
if recipe_seq:
|
||||||
return not self.parallel_start
|
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)
|
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):
|
def _fp_has_unfinished_predecessors(self):
|
||||||
"""True when an earlier-sequence step on the same job is not yet
|
"""True when an earlier-sequence step on the same job is not yet
|
||||||
in a terminal state. Composes with _fp_should_block_predecessors
|
in a terminal state. Composes with _fp_should_block_predecessors
|
||||||
@@ -86,6 +111,10 @@ class FpJobStep(models.Model):
|
|||||||
'job_id.enforce_sequential',
|
'job_id.enforce_sequential',
|
||||||
'job_id.step_ids.state',
|
'job_id.step_ids.state',
|
||||||
'job_id.step_ids.sequence',
|
'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):
|
def _compute_can_start(self):
|
||||||
for step in self:
|
for step in self:
|
||||||
@@ -217,6 +246,9 @@ class FpJobStep(models.Model):
|
|||||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||||
'job_id.enforce_sequential',
|
'job_id.enforce_sequential',
|
||||||
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
'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):
|
def _compute_blocker(self):
|
||||||
for step in self:
|
for step in self:
|
||||||
@@ -652,6 +684,42 @@ class FpJobStep(models.Model):
|
|||||||
).sorted('sequence')
|
).sorted('sequence')
|
||||||
return candidates[:1] or self.env['fp.job.step']
|
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):
|
def _fp_has_uncaptured_step_inputs(self):
|
||||||
"""True when the recipe step has REQUIRED step_input prompts
|
"""True when the recipe step has REQUIRED step_input prompts
|
||||||
whose values haven't been recorded yet.
|
whose values haven't been recorded yet.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.36.1.1',
|
'version': '19.0.36.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -147,7 +147,12 @@ class FpTabletMoveController(http.Controller):
|
|||||||
Step = request.env['fp.job.step']
|
Step = request.env['fp.job.step']
|
||||||
from_step = Step.browse(from_step_id)
|
from_step = Step.browse(from_step_id)
|
||||||
to_step = Step.browse(to_step_id)
|
to_step = Step.browse(to_step_id)
|
||||||
qty = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
|
# Available-to-move = parts currently parked here (qty_at_step —
|
||||||
|
# the exact number the operator sees on the card). The old
|
||||||
|
# qty_done − qty_scrapped read referenced step fields that don't
|
||||||
|
# exist on fp.job.step (always 0), which is why the move path was
|
||||||
|
# effectively unusable. See partial-order-handling design.
|
||||||
|
qty = from_step.qty_at_step or 0
|
||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'qty_available': qty,
|
'qty_available': qty,
|
||||||
@@ -186,7 +191,7 @@ class FpTabletMoveController(http.Controller):
|
|||||||
if hard:
|
if hard:
|
||||||
raise UserError(hard[0]['message'])
|
raise UserError(hard[0]['message'])
|
||||||
|
|
||||||
qty_avail = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
|
qty_avail = from_step.qty_at_step or 0
|
||||||
move = Move.create({
|
move = Move.create({
|
||||||
'job_id': from_step.job_id.id,
|
'job_id': from_step.job_id.id,
|
||||||
'from_step_id': from_step.id,
|
'from_step_id': from_step.id,
|
||||||
@@ -214,6 +219,28 @@ class FpTabletMoveController(http.Controller):
|
|||||||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||||||
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
|
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
|
||||||
|
|
||||||
|
# Partial-flow "light up" (2026-06-02 partial-order handling).
|
||||||
|
# A normal forward transfer that parks parts at the destination
|
||||||
|
# makes that stage actionable — flip pending -> ready so the
|
||||||
|
# receiving operator immediately sees a "Ready" card in their
|
||||||
|
# column with zero action by anyone. Never downgrade a step that
|
||||||
|
# is already past pending. Hold/scrap/rework/return route parts
|
||||||
|
# elsewhere and must NOT auto-ready a recipe step, so gate on
|
||||||
|
# transfer_type == 'step'.
|
||||||
|
if transfer_type == 'step' and to_step.state == 'pending':
|
||||||
|
to_step.state = 'ready'
|
||||||
|
# No auto-START — that begins the labour timer, which stays an
|
||||||
|
# explicit operator tap (keeps cost accurate; avoids the S16
|
||||||
|
# phantom-timer problem).
|
||||||
|
|
||||||
|
# Auto-finish the source when THIS forward move drained it to zero
|
||||||
|
# parked parts — one fewer tap. Best-effort: swallows finish-gate
|
||||||
|
# failures so the move always stands. Restricted to 'step' moves:
|
||||||
|
# a step drained by a HOLD still has unresolved held parts and
|
||||||
|
# must not auto-finish.
|
||||||
|
if transfer_type == 'step':
|
||||||
|
from_step._fp_try_autofinish_on_drain()
|
||||||
|
|
||||||
# Manager-bypass audit trail
|
# Manager-bypass audit trail
|
||||||
ctx = request.env.context
|
ctx = request.env.context
|
||||||
bypass_flags = [
|
bypass_flags = [
|
||||||
@@ -279,7 +306,7 @@ class FpTabletMoveController(http.Controller):
|
|||||||
'batches': [
|
'batches': [
|
||||||
{
|
{
|
||||||
'step_id': s.id,
|
'step_id': s.id,
|
||||||
'qty': (s.qty_done or 0) - (s.qty_scrapped or 0),
|
'qty': s.qty_at_step or 0,
|
||||||
'part_number': (s.job_id.product_id.default_code or ''),
|
'part_number': (s.job_id.product_id.default_code or ''),
|
||||||
'wo_number': s.job_id.name or '',
|
'wo_number': s.job_id.name or '',
|
||||||
}
|
}
|
||||||
@@ -343,7 +370,7 @@ class FpTabletMoveController(http.Controller):
|
|||||||
|
|
||||||
moves = []
|
moves = []
|
||||||
for batch in Step.search([('rack_id', '=', rack.id)]):
|
for batch in Step.search([('rack_id', '=', rack.id)]):
|
||||||
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)
|
qty = batch.qty_at_step or 0
|
||||||
move = Move.create({
|
move = Move.create({
|
||||||
'job_id': batch.job_id.id,
|
'job_id': batch.job_id.id,
|
||||||
'from_step_id': batch.id,
|
'from_step_id': batch.id,
|
||||||
@@ -353,9 +380,19 @@ class FpTabletMoveController(http.Controller):
|
|||||||
'rack_id': rack.id,
|
'rack_id': rack.id,
|
||||||
'to_tank_id': to_tank_id or False,
|
'to_tank_id': to_tank_id or False,
|
||||||
})
|
})
|
||||||
batch.qty_at_step_finish = qty
|
batch.qty_at_step_finish = (batch.qty_at_step_finish or 0) + qty
|
||||||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||||||
moves.append(move.id)
|
moves.append(move.id)
|
||||||
|
# Partial-flow "light up" — auto-finish the drained source
|
||||||
|
# batch (best-effort; see _fp_try_autofinish_on_drain).
|
||||||
|
if transfer_type == 'step':
|
||||||
|
batch._fp_try_autofinish_on_drain()
|
||||||
|
|
||||||
|
# Auto-ready the destination once parts have arrived (pending ->
|
||||||
|
# ready) so the receiving operator sees an actionable card. No
|
||||||
|
# auto-start (labour timer stays an explicit tap).
|
||||||
|
if transfer_type == 'step' and to_step.state == 'pending':
|
||||||
|
to_step.state = 'ready'
|
||||||
|
|
||||||
rack.racking_state = 'in_use'
|
rack.racking_state = 'in_use'
|
||||||
return {'move_ids': moves, 'count': len(moves)}
|
return {'move_ids': moves, 'count': len(moves)}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime
|
||||||
|
|
||||||
from odoo import _, http
|
from odoo import _, http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@@ -110,19 +110,28 @@ class PlantKanbanController(http.Controller):
|
|||||||
|
|
||||||
jobs = Job.search(domain, limit=500)
|
jobs = Job.search(domain, limit=500)
|
||||||
|
|
||||||
# Bucket by area_kind of the active step (or 'receiving' when no
|
# Partial-order handling (2026-06-02): a job shows up as a card in
|
||||||
# active step yet — matches the contract_review / no_parts states
|
# EVERY stage where it currently has parts (a "presence"), not just
|
||||||
# that live in Receiving column per spec §3 D5).
|
# the single active-step column. Cards are keyed by a composite
|
||||||
|
# "{job_id}:{area}" so one job can appear in several columns. A job
|
||||||
|
# whose parts are all at one stage produces exactly one presence —
|
||||||
|
# byte-for-byte identical to the previous one-card-per-job board.
|
||||||
cards = {}
|
cards = {}
|
||||||
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
|
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
area = _resolve_card_area(job)
|
active_area = (job.active_step_id.area_kind
|
||||||
cards_by_area.setdefault(area, []).append(job.id)
|
if job.active_step_id else _resolve_card_area(job))
|
||||||
cards[str(job.id)] = _render_card(job, paired)
|
for area, focus_step, qty_here in _job_presences(job):
|
||||||
|
key = '%s:%s' % (job.id, area)
|
||||||
|
cards[key] = _render_presence(
|
||||||
|
job, area, focus_step, qty_here,
|
||||||
|
area == active_area, paired,
|
||||||
|
)
|
||||||
|
cards_by_area.setdefault(area, []).append(key)
|
||||||
|
|
||||||
# Sort within each column by priority then due date
|
# Sort within each column by priority then due date
|
||||||
for area in cards_by_area:
|
for area in cards_by_area:
|
||||||
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
|
cards_by_area[area].sort(key=lambda k: _sort_key(cards[k]))
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
{
|
{
|
||||||
@@ -251,21 +260,99 @@ def _resolve_card_area(job):
|
|||||||
return 'receiving'
|
return 'receiving'
|
||||||
|
|
||||||
|
|
||||||
def _render_card(job, paired):
|
def _job_presences(job):
|
||||||
"""Build the full card payload for one fp.job."""
|
"""Return the list of (area, focus_step, qty_here) presences for a job.
|
||||||
# Sudo the job recordset so cross-module field reads (sale.order,
|
|
||||||
# fp.part.catalog, fusion.plating.customer.spec) don't AccessError
|
One entry per Shop Floor area where the job currently has parts parked
|
||||||
# for low-privilege roles like Technician. The output is denormalized
|
OR an actionable (in_progress / paused / ready) step. This is what lets
|
||||||
# display data; the underlying record visibility is controlled by the
|
a split job appear in several columns at once. A job whose parts are
|
||||||
# caller's fp.job ACL (Technician can read all jobs).
|
all at one stage yields exactly ONE presence — byte-for-byte identical
|
||||||
|
to the previous one-card-per-job board.
|
||||||
|
"""
|
||||||
|
job = job.sudo()
|
||||||
|
Step = job.env['fp.job.step']
|
||||||
|
# Post-shop + no-parts states are single-column, state-driven (mirrors
|
||||||
|
# _resolve_card_area). No per-stage fan-out once the job has cleared
|
||||||
|
# the line or hasn't received parts yet.
|
||||||
|
if job.card_state == 'no_parts':
|
||||||
|
return [('receiving', job.active_step_id, 0)]
|
||||||
|
if job.state == 'awaiting_cert':
|
||||||
|
return [('inspection', Step, 0)]
|
||||||
|
if job.state == 'awaiting_ship':
|
||||||
|
return [('shipping', Step, 0)]
|
||||||
|
|
||||||
|
open_steps = job.step_ids.filtered(
|
||||||
|
lambda s: s.state not in ('done', 'skipped', 'cancelled')
|
||||||
|
)
|
||||||
|
by_area = {}
|
||||||
|
for s in open_steps:
|
||||||
|
by_area.setdefault(s.area_kind or 'plating', []).append(s)
|
||||||
|
|
||||||
|
presences = []
|
||||||
|
for area, steps in by_area.items():
|
||||||
|
qty_here = sum((s.qty_at_step or 0) for s in steps)
|
||||||
|
actionable = any(
|
||||||
|
s.state in ('in_progress', 'paused', 'ready') for s in steps
|
||||||
|
)
|
||||||
|
if qty_here > 0 or actionable:
|
||||||
|
presences.append((area, _pick_focus_step(steps), qty_here))
|
||||||
|
|
||||||
|
if not presences:
|
||||||
|
# Nothing parked and nothing actionable — fall back to the single
|
||||||
|
# resolved column so the job never vanishes from the board.
|
||||||
|
return [(_resolve_card_area(job), job.active_step_id, 0)]
|
||||||
|
return presences
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_focus_step(steps):
|
||||||
|
"""The most-actionable step in an area: in_progress > paused > ready >
|
||||||
|
pending, lowest sequence within a state. Drives the presence card's
|
||||||
|
step label, operator pill, and tap target (focus_step_id)."""
|
||||||
|
ordered = sorted(steps, key=lambda s: s.sequence or 0)
|
||||||
|
for state in ('in_progress', 'paused', 'ready', 'pending'):
|
||||||
|
for s in ordered:
|
||||||
|
if s.state == state:
|
||||||
|
return s
|
||||||
|
return ordered[0] if ordered else None
|
||||||
|
|
||||||
|
|
||||||
|
def _secondary_card_state(step, paired):
|
||||||
|
"""Card state for a NON-primary presence (a stage other than the job's
|
||||||
|
active step). Derived purely from the focus step so the operator at
|
||||||
|
that stage gets an honest 'running' / 'ready' chip. The PRIMARY
|
||||||
|
presence keeps the full job-level card_state (holds, QC, bake, etc.)."""
|
||||||
|
if not step:
|
||||||
|
return 'ready'
|
||||||
|
mine = bool(
|
||||||
|
paired and step.work_centre_id
|
||||||
|
and step.work_centre_id.id == paired.id
|
||||||
|
)
|
||||||
|
if step.state == 'in_progress':
|
||||||
|
return 'running_mine' if mine else 'running'
|
||||||
|
if step.state == 'paused':
|
||||||
|
return 'running'
|
||||||
|
# ready / pending → queued at this stage
|
||||||
|
return 'ready_mine' if mine else 'ready'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_presence(job, area, step, qty_here, is_primary, paired):
|
||||||
|
"""Build a card payload for one (job, stage) presence.
|
||||||
|
|
||||||
|
The PRIMARY presence (the job's active-step column) carries the full
|
||||||
|
job-level card_state so every existing job-level signal (hold, QC,
|
||||||
|
bake-due, sign-off, idle, post-shop) renders exactly as before.
|
||||||
|
SECONDARY presences derive a simpler state from their own focus step.
|
||||||
|
|
||||||
|
Sudo the job so cross-module reads (sale.order, fp.part.catalog,
|
||||||
|
customer.spec) don't AccessError for low-privilege roles (Rule 13m) —
|
||||||
|
the output is denormalized display data; fp.job ACL gates visibility.
|
||||||
|
"""
|
||||||
job = job.sudo()
|
job = job.sudo()
|
||||||
step = job.active_step_id
|
|
||||||
try:
|
try:
|
||||||
timeline = json.loads(job.mini_timeline_json or '[]')
|
timeline = json.loads(job.mini_timeline_json or '[]')
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
timeline = []
|
timeline = []
|
||||||
|
|
||||||
# Cross-module field probes (sudo'd via job.sudo() above)
|
|
||||||
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
|
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
|
||||||
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
|
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
|
||||||
so = job.sale_order_id
|
so = job.sale_order_id
|
||||||
@@ -274,10 +361,11 @@ def _render_card(job, paired):
|
|||||||
if so and 'x_fc_po_number' in so._fields:
|
if so and 'x_fc_po_number' in so._fields:
|
||||||
po_number = so.x_fc_po_number or ''
|
po_number = so.x_fc_po_number or ''
|
||||||
|
|
||||||
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
|
|
||||||
tags = _compute_tags(job, part, spec)
|
tags = _compute_tags(job, part, spec)
|
||||||
|
|
||||||
# Step + tank labels
|
card_state = (job.card_state if is_primary
|
||||||
|
else _secondary_card_state(step, paired))
|
||||||
|
|
||||||
step_name = step.name if step else _('—')
|
step_name = step.name if step else _('—')
|
||||||
step_seq = step.sequence if step else 0
|
step_seq = step.sequence if step else 0
|
||||||
step_total = len(job.step_ids)
|
step_total = len(job.step_ids)
|
||||||
@@ -285,23 +373,15 @@ def _render_card(job, paired):
|
|||||||
if step and step.work_centre_id:
|
if step and step.work_centre_id:
|
||||||
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
|
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
|
||||||
|
|
||||||
# State chip
|
state_chip = _state_chip(card_state, step)
|
||||||
state_chip = _state_chip(job.card_state, step)
|
|
||||||
|
|
||||||
# Operator pill (only when step has an assigned user)
|
|
||||||
operator = None
|
operator = None
|
||||||
if step and step.assigned_user_id:
|
if step and step.assigned_user_id:
|
||||||
u = step.assigned_user_id
|
u = step.assigned_user_id
|
||||||
operator = {
|
operator = {'id': u.id, 'name': u.name, 'initials': _initials_for(u)}
|
||||||
'id': u.id,
|
|
||||||
'name': u.name,
|
|
||||||
'initials': _initials_for(u),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Icon row
|
|
||||||
icons = _icons(job, step)
|
icons = _icons(job, step)
|
||||||
|
|
||||||
# Due label
|
|
||||||
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
|
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
|
||||||
is_overdue = (
|
is_overdue = (
|
||||||
bool(job.date_deadline)
|
bool(job.date_deadline)
|
||||||
@@ -311,9 +391,17 @@ def _render_card(job, paired):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'job_id': job.id,
|
'job_id': job.id,
|
||||||
|
# Composite identity — one job can have several presences.
|
||||||
|
'card_key': '%s:%s' % (job.id, area),
|
||||||
|
'area_kind': area,
|
||||||
|
'is_primary': is_primary,
|
||||||
|
# Partial-order fields: parts parked at THIS stage vs whole job.
|
||||||
|
'qty_here': int(qty_here or 0),
|
||||||
|
'job_qty': int(job.qty or 0),
|
||||||
|
'focus_step_id': step.id if step else False,
|
||||||
'wo_name': job.display_wo_name or job.name or '',
|
'wo_name': job.display_wo_name or job.name or '',
|
||||||
'is_mine': job.card_state in ('ready_mine', 'running_mine'),
|
'is_mine': card_state in ('ready_mine', 'running_mine'),
|
||||||
'card_state': job.card_state or '',
|
'card_state': card_state or '',
|
||||||
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
|
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
|
||||||
if job.date_deadline else None),
|
if job.date_deadline else None),
|
||||||
'due_label': due_label,
|
'due_label': due_label,
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ class FpWorkspaceController(http.Controller):
|
|||||||
'kind': step.kind or 'other',
|
'kind': step.kind or 'other',
|
||||||
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
|
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
|
||||||
'state': step.state,
|
'state': step.state,
|
||||||
|
# Partial-order handling — parts currently parked at this
|
||||||
|
# step. Drives the "Send to next" button visibility + the
|
||||||
|
# per-step "N here" hint; the Move dialog pre-fills from the
|
||||||
|
# same number via the preview endpoint.
|
||||||
|
'qty_at_step': int(getattr(step, 'qty_at_step', 0) or 0),
|
||||||
'assigned_user_id': step.assigned_user_id.id or False,
|
'assigned_user_id': step.assigned_user_id.id or False,
|
||||||
'assigned_user_name': step.assigned_user_id.name or '',
|
'assigned_user_name': step.assigned_user_id.name or '',
|
||||||
'work_centre_name': step.work_centre_id.name or '',
|
'work_centre_name': step.work_centre_id.name or '',
|
||||||
|
|||||||
@@ -60,11 +60,15 @@ export class FpPlantCard extends Component {
|
|||||||
onCardClick() {
|
onCardClick() {
|
||||||
const c = this.props.card;
|
const c = this.props.card;
|
||||||
if (!c.job_id) return;
|
if (!c.job_id) return;
|
||||||
|
// Open the workspace focused on THIS stage's step (partial-order
|
||||||
|
// handling) — tapping the Baking card lands on the Baking step,
|
||||||
|
// not the job's global active step. The workspace already accepts
|
||||||
|
// focus_step_id (see the FP-STEP scan path in plant_kanban.js).
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.client",
|
type: "ir.actions.client",
|
||||||
tag: "fp_job_workspace",
|
tag: "fp_job_workspace",
|
||||||
target: "current",
|
target: "current",
|
||||||
params: { job_id: c.job_id },
|
params: { job_id: c.job_id, focus_step_id: c.focus_step_id || false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ import { FpTabletLock } from "./tablet_lock";
|
|||||||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||||
|
import { FpMovePartsDialog } from "./move_parts_dialog";
|
||||||
|
|
||||||
export class FpJobWorkspace extends Component {
|
export class FpJobWorkspace extends Component {
|
||||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
|
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, FpMovePartsDialog };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
@@ -225,7 +226,21 @@ export class FpJobWorkspace extends Component {
|
|||||||
if (step.override_excluded) return [];
|
if (step.override_excluded) return [];
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
// Partial-order handling — "Send to next →" advances parts parked
|
||||||
|
// at this step to the next stage. Only shown when parts are here
|
||||||
|
// AND a next stage exists. The destination name is on the button
|
||||||
|
// so there's nothing to guess; qty defaults to all parked here.
|
||||||
|
const advanceAction = () => {
|
||||||
|
const nxt = this.nextStepFor(step);
|
||||||
|
if (nxt && (step.qty_at_step || 0) > 0) {
|
||||||
|
return { key: "advance", label: "Send → " + nxt.name,
|
||||||
|
icon: "fa fa-arrow-right", cssClass: "btn btn-primary" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
if (step.state === "in_progress") {
|
if (step.state === "in_progress") {
|
||||||
|
const adv = advanceAction();
|
||||||
|
if (adv) actions.push(adv);
|
||||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||||
actions.push({ key: "pause", label: "Pause",
|
actions.push({ key: "pause", label: "Pause",
|
||||||
@@ -240,6 +255,8 @@ export class FpJobWorkspace extends Component {
|
|||||||
if (step.state === "paused") {
|
if (step.state === "paused") {
|
||||||
actions.push({ key: "resume", label: "Resume",
|
actions.push({ key: "resume", label: "Resume",
|
||||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||||
|
const adv = advanceAction();
|
||||||
|
if (adv) actions.push(adv);
|
||||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||||
actions.push({
|
actions.push({
|
||||||
@@ -281,6 +298,7 @@ export class FpJobWorkspace extends Component {
|
|||||||
case "mark_passed": return this.onMarkPassed(step);
|
case "mark_passed": return this.onMarkPassed(step);
|
||||||
case "open_contract_review": return this.onOpenContractReview(step);
|
case "open_contract_review": return this.onOpenContractReview(step);
|
||||||
case "start_with_rack": return this.onStartWithRack(step);
|
case "start_with_rack": return this.onStartWithRack(step);
|
||||||
|
case "advance": return this.onAdvanceStep(step);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +481,44 @@ export class FpJobWorkspace extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Partial-order advance (2026-06-02) -------------------------------
|
||||||
|
// "Send to next →" — moves parts parked at this step to the next stage.
|
||||||
|
// The destination auto-readies server-side (move_controller), so the
|
||||||
|
// receiving operator sees a Ready card immediately; the source
|
||||||
|
// auto-finishes when it drains to zero. Pure client-side next-step
|
||||||
|
// resolution off the loaded step list — no extra RPC.
|
||||||
|
|
||||||
|
nextStepFor(step) {
|
||||||
|
// The next stage parts flow into: lowest-sequence non-terminal step
|
||||||
|
// after this one. Returns null at the end of the line (parts finish
|
||||||
|
// in place there and close out at job mark-done).
|
||||||
|
const steps = (this.state.data && this.state.data.steps) || [];
|
||||||
|
const candidates = steps
|
||||||
|
.filter((s) => s.sequence > step.sequence
|
||||||
|
&& ["pending", "ready", "paused", "in_progress"].includes(s.state))
|
||||||
|
.sort((a, b) => a.sequence - b.sequence);
|
||||||
|
return candidates.length ? candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdvanceStep(step) {
|
||||||
|
const nxt = this.nextStepFor(step);
|
||||||
|
if (!nxt) {
|
||||||
|
this.notification.add(
|
||||||
|
"This is the last stage — parts finish here and close out at job completion.",
|
||||||
|
{ type: "warning" },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Open the slim Move dialog pre-set to advance to the next stage.
|
||||||
|
// Qty defaults to all parked here (qty_at_step) via the preview
|
||||||
|
// endpoint; the operator confirms or trims it with the steppers.
|
||||||
|
this.dialog.add(FpMovePartsDialog, {
|
||||||
|
fromStepId: step.id,
|
||||||
|
toStepId: nxt.id,
|
||||||
|
onCommit: async () => { await this.refresh(); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
|
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
|
||||||
// The receiver card at the top of the workspace lets the dock receiver
|
// The receiver card at the top of the workspace lets the dock receiver
|
||||||
// count boxes, set per-line received quantities + condition, log damage
|
// count boxes, set per-line received quantities + condition, log damage
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export class FpMovePartsDialog extends Component {
|
|||||||
promptValues: {},
|
promptValues: {},
|
||||||
blockers: [],
|
blockers: [],
|
||||||
committing: false,
|
committing: false,
|
||||||
|
// Advanced fields (Transfer Type, To Location) stay collapsed
|
||||||
|
// by default — the everyday flow is "advance all to the next
|
||||||
|
// stage", which needs none of them. Keeps the dialog to a qty
|
||||||
|
// confirm + SEND for the 95% case.
|
||||||
|
showAdvanced: false,
|
||||||
});
|
});
|
||||||
onWillStart(async () => {
|
onWillStart(async () => {
|
||||||
await this.loadPreview();
|
await this.loadPreview();
|
||||||
@@ -152,4 +157,20 @@ export class FpMovePartsDialog extends Component {
|
|||||||
{ type: "warning" });
|
{ type: "warning" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Qty steppers (no keyboard) ---------------------------------------
|
||||||
|
// The operator taps − / + or "All". Clamped to [1, qtyAvailable] so the
|
||||||
|
// count can never exceed what's parked here.
|
||||||
|
incQty() {
|
||||||
|
if (this.state.qty < this.state.qtyAvailable) this.state.qty += 1;
|
||||||
|
}
|
||||||
|
decQty() {
|
||||||
|
if (this.state.qty > 1) this.state.qty -= 1;
|
||||||
|
}
|
||||||
|
setQtyAll() {
|
||||||
|
this.state.qty = this.state.qtyAvailable;
|
||||||
|
}
|
||||||
|
toggleAdvanced() {
|
||||||
|
this.state.showAdvanced = !this.state.showAdvanced;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,21 @@
|
|||||||
.card-sub-em { color: $plant-text; font-weight: 600; }
|
.card-sub-em { color: $plant-text; font-weight: 600; }
|
||||||
.card-meta { font-size: 11px; color: $plant-muted; }
|
.card-meta { font-size: 11px; color: $plant-muted; }
|
||||||
.card-step { font-size: 14px; font-weight: 600; color: $plant-text; margin-top: 2px; }
|
.card-step { font-size: 14px; font-weight: 600; color: $plant-text; margin-top: 2px; }
|
||||||
|
// Partial-order handling — "20 of 50 here" per-stage count. The big
|
||||||
|
// number pops so an operator scanning their column instantly sees how
|
||||||
|
// many of a job's parts are at their station. Uses existing tokens so
|
||||||
|
// dark mode is handled at compile time by _plant_tokens.scss.
|
||||||
|
.card-qty-here {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $plant-muted;
|
||||||
|
margin-top: 1px;
|
||||||
|
.qty-here-num {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $plant-mine-border;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
}
|
||||||
.card-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
.card-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
|
|||||||
@@ -173,3 +173,89 @@ $fp-md-page: var(--fp-page-bg, #{$_fp_md_page_hex});
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================ Partial-order handling — easy-advance layout
|
||||||
|
// "Send Parts Forward" dialog: destination banner + big-tap qty stepper
|
||||||
|
// (no keyboard) + collapsed advanced fields. Reuses the $fp-md-* tokens so
|
||||||
|
// dark mode is handled at compile time.
|
||||||
|
.o_fp_move_dialog {
|
||||||
|
.o_fp_move_route {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
background: $fp-md-page;
|
||||||
|
border: 1px solid $fp-md-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.route-from { color: $fp-md-muted; }
|
||||||
|
.route-arrow { color: $fp-md-accent; font-weight: 800; }
|
||||||
|
.route-to { color: $fp-md-accent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_move_qty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
|
||||||
|
label { font-weight: 600; margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qty_stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
.qty-btn {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid $fp-md-border;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $fp-md-card;
|
||||||
|
color: $fp-md-accent;
|
||||||
|
|
||||||
|
&:disabled { opacity: .4; }
|
||||||
|
}
|
||||||
|
.qty-value {
|
||||||
|
min-width: 3.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.qty-all {
|
||||||
|
margin-left: .5rem;
|
||||||
|
padding: .5rem .9rem;
|
||||||
|
border: 1px solid $fp-md-border;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $fp-md-card;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qty_hint {
|
||||||
|
color: $fp-md-muted;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_move_advanced_toggle {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.btn-link { color: $fp-md-muted; text-decoration: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_move_advanced {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
border: 1px dashed $fp-md-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,14 @@
|
|||||||
<!-- Step name -->
|
<!-- Step name -->
|
||||||
<div class="card-step" t-esc="props.card.step_name"/>
|
<div class="card-step" t-esc="props.card.step_name"/>
|
||||||
|
|
||||||
|
<!-- Parts at THIS stage (partial-order handling). "20 of 50"
|
||||||
|
so a per-stage presence is never mistaken for a whole job.
|
||||||
|
Hidden when nothing is parked here (post-shop / empty). -->
|
||||||
|
<div t-if="props.card.qty_here" class="card-qty-here">
|
||||||
|
<span class="qty-here-num" t-esc="props.card.qty_here"/>
|
||||||
|
<span class="qty-here-of"> of <t t-esc="props.card.job_qty"/> here</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tank + state chip -->
|
<!-- Tank + state chip -->
|
||||||
<div class="card-chips">
|
<div class="card-chips">
|
||||||
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
|
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
|
||||||
|
|||||||
@@ -2,75 +2,48 @@
|
|||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
|
<t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
|
||||||
<Dialog title.translate="Move Parts" size="'lg'">
|
<Dialog title.translate="Send Parts Forward" size="'md'">
|
||||||
<div class="o_fp_move_dialog" t-if="!state.loading">
|
<div class="o_fp_move_dialog" t-if="!state.loading">
|
||||||
|
|
||||||
<div class="o_fp_move_field">
|
<!-- Destination banner — operator sees exactly where parts go,
|
||||||
<label>Part Count</label>
|
nothing to guess. -->
|
||||||
<input type="number" t-model.number="state.qty"
|
<div class="o_fp_move_route">
|
||||||
t-att-min="1" t-att-max="state.qtyAvailable"/>
|
<span class="route-from" t-esc="state.fromStep.name"/>
|
||||||
<span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
|
<span class="route-arrow"> → </span>
|
||||||
|
<span class="route-to" t-esc="state.toStep.name"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_move_field">
|
<!-- Qty stepper — no keyboard. Defaults to all parked here. -->
|
||||||
<label>From Node</label>
|
<div class="o_fp_move_qty">
|
||||||
<span t-esc="state.fromStep.name"/>
|
<label>How many to send?</label>
|
||||||
<span/>
|
<div class="o_fp_qty_stepper">
|
||||||
</div>
|
<button class="qty-btn" t-on-click="decQty"
|
||||||
|
t-att-disabled="state.qty <= 1">−</button>
|
||||||
<div class="o_fp_move_field" t-if="state.fromStep.tank_name">
|
<span class="qty-value" t-esc="state.qty"/>
|
||||||
<label>From Station</label>
|
<button class="qty-btn" t-on-click="incQty"
|
||||||
<span t-esc="state.fromStep.tank_name"/>
|
t-att-disabled="state.qty >= state.qtyAvailable">+</button>
|
||||||
<span/>
|
<button class="qty-all" t-on-click="setQtyAll">
|
||||||
</div>
|
All (<t t-esc="state.qtyAvailable"/>)
|
||||||
|
</button>
|
||||||
<div class="o_fp_move_field">
|
</div>
|
||||||
<label>Transfer Type</label>
|
<span class="o_fp_qty_hint"><t t-esc="state.qtyAvailable"/> parked here</span>
|
||||||
<select t-model="state.transferType">
|
|
||||||
<option value="step">Step</option>
|
|
||||||
<option value="hold">Hold</option>
|
|
||||||
<option value="scrap">Scrap</option>
|
|
||||||
<option value="rework">Rework</option>
|
|
||||||
<option value="split">Split</option>
|
|
||||||
<option value="return">Return</option>
|
|
||||||
</select>
|
|
||||||
<span/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="o_fp_move_field">
|
|
||||||
<label>To Node</label>
|
|
||||||
<span t-esc="state.toStep.name"/>
|
|
||||||
<span/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- To Station (tank) — only when the recipe offers a choice -->
|
||||||
<div class="o_fp_move_field"
|
<div class="o_fp_move_field"
|
||||||
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
||||||
<label>To Station</label>
|
<label>To Station</label>
|
||||||
<select t-model.number="state.toTankId">
|
<select t-model.number="state.toTankId">
|
||||||
<t t-foreach="state.toStep.tank_options"
|
<t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
|
||||||
t-as="tk" t-key="tk.id">
|
|
||||||
<option t-att-value="tk.id"><t t-esc="tk.name"/></option>
|
<option t-att-value="tk.id"><t t-esc="tk.name"/></option>
|
||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
<span/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_move_field">
|
<!-- Compliance prompts — only when the recipe author required
|
||||||
<label>To Location</label>
|
them. Pickers/checkboxes, minimal free text. -->
|
||||||
<select t-model="state.toLocation">
|
<div class="o_fp_compliance_prompts" t-if="state.transitionPrompts.length">
|
||||||
<option value="global">Global</option>
|
<h5>Required before sending</h5>
|
||||||
<option value="quarantine">Quarantine</option>
|
|
||||||
<option value="staging_a">Staging A</option>
|
|
||||||
<option value="staging_b">Staging B</option>
|
|
||||||
<option value="shipping_dock">Shipping Dock</option>
|
|
||||||
<option value="scrap_bin">Scrap Bin</option>
|
|
||||||
</select>
|
|
||||||
<span/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="o_fp_compliance_prompts"
|
|
||||||
t-if="state.transitionPrompts.length">
|
|
||||||
<h5>Compliance Prompts</h5>
|
|
||||||
<t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
|
<t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
|
||||||
<div class="o_fp_move_field">
|
<div class="o_fp_move_field">
|
||||||
<label>
|
<label>
|
||||||
@@ -94,13 +67,12 @@
|
|||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
|
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
|
||||||
<span t-else=""/>
|
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Blockers — inline resolve where possible -->
|
||||||
<div class="o_fp_blockers" t-if="state.blockers.length">
|
<div class="o_fp_blockers" t-if="state.blockers.length">
|
||||||
<h5>Blockers</h5>
|
|
||||||
<t t-foreach="state.blockers" t-as="b" t-key="b_index">
|
<t t-foreach="state.blockers" t-as="b" t-key="b_index">
|
||||||
<div class="o_fp_blocker_row"
|
<div class="o_fp_blocker_row"
|
||||||
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
|
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
|
||||||
@@ -114,6 +86,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- More options (advanced) — hold / scrap / rework / location.
|
||||||
|
Collapsed by default so the everyday "advance all" flow is
|
||||||
|
a qty confirm + SEND. -->
|
||||||
|
<div class="o_fp_move_advanced_toggle">
|
||||||
|
<button class="btn btn-link btn-sm" t-on-click="toggleAdvanced">
|
||||||
|
<t t-if="state.showAdvanced">▾ Hide options</t>
|
||||||
|
<t t-else="">▸ More options (hold / scrap / location)</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div t-if="state.showAdvanced" class="o_fp_move_advanced">
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>Transfer Type</label>
|
||||||
|
<select t-model="state.transferType">
|
||||||
|
<option value="step">Send to next step</option>
|
||||||
|
<option value="hold">Hold</option>
|
||||||
|
<option value="scrap">Scrap</option>
|
||||||
|
<option value="rework">Rework</option>
|
||||||
|
<option value="return">Return</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>To Location</label>
|
||||||
|
<select t-model="state.toLocation">
|
||||||
|
<option value="global">Global</option>
|
||||||
|
<option value="quarantine">Quarantine</option>
|
||||||
|
<option value="staging_a">Staging A</option>
|
||||||
|
<option value="staging_b">Staging B</option>
|
||||||
|
<option value="shipping_dock">Shipping Dock</option>
|
||||||
|
<option value="scrap_bin">Scrap Bin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div t-if="state.loading">Loading…</div>
|
<div t-if="state.loading">Loading…</div>
|
||||||
@@ -126,7 +131,7 @@
|
|||||||
t-att-disabled="!canCommit"
|
t-att-disabled="!canCommit"
|
||||||
t-att-title="blockerTooltip"
|
t-att-title="blockerTooltip"
|
||||||
t-on-click="onCommit">
|
t-on-click="onCommit">
|
||||||
MOVE (<t t-esc="state.qty"/>)
|
SEND (<t t-esc="state.qty"/>)
|
||||||
</button>
|
</button>
|
||||||
</t>
|
</t>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user