From ca44461b6f158e10f06d7639eb2ff68446ed68fb Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 00:32:52 -0400 Subject: [PATCH] feat(fusion_plating): partial order handling on the shop floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> " 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) --- fusion_plating/CLAUDE.md | 22 +++ fusion_plating/fusion_plating/__manifest__.py | 2 +- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/fp_job.py | 33 +++- .../fusion_plating_jobs/models/fp_job_step.py | 68 ++++++++ .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../controllers/move_controller.py | 47 +++++- .../controllers/plant_kanban.py | 150 ++++++++++++++---- .../controllers/workspace_controller.py | 5 + .../static/src/js/components/plant_card.js | 6 +- .../static/src/js/job_workspace.js | 58 ++++++- .../static/src/js/move_parts_dialog.js | 21 +++ .../src/scss/components/_plant_card.scss | 15 ++ .../static/src/scss/move_dialogs.scss | 86 ++++++++++ .../static/src/xml/components/plant_card.xml | 8 + .../static/src/xml/move_parts_dialog.xml | 119 +++++++------- 16 files changed, 544 insertions(+), 100 deletions(-) diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 312d5015..050606b8 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -1832,3 +1832,25 @@ When adding a new admin config, drop it into the right Configuration folder: - 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. + +--- + +## 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 → " 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). diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index bf273657..42aa6d9d 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.22.1.0', + 'version': '19.0.22.2.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index dbb31df6..be02bae2 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.11.5.0', + 'version': '19.0.11.6.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index b0e5ede5..31883e0f 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -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(). diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 9bffefb6..5fd54044 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -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. diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 36d6eac3..016e1472 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.36.1.1', + 'version': '19.0.36.2.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.', 'description': """ diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py index 8263e3a3..548905e8 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/move_controller.py @@ -147,7 +147,12 @@ class FpTabletMoveController(http.Controller): Step = request.env['fp.job.step'] from_step = Step.browse(from_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 { 'ok': True, 'qty_available': qty, @@ -186,7 +191,7 @@ class FpTabletMoveController(http.Controller): if hard: 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({ 'job_id': from_step.job_id.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 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 ctx = request.env.context bypass_flags = [ @@ -279,7 +306,7 @@ class FpTabletMoveController(http.Controller): 'batches': [ { '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 ''), 'wo_number': s.job_id.name or '', } @@ -343,7 +370,7 @@ class FpTabletMoveController(http.Controller): moves = [] 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({ 'job_id': batch.job_id.id, 'from_step_id': batch.id, @@ -353,9 +380,19 @@ class FpTabletMoveController(http.Controller): 'rack_id': rack.id, '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 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' return {'move_ids': moves, 'count': len(moves)} diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py b/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py index fb2100f6..d5e0e15d 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py @@ -10,7 +10,7 @@ docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md. """ import json import logging -from datetime import date, datetime, timedelta +from datetime import date, datetime from odoo import _, http from odoo.http import request @@ -110,19 +110,28 @@ class PlantKanbanController(http.Controller): jobs = Job.search(domain, limit=500) - # Bucket by area_kind of the active step (or 'receiving' when no - # active step yet — matches the contract_review / no_parts states - # that live in Receiving column per spec §3 D5). + # Partial-order handling (2026-06-02): a job shows up as a card in + # EVERY stage where it currently has parts (a "presence"), not just + # 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_by_area = {area: [] for area, _label in _COLUMN_LABELS} for job in jobs: - area = _resolve_card_area(job) - cards_by_area.setdefault(area, []).append(job.id) - cards[str(job.id)] = _render_card(job, paired) + active_area = (job.active_step_id.area_kind + if job.active_step_id else _resolve_card_area(job)) + 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 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 = [ { @@ -251,21 +260,99 @@ def _resolve_card_area(job): return 'receiving' -def _render_card(job, paired): - """Build the full card payload for one fp.job.""" - # Sudo the job recordset so cross-module field reads (sale.order, - # fp.part.catalog, fusion.plating.customer.spec) don't AccessError - # for low-privilege roles like Technician. The output is denormalized - # display data; the underlying record visibility is controlled by the - # caller's fp.job ACL (Technician can read all jobs). +def _job_presences(job): + """Return the list of (area, focus_step, qty_here) presences for a job. + + One entry per Shop Floor area where the job currently has parts parked + OR an actionable (in_progress / paused / ready) step. This is what lets + a split job appear in several columns at once. A job whose parts are + 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() - step = job.active_step_id try: timeline = json.loads(job.mini_timeline_json or '[]') except (TypeError, ValueError): 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 spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None so = job.sale_order_id @@ -274,10 +361,11 @@ def _render_card(job, paired): if so and 'x_fc_po_number' in so._fields: po_number = so.x_fc_po_number or '' - # Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable) 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_seq = step.sequence if step else 0 step_total = len(job.step_ids) @@ -285,23 +373,15 @@ def _render_card(job, paired): if step and step.work_centre_id: tank_label = step.work_centre_id.name or step.work_centre_id.code or '' - # State chip - state_chip = _state_chip(job.card_state, step) + state_chip = _state_chip(card_state, step) - # Operator pill (only when step has an assigned user) operator = None if step and step.assigned_user_id: u = step.assigned_user_id - operator = { - 'id': u.id, - 'name': u.name, - 'initials': _initials_for(u), - } + operator = {'id': u.id, 'name': u.name, 'initials': _initials_for(u)} - # Icon row icons = _icons(job, step) - # Due label due_label = _due_label(job.date_deadline) if job.date_deadline else '' is_overdue = ( bool(job.date_deadline) @@ -311,9 +391,17 @@ def _render_card(job, paired): return { '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 '', - 'is_mine': job.card_state in ('ready_mine', 'running_mine'), - 'card_state': job.card_state or '', + 'is_mine': card_state in ('ready_mine', 'running_mine'), + 'card_state': card_state or '', 'due_date': (job.date_deadline.strftime('%Y-%m-%d') if job.date_deadline else None), 'due_label': due_label, diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py index 6357b7af..b0a473ab 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py @@ -76,6 +76,11 @@ class FpWorkspaceController(http.Controller): 'kind': step.kind or 'other', 'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''), '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_name': step.assigned_user_id.name or '', 'work_centre_name': step.work_centre_id.name or '', diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/components/plant_card.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/components/plant_card.js index c92105db..311e8acc 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/components/plant_card.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/components/plant_card.js @@ -60,11 +60,15 @@ export class FpPlantCard extends Component { onCardClick() { const c = this.props.card; 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({ type: "ir.actions.client", tag: "fp_job_workspace", target: "current", - params: { job_id: c.job_id }, + params: { job_id: c.job_id, focus_step_id: c.focus_step_id || false }, }); } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js index e55595f1..e726d995 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js @@ -30,11 +30,12 @@ import { FpTabletLock } from "./tablet_lock"; import { FpRackPartsDialog } from "./rack_parts_dialog"; import { FpDamageDialog } from "./fp_damage_dialog"; import { FpFinishBlockDialog } from "./fp_finish_block_dialog"; +import { FpMovePartsDialog } from "./move_parts_dialog"; export class FpJobWorkspace extends Component { static template = "fusion_plating_shopfloor.JobWorkspace"; static props = ["*"]; - static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog }; + static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, FpMovePartsDialog }; setup() { this.notification = useService("notification"); @@ -225,7 +226,21 @@ export class FpJobWorkspace extends Component { if (step.override_excluded) return []; 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") { + const adv = advanceAction(); + if (adv) actions.push(adv); actions.push({ key: "record_inputs", label: "Record Inputs", icon: "fa fa-pencil", cssClass: "btn btn-secondary" }); actions.push({ key: "pause", label: "Pause", @@ -240,6 +255,8 @@ export class FpJobWorkspace extends Component { if (step.state === "paused") { actions.push({ key: "resume", label: "Resume", icon: "fa fa-play", cssClass: "btn btn-primary" }); + const adv = advanceAction(); + if (adv) actions.push(adv); actions.push({ key: "record_inputs", label: "Record Inputs", icon: "fa fa-pencil", cssClass: "btn btn-secondary" }); actions.push({ @@ -281,6 +298,7 @@ export class FpJobWorkspace extends Component { case "mark_passed": return this.onMarkPassed(step); case "open_contract_review": return this.onOpenContractReview(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) ----------------------- // The receiver card at the top of the workspace lets the dock receiver // count boxes, set per-line received quantities + condition, log damage diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/move_parts_dialog.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/move_parts_dialog.js index e0c16421..c7a4af73 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/move_parts_dialog.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/move_parts_dialog.js @@ -40,6 +40,11 @@ export class FpMovePartsDialog extends Component { promptValues: {}, blockers: [], 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 () => { await this.loadPreview(); @@ -152,4 +157,20 @@ export class FpMovePartsDialog extends Component { { 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; + } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss index f6fe3c1d..01c9730a 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss @@ -91,6 +91,21 @@ .card-sub-em { color: $plant-text; font-weight: 600; } .card-meta { font-size: 11px; color: $plant-muted; } .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; } .chip { diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/move_dialogs.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/move_dialogs.scss index 166e207b..f0c2012e 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/move_dialogs.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/move_dialogs.scss @@ -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; + } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/components/plant_card.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/components/plant_card.xml index 5097b5cb..5482bbc0 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/components/plant_card.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/components/plant_card.xml @@ -47,6 +47,14 @@
+ +
+ + of here +
+
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml index 24accb48..8e027316 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/move_parts_dialog.xml @@ -2,75 +2,48 @@ - +
-
- - - Available: + +
+ + +
-
- - - -
- -
- - - -
- -
- - - -
- -
- - - + +
+ +
+ + + + +
+ parked here
+
-
-
- - - -
- -
-
Compliance Prompts
+ +
+
Required before sending
+
-
Blockers
@@ -114,6 +86,39 @@
+ + +
+ +
+
+
+ + +
+
+ + +
+
Loading…
@@ -126,7 +131,7 @@ t-att-disabled="!canCommit" t-att-title="blockerTooltip" t-on-click="onCommit"> - MOVE () + SEND ()