docs(fusion_plating): shop-floor partial order handling design spec

Design for parts fanning across shop-floor stages (e.g. 10 at Masking,
20 at Plating, 20 at Baking on one 50-part job):

- Tracking model C — fluid per-stage quantities via existing qty_at_step;
  failed/held/rework subsets ride existing hold/scrap/rework records.
- Board Option 2 — a card per stage-presence (composite job:area keys);
  unsplit jobs render identically to today.
- Easy-advance operator flow — one "Send to next" action, steppers /
  rack-tap (no keyboard), intent-named Hold/Scrap/Rework buttons.
- Light-up plumbing — auto-ready on arrival, qty-aware predecessor gate,
  auto-finish source on drain; no auto-start (labour accuracy).
- Close — wait to reconverge; close/cert/ship/invoice lifecycle unchanged.

Additive only: no new core model, no data migration, no change to the
quantity model, OWL component tree, or close lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-02 00:06:38 -04:00
parent aafc2db8a8
commit 249adf8145

View File

@@ -0,0 +1,173 @@
# Shop Floor — Partial Order Handling (design)
- **Date:** 2026-06-02
- **Status:** Approved (design), pending implementation plan
- **Modules touched:** `fusion_plating_shopfloor`, `fusion_plating_jobs`, `fusion_plating` (core step model)
- **Author context:** Nexa Systems / EN Technologies (entech), Odoo 19
## Problem
A plating job runs 50 parts, but parts physically fan out across stages — 10 at Masking, 20 at Plating, 20 at Baking — because the shop processes in racks/waves through tank-limited stations. Today the Shop Floor board collapses each job to **one card in one column** (the single `active_step_id.area_kind`), so an operator standing at Baking has no way to see "10 of this job's parts are here, waiting for me." Operators need to handle the spread-out parts through the different stages, and it must be **easy** (minimal typing, minimal guessing) and **production-ready**.
## Goals
- Operators can **see** a job's parts at every stage where they physically are.
- Operators can **advance** a subset to the next stage with near-zero friction.
- Failed/held/rework subsets are tracked and visible (not silently lost).
- The change is **additive** — it must not redesign the board, the quantity model, the workspace entry contract, or the close/cert/ship/invoice lifecycle.
## Non-goals (explicitly out of scope for v1)
- **Partial shipping** — shipping the good parts now and the rest later (split CoCs, split deliveries, partial invoicing). The job reconverges and ships once.
- **Named sub-lots** — persistent per-group identity/labels for interchangeable parts.
- **Manager-initiated job split** — closing a job at a shipped quantity and spawning a follow-on job for the remainder. Notable as a possible future add-on; operators never touch it.
- Multi-cert / multi-delivery per job; per-delivery quantity tracking.
---
## Current-state baseline (verified in code)
**The data layer already supports partial quantities.**
- `fp.job.step.qty_at_step` — live "parts parked here" = `sum(incoming moves) sum(outgoing moves)`, with a first-step seed (the earliest non-terminal step implicitly holds full `job.qty` before any move). Compute at `fusion_plating/models/fp_job_step.py::_compute_qty_at_step`.
- `fp.job.step.move` (`fusion_plating/models/fp_job_step_move.py`) — chain-of-custody row per move; `qty_moved` already lets an operator move a subset; `transfer_type` ∈ {step, hold, scrap, rework, split, return}.
- `move_parts_commit` (`fusion_plating_shopfloor/controllers/move_controller.py`) already moves a subset and advances `qty_at_step_start/finish`.
- `button_finish` already refuses to close a stage while parts are parked there with a downstream step waiting (`fusion_plating/models/fp_job_step.py`).
**The gap is visibility + interaction.**
- `fp.job.active_step_id` picks **one** step by priority (in_progress > paused > ready > pending) — `fusion_plating_jobs/models/fp_job.py::_compute_active_step_id`.
- The plant kanban places the whole job in the **one** column of that step — `fusion_plating_shopfloor/controllers/plant_kanban.py::_resolve_card_area` / `_render_card`; the board payload is `cards` (dict keyed by `str(job.id)`) + `columns[].card_ids` (list of job ids); OWL renders one `FpPlantCard` per id (`plant_kanban.xml`, `components/plant_card.js`).
- The Move dialog (`move_parts_dialog.{js,xml}`) exposes Transfer Type (6 options) and To Location (6 options) as always-visible dropdowns and uses a raw numeric input for qty.
**The close lifecycle assumes one job = one quantity = one CoC = one delivery.**
- `button_mark_done` (`fusion_plating_jobs/models/fp_job.py`) gates on step-completion, bake, `qty_done + qty_scrapped == qty`, receiving reconciliation, and QC, then calls `_fp_create_delivery()` + `_fp_create_certificates()` (one each).
- Auto-advance: last step finished → `awaiting_cert` (if a cert is required) or `awaiting_ship`; cert issue advances `awaiting_cert → awaiting_ship`; cert void regresses. There is an order-level "ship together" constraint (`_fp_order_ship_state`).
- No partial-shipment infrastructure exists anywhere.
---
## Decisions
| Fork | Decision |
|---|---|
| Tracking model | **C — fluid quantities per stage + existing records for exceptions.** Normal flow uses `qty_at_step`; failed/held/rework subsets ride the existing hold/scrap/rework records. No new core model. |
| Board representation | **Option 2 — a card per stage-presence.** A job appears as a card in every stage where it has parts; composite `job:area` card keys. Unsplit jobs render identically to today. |
| Operator move interaction | **Easy-advance.** One intent-named "Send to [next] →" action with everything defaulted; steppers / rack-tap instead of a keyboard; Hold/Scrap/Rework as distinct buttons. |
| Downstream "light-up" | **Auto-ready on arrival + qty-aware predecessor gate + auto-finish source on drain.** No auto-start (labour accuracy). |
| Close behaviour | **Option B — wait to reconverge.** The close/cert/ship/invoice lifecycle is unchanged; the diverged subset keeps the job open via the existing reconciliation gate. |
---
## Detailed design
### A. Data model
No new model and no new core fields are required. The feature reuses:
- `fp.job.step.qty_at_step` (live parked count) — already the source of truth.
- `fp.job.step.move` + `transfer_type` — already records advance/hold/scrap/rework.
- `fusion.plating.quality.hold` — already represents "N parts of this job are held" (the tracked exception group).
### B. Board — card per stage-presence
**Backend (`plant_kanban.py`).** Replace the "one area per job" bucketing with per-stage presences. For each in-flight job:
1. Group the job's **non-terminal** steps by `area_kind` (note: many steps can share an area, e.g. all wet-line steps roll into `plating` per the existing column map).
2. For each area, compute:
- `qty_here` = sum of `qty_at_step` across that area's non-terminal steps.
- `focus_step_id` = the most-actionable step in the area (in_progress > paused > ready > pending) — used for the tap target and the per-presence state.
3. **Emit a presence** for an area when `qty_here > 0` **or** any step in the area is in_progress/paused/ready (a started-but-drained stage still shows until finished).
4. **Exception presences:** a quality hold on N parts surfaces as a flagged presence ("🔴 N on hold") in the stage it is associated with. *Linkage detail* (hold→step/area vs. hold→job + area inference) is finalized in the plan after reading the hold model; fallback is to attach the held indicator to the job's furthest-along presence. Rework re-entering an earlier stage shows as a normal presence there (optionally flagged "rework"). Scrap is not a presence (counted in `qty_scrapped`, shown in job totals only).
**Payload schema.** `cards` becomes a dict keyed by composite `"{job_id}:{area}"`; `columns[].card_ids` lists composite keys; a split job lists one key in each occupied column. Each presence payload is the existing card payload **plus**: `area_kind`, `qty_here`, `job_qty`, `focus_step_id`, and a per-presence `card_state` / `state_chip` / `operator` / `step_name` derived from that area's focus step (not the job's global `active_step_id`). Reuse the existing helpers (`_state_chip`, `_compute_tags`, `_due_label`, `_icons`).
- The job-level `card_state` / `active_step_id` computes stay **unchanged** — they still drive server-side filters and KPI counts.
- **KPIs** dedupe by `job_id` (count distinct jobs, not presences).
- **mini_timeline** on every presence still shows the **whole-job** spread, so the big picture is visible from any stage.
**Frontend (`plant_kanban.xml`, `components/plant_card.js`).**
- The render loop (`t-foreach columns → card_ids → FpPlantCard`) is **unchanged**`t-key="card_id"` already works with composite keys.
- `FpPlantCard` gains a "**{qty_here} of {job_qty}**" line; `onCardClick` passes `focus_step_id` into the workspace (the workspace already accepts `focus_step_id` — see the FP-STEP scan path in `plant_kanban.js`).
- `filteredCardIds` is unchanged (it filters on card payload fields; each presence carries the same searchable fields, so all presences of a matching job show).
**Unsplit invariant.** A job with all parts at one stage produces exactly one presence → one card in one column → byte-for-byte identical to today. Multi-card behaviour only activates on an actual split.
### C. Operator flow — easy advance
**Primary action: "Send to [next stage] →".** Surfaced on the stage presence (card/workspace). Opens a slim confirm pre-set to:
- `to_step` = next step in recipe sequence (no guessing).
- `qty` = all parked here (`qty_at_step`); adjustable with **± steppers + an "All" preset** — the keyboard never opens for the common case.
- `transfer_type` = `step` (hidden); `to_location` = `global` (hidden behind **More options**); `to_tank` = recipe default (existing behaviour).
- Compliance prompts render only when the recipe author marked them (unchanged), using pickers, not free text.
**Racked parts:** when parts are on racks, advancing is **rack-granular** — tap the rack(s) to send, moving that rack's count atomically via the existing **Move Rack** flow. No quantity typed at all.
**Exceptions get their own intent-named buttons** (replacing the Transfer Type dropdown for everyday use):
- **Hold** — pick qty + reason from a picker → reuses the existing hold composer → the subset becomes the tracked held group; the rest stay put.
- **Scrap** — qty + reason → counted in `qty_scrapped`.
- **Rework** — qty + destination earlier stage → a `transfer_type='rework'` move.
The full generic Move dialog remains available behind "More options" — we slim the default path, we don't delete capability.
### D. State machine — the invisible "light-up"
Three small, additive behaviours so operators never manage stage state manually:
1. **Auto-ready on arrival.** In `_do_move_parts_commit` (and `_do_move_rack_commit`): after the move + counter advance, if `to_step.state == 'pending'`, set it to `ready`. Never downgrade a step. Result: the receiving operator's column immediately shows a "{qty} of {job_qty} · Ready to start" card with no action by anyone.
2. **Qty-aware predecessor gate.** A step that has **real parts parked** (an incoming move with `from_step_id != step` and `qty_moved > 0`) is startable regardless of whether upstream steps are fully done. Applied consistently in `_fp_should_block_predecessors` (used by both `button_start` and the Move dialog's `_blockers_for_move`). Rationale: once parts physically arrive, the predecessor lock is moot.
3. **Auto-finish the source on drain-to-zero.** When a move drains `from_step.qty_at_step` to 0 and the step is in_progress with no remaining work, finish it via the existing finish path (generalize the `action_complete_one_to_next` drain→finish pattern to bulk moves). One fewer tap.
Deliberately **no auto-start** of the receiving step — `button_start` stays an explicit tap because it begins the labour timer (keeps cost/time accurate and avoids the phantom-timer problem the S16 cron already fights).
**Correctness fix.** The move preview currently derives availability from `from_step.qty_done from_step.qty_scrapped`; change it to read `qty_at_step` (the live parked count shown on the card) so the pre-filled number **always matches what the operator sees**.
### E. Close — wait to reconverge (Option B)
The close/cert/ship/invoice lifecycle is **unchanged**:
- `button_mark_done` gates, `_fp_create_certificates` (one CoC), `_fp_create_delivery` (one delivery), and the `awaiting_cert`/`awaiting_ship` transitions stay as-is.
- The diverged subset keeps the job open **for free**: with parts in hold/rework, `qty_done + qty_scrapped` cannot equal `qty`, so the existing reconciliation gate simply won't let the job close until those parts resolve (reworked → done, or scrapped → counted). This is the correct behaviour, already enforced.
- Normal jobs reconverge at Final Inspection (all parts back in one count) and close once.
**Hardening (additive).** At close, `qty_done` auto-fill should derive from the quantity that actually **completed the final runnable step** (via the last step's `qty_at_step_finish` / move chain), not assume `job.qty`. Keep the existing reconciliation gate — just feed it an honest number when parts fanned out.
### F. Reconciliation invariant
At any time: `job.qty = (parts parked across all stages) + (on hold) + (in rework) + (scrapped) + (completed/shipped)`. The board surfaces the first three as presences; `qty_scrapped` and the final count feed the existing close gate `qty_done + qty_scrapped == qty`.
---
## Blast radius
**Changes**
- `plant_kanban.py` — per-stage presence rendering + composite keys + KPI dedupe (localized to `_render_card` + the bucketing loop; reuses all existing chip/tag/icon helpers).
- `components/plant_card.js` + `plant_kanban.xml` — "{qty} of {job_qty}" line; tap passes `focus_step_id` (~a few lines).
- `move_parts_dialog.{js,xml}` — slim "Advance" default (steppers, "All", hidden advanced fields); Hold/Scrap/Rework intent buttons; full dialog behind "More options".
- `move_controller.py` — auto-ready on arrival; auto-finish on drain; preview availability/pre-fill from `qty_at_step`.
- `fp_job_step.py` — qty-aware predecessor gate; bulk drain→finish helper.
- `fp_job.py``qty_done` derivation at close (hardening only).
**Does NOT change**
- The quantity model (`qty_at_step`, `fp.job.step.move`).
- The OWL component tree (FpTabletLock → header → board → columns → FpPlantCard → FpMiniTimeline), polling, filters, search, station pairing, QR.
- The Job Workspace entry contract (`focus_step_id` already supported).
- Holds / certs / bake windows / QC.
- The close → cert → ship → invoice lifecycle (`button_mark_done`, `_fp_create_certificates`, `_fp_create_delivery`, awaiting_cert/awaiting_ship, order "ship together").
---
## Edge cases
- **No recipe / no steps:** orphan fallback unchanged (Receiving column).
- **Multiple steps in one area** (wet-line → plating): presence aggregates `qty_here` across them; `focus_step_id` is the most-actionable.
- **First-step seed:** before any move, the whole qty sits at the first stage → one presence = today's single card.
- **Recombination:** 30 + 20 both reaching Baking simply read as `qty_here = 50` at that stage (fluid quantities merge automatically).
- **Held parts with no step linkage:** held indicator attaches to the job's furthest-along presence (plan-time detail).
- **Search match:** every presence of a matching job shows across its columns (each carries the same searchable fields).
## Testing
- **Unit:** `qty_at_step` across a multi-stage split; qty-aware predecessor (parts-present → startable); auto-ready pending→ready on move; auto-finish source on drain-to-zero; `qty_done` derivation at close; reconciliation gate still fires with held parts.
- **Integration:** board emits N presences for a split job and exactly 1 for an unsplit job; KPIs dedupe by job; tapping a presence opens the workspace on the right step.
- **Persona walk:** operator finishes a subset at Plating → taps Send → receiver sees a "Ready" card at Baking with the right qty; 5 parts go on Hold → job stays open until resolved → reworked/closed → one cert, one delivery.
## Deployment
Per-module `__manifest__.py` version bumps so the SCSS/asset bundle busts and any data reloads. Entech is native Odoo (LXC 111, DB `admin`) — standard `-u fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating` upgrade. No data migration required (no new persistent fields; additive behaviours only).