Merge remote-tracking branch 'origin/main'
# Conflicts: # fusion_plating/fusion_plating/__manifest__.py # fusion_plating/fusion_plating_jobs/__manifest__.py # fusion_plating/fusion_plating_jobs/models/fp_job_step.py # fusion_plating/fusion_plating_shopfloor/__manifest__.py # fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
This commit is contained in:
@@ -630,8 +630,27 @@ De-Racking → Final inspection → Shipping`
|
||||
Columns are first-class — they always render in this exact order, never
|
||||
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
|
||||
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
|
||||
(stored) from `work_centre.area_kind` with a fallback to a step-kind
|
||||
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
|
||||
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`):
|
||||
`work_centre.area_kind` → else `recipe_node.kind_id.area_kind` (the
|
||||
`fp.step.kind` taxonomy is authoritative; the legacy `_STEP_KIND_TO_AREA`
|
||||
dict is gone) → else catch-all `'plating'`.
|
||||
|
||||
**Gating/"Ready for X" marker steps fall FORWARD (fixed 2026-06-02).** The
|
||||
`fp.step.kind` named *Gating* has `code='gating'` **and `area_kind='receiving'`**.
|
||||
A gating step is a non-physical "ready for the next stage" marker, so
|
||||
mapping it to Receiving made a *mid-recipe* gate snap the job's card back
|
||||
to the first column (Racking → "Ready for processing" jumped to Receiving,
|
||||
so the job looked like it vanished). `_compute_area_kind` therefore detects
|
||||
a gating step via the **stable `kind_id.code == 'gating'`** (never the
|
||||
display name) and resolves its column to the **next non-gating step's** raw
|
||||
area (so "Ready for processing" before plating shows in the **Plating**
|
||||
column); if nothing real follows, it falls back to the last real stage.
|
||||
Helpers: `_fp_is_gating_step`, `_fp_raw_area_kind` (own work_centre/kind
|
||||
only — no look-ahead, avoids recursion), `_fp_resolve_area_kind`. **NB:**
|
||||
`area_kind` is a STORED compute, so after changing this logic you must
|
||||
force-recompute existing rows (`env['fp.job.step'].search([])._compute_area_kind()`
|
||||
+ `flush_recordset(['area_kind'])` + commit) — a `-u`/restart alone leaves
|
||||
old values stale.
|
||||
|
||||
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
|
||||
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
|
||||
@@ -1832,3 +1851,57 @@ 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. **A presence is emitted ONLY where parts physically are (`qty_at_step > 0`, incl. the first-active seed) OR a step is `in_progress`/`paused` — NEVER for a merely `ready`/`pending` future step.** These recipes seed EVERY downstream step to `ready` at job creation (not `pending`), so keying presence off `ready` made one job show in every not-yet-started stage at once (WO-30061 bug, fixed 2026-06-02). The old single-card board masked this because `active_step_id` picked just one. Strict sequential progress falls out for free: the `qty_at_step` seed always sits on the lowest-sequence non-terminal step and advances as each completes — so don't add `ready` back to the presence condition.
|
||||
|
||||
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. **Two non-obvious traps in `_fp_try_autofinish_on_drain` (both fixed 2026-06-02):** (1) it must guard on a real **OUTGOING** move (`move_ids` to a different step, `qty_moved > 0`), NOT `_fp_has_real_incoming()` — the FIRST/seeded stage (e.g. Racking) is fed by the `qty_at_step` seed, has no incoming move, and so never auto-finished when all its parts were sent forward. (2) It is **best-effort and gated**: `button_finish` still runs the required-step-input / sign-off / contract-review gates, so a step with an unrecorded required input (e.g. Racking's "Count the Parts") will NOT auto-finish on drain — it stays `in_progress` with `qty_at_step=0` ("running, 0 here → finish me") until the operator records the input and finishes. That's correct (can't complete a step missing compliance data); don't try to force auto-finish past the gates.
|
||||
|
||||
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. **Second, distinct trap (fixed 2026-06-02): the Move dialog's `_blockers_for_move` predecessor check must only flag unfinished steps STRICTLY BETWEEN `from_step` and `to_step` (`from_step.sequence < s.sequence < to_step.sequence`), NOT all steps before `to_step`.** The original `s.sequence < to_step.sequence` filter counted the `from_step` itself (which is in-progress *by definition* when you advance partial parts out of it) as an "unfinished predecessor" of the destination — so EVERY partial advance to a not-yet-started next step showed a hard "Predecessor not done: \<from_step\>" blocker and greyed out SEND (hit on WO-30061). The between-only rule allows the immediate-next advance, still blocks skip-ahead moves over incomplete intermediate stages, and leaves backward (rework) moves unblocked (empty range).
|
||||
|
||||
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).
|
||||
|
||||
### Rollout fixes + open items (live operator testing, 2026-06-02)
|
||||
|
||||
Bugs that only real tablet testing surfaced (all fixed, deployed to entech, on main):
|
||||
- **Phantom future-stage cards** — a job showed in every not-yet-started `ready` stage. Presence keys off parked qty / `in_progress`, never `ready` (gotcha 1).
|
||||
- **Scan buttons** — camera button rendered two icons; "Scan Code" vs "Camera" was confusing. `QrScanner` keeps its single icon; now **"Scan QR"** (camera) + **"Enter Code"** (wedge/manual). Don't pass an emoji in the `QrScanner` label — it doubles the icon.
|
||||
- **Dark-mode invisible text** — `var(--bs-body-color)` / `var(--bs-secondary-color)` are UNDEFINED in Odoo's backend CSS → always fall back to the dark hex. Use inherit / translucent `rgba()` (see the Dark-mode SCSS section).
|
||||
- **Partial advance blocked by the from-step's own predecessor** — `_blockers_for_move` now blocks only steps STRICTLY BETWEEN from/to (gotcha 5).
|
||||
- **First/seeded stage never auto-finished on drain** — `_fp_try_autofinish_on_drain` guards on a real OUTGOING move, not incoming.
|
||||
- **Gating "Ready for X" steps zig-zagged the card back to Receiving** — gating steps fall FORWARD to the next real stage's column (see the Plant-View `area_kind` note).
|
||||
|
||||
Open / deferred (next session):
|
||||
- **Discoverability (not built):** show a "N here" qty badge on step rows + the count on the Send button; add a "✓ all sent — record inputs to finish" hint when a step is drained-to-0 but still has a pending required input (answers operators' "why is it still active?").
|
||||
- **Scrap / Rework as standalone intent buttons** — currently under the Move dialog's "More options"; only Hold has its own button.
|
||||
- **Automated tests NOT written** — modules need enterprise deps (can't install on local Community); validated via pyflakes/lxml + live odoo-shell verification on entech. A `bt_s*`-style battle test is the recommended next step.
|
||||
- **Plant-card status chips** read fine but bright in dark mode (deferred).
|
||||
|
||||
---
|
||||
|
||||
## Dark-mode SCSS gotchas — shop-floor dialogs/components (fixed 2026-06-02)
|
||||
|
||||
Operators reported invisible (dark-on-dark) text in the workspace + "Cannot Finish Step" dialog under Odoo dark mode. Root causes + the rules:
|
||||
|
||||
1. **Odoo's compiled backend CSS does NOT define the Bootstrap colour custom-properties — `var(--bs-body-color)`, `var(--bs-secondary-color)`, `var(--bs-tertiary-bg)`, `var(--bs-body-bg)` are REFERENCED but never DEFINED (verified 2026-06-02: 0 definitions for `--bs-body-color`/`--bs-secondary-color` in the live `web.assets_backend` text).** So **any `color: var(--bs-body-color, #hex)` resolves to the `#hex` fallback in BOTH light and dark mode** — a dark hex → invisible on a dark surface. (`var(--text-secondary, …)` is even worse — that var name is entirely made-up.) Odoo themes the backend via **runtime `[data-bs-theme="dark"]`** (Bootstrap 5.3) + SCSS literals, NOT via those CSS vars, and NOT via `prefers-color-scheme`. Do NOT colour custom text with `var(--bs-*)`. **Correct, verified options:**
|
||||
- **Inherit** — omit `color:` entirely so the element takes the dialog/page theme colour. Proven: the finish-block dialog's title + `.o_fp_finish_block_list` items have no colour and ARE readable in both modes; the `.o_fp_finish_block_msg` line was the ONLY broken one because it set `color: var(--bs-body-color,…)`. Removing that one line fixed it. This is the simplest fix for dialog/modal text.
|
||||
- **Translucent `rgba()` for tinted boxes** — e.g. `background: rgba(245,158,11,0.16)` (warning) / `rgba(128,128,128,0.12)` (neutral). Works over whatever the live theme background is. (`color-mix(…, var(--bs-body-bg))` does NOT work — `--bs-body-bg` is undefined, so the whole `color-mix` is invalid and dropped.)
|
||||
- **Explicit `[data-bs-theme="dark"] .my-class { color: … }`** override with literal hex when you genuinely need a different value per theme.
|
||||
- **Compile-time `$o-webclient-color-scheme == dark`** literals only work if the **dark bundle is actually served**; on entech the active mechanism is runtime `[data-bs-theme]`, so prefer inherit / rgba / `[data-bs-theme=dark]` selectors over the two-bundle approach for backend dialogs.
|
||||
|
||||
NOTE: ~33 muted-text usages across `job_workspace.scss` + 5 component stylesheets still use `var(--bs-secondary-color, #hex)` (undefined → dark hex). They're muted/secondary so less glaring, but technically wrong in dark mode — sweep them to one of the patterns above when touched.
|
||||
2. **Odoo's bootstrap does NOT define the Bootstrap 5.3 `--bs-{color}-bg-subtle` / `--bs-{color}-text-emphasis` family.** Verified by grepping `web/static/lib/bootstrap/scss/_root.scss`: `--bs-tertiary-bg` and `--bs-secondary-color` exist; `--bs-warning-bg-subtle`, `--bs-danger-bg-subtle`, `--bs-warning-text-emphasis` are MISSING. So `var(--bs-warning-bg-subtle, #fef3c7)` just yields the bright hex fallback — useless for dark mode. **For tinted status banners (warning/danger/info), use `color-mix` over the live theme bg instead:** `background-color: color-mix(in srgb, #f59e0b 14%, var(--bs-body-bg)); color: var(--bs-body-color);` — pale in light mode, dark-tinted in dark mode, readable in both, graceful-degrades to no-bg on ancient browsers. (`color-mix` works in `background-color` per the rule-8 note; keep it out of shorthands.) Solid accent elements (selected pills, priority dots) with `color: white` are fine as-is in both modes.
|
||||
3. **Confirmed-present, dark-aware Odoo vars to reach for:** `--bs-body-color` (primary text), `--bs-secondary-color` (muted text), `--bs-body-bg` / `--bs-tertiary-bg` (surfaces), `--bs-border-color`. The deliberate color-coded plant-card status chips (`_plant_card.scss` `.kind-*` / `.tag-*`) are light-bg + dark-text (readable in both modes, just bright on a dark card) — intentionally left as a color-coded set.
|
||||
|
||||
@@ -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).
|
||||
@@ -2074,11 +2074,27 @@ class FpJob(models.Model):
|
||||
# the operator reconciles by hand. Mirrors the receiving
|
||||
# `_update_job_qty_received` pattern: server fills the
|
||||
# obvious default, operator owns the edge cases.
|
||||
if (not job.qty_done and not job.qty_scrapped
|
||||
# Partial-order handling (2026-06-02): surface scrap that
|
||||
# was recorded through the Move log (transfer_type='scrap')
|
||||
# into qty_scrapped, so the reconciliation + cert qty stay
|
||||
# honest even when scrap was done from the tablet Move
|
||||
# dialog rather than the qty_scrapped field. Only when the
|
||||
# field hasn't been set by hand.
|
||||
scrap_moves = job._fp_scrapped_via_moves()
|
||||
if scrap_moves and not job.qty_scrapped:
|
||||
job.qty_scrapped = scrap_moves
|
||||
# Clean-close auto-fill: derive the good (done) count from
|
||||
# what physically came in minus scrap, instead of blindly
|
||||
# assuming the whole order completed (which over-counts when
|
||||
# parts were scrapped mid-line). Skips when the operator
|
||||
# already typed qty_done, or when visual rejects make the
|
||||
# split non-obvious — then the gate below makes them
|
||||
# reconcile by hand.
|
||||
if (not job.qty_done
|
||||
and not (job.qty_visual_inspection_rejects or 0)
|
||||
and job.qty_received
|
||||
and abs(job.qty_received - job.qty) < 0.0001):
|
||||
job.qty_done = job.qty
|
||||
job.qty_done = job.qty - (job.qty_scrapped or 0)
|
||||
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
|
||||
if abs(accounted - job.qty) > 0.0001:
|
||||
raise UserError(_(
|
||||
@@ -2439,6 +2455,19 @@ class FpJob(models.Model):
|
||||
fp_skip_step_gate=True,
|
||||
).button_mark_done()
|
||||
|
||||
def _fp_scrapped_via_moves(self):
|
||||
"""Total parts scrapped through the Move log (transfer_type=
|
||||
'scrap') for this job. Lets button_mark_done's reconciliation
|
||||
count scrap done via the tablet Move dialog, not just the
|
||||
qty_scrapped field (partial-order handling, 2026-06-02)."""
|
||||
self.ensure_one()
|
||||
Move = self.env['fp.job.step.move']
|
||||
moves = Move.sudo().search([
|
||||
('job_id', '=', self.id),
|
||||
('transfer_type', '=', 'scrap'),
|
||||
])
|
||||
return int(sum(m.qty_moved or 0 for m in moves))
|
||||
|
||||
def _fp_check_advance_post_shop(self):
|
||||
"""Auto-advance in_progress jobs whose recipe steps are all
|
||||
terminal. Called from fp.job.step.button_finish post-super().
|
||||
|
||||
@@ -54,12 +54,37 @@ class FpJobStep(models.Model):
|
||||
# leak permissive behaviour through a related-field None.
|
||||
if not self.job_id:
|
||||
return True
|
||||
# Partial-flow short-circuit (2026-06-02 partial-order handling).
|
||||
# Once REAL parts have physically arrived at this step (a move
|
||||
# parked them here), the predecessor lock is moot — the parts are
|
||||
# on the floor at this station, so the step is startable
|
||||
# regardless of whether upstream steps are fully done. This is
|
||||
# what lets a partial group "light up" the next stage while the
|
||||
# rest of the batch is still being processed upstream. Single
|
||||
# source of truth: every caller (can_start, blocker, button_start,
|
||||
# the Move dialog's _blockers_for_move) inherits this behaviour.
|
||||
if self._fp_has_real_incoming():
|
||||
return False
|
||||
recipe_seq = self.job_id.enforce_sequential
|
||||
if recipe_seq:
|
||||
return not self.parallel_start
|
||||
# Free-flow recipe — only the legacy per-step flag still gates.
|
||||
return bool(self.requires_predecessor_done)
|
||||
|
||||
def _fp_has_real_incoming(self):
|
||||
"""True when real parts have physically arrived at this step via
|
||||
a move — an incoming move from a DIFFERENT step with qty_moved > 0.
|
||||
|
||||
Distinct from the qty_at_step first-step seed (a notional UI hint
|
||||
with no backing move) and from self-loop measurement moves
|
||||
(from_step == to_step, used by the Record Inputs wizard). Mirrors
|
||||
the has_real_incoming test in core button_finish's qty gate.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return bool(self.incoming_move_ids.filtered(
|
||||
lambda m: m.from_step_id != self and (m.qty_moved or 0) > 0
|
||||
))
|
||||
|
||||
def _fp_has_unfinished_predecessors(self):
|
||||
"""True when an earlier-sequence step on the same job is not yet
|
||||
in a terminal state. Composes with _fp_should_block_predecessors
|
||||
@@ -86,6 +111,10 @@ class FpJobStep(models.Model):
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state',
|
||||
'job_id.step_ids.sequence',
|
||||
# Partial-flow: arriving parts clear the predecessor gate
|
||||
# (_fp_has_real_incoming), so can_start must recompute on move.
|
||||
'incoming_move_ids.qty_moved',
|
||||
'incoming_move_ids.from_step_id',
|
||||
)
|
||||
def _compute_can_start(self):
|
||||
for step in self:
|
||||
@@ -129,47 +158,87 @@ class FpJobStep(models.Model):
|
||||
'work_centre_id.area_kind',
|
||||
'recipe_node_id.kind_id.area_kind',
|
||||
'name',
|
||||
'recipe_node_id.kind_id.code',
|
||||
'sequence',
|
||||
'job_id.step_ids.sequence',
|
||||
'job_id.step_ids.name',
|
||||
'job_id.step_ids.work_centre_id.area_kind',
|
||||
'job_id.step_ids.recipe_node_id.kind_id.area_kind',
|
||||
'job_id.step_ids.recipe_node_id.kind_id.code',
|
||||
)
|
||||
def _compute_area_kind(self):
|
||||
"""Resolve the plant-view column this step belongs in.
|
||||
|
||||
Priority chain:
|
||||
1. name-based override for unambiguous de-rack / de-mask steps
|
||||
(2026-06-03): their recipe kind AND/OR work-centre is
|
||||
frequently wrong (tagged 'racking'/'mask', a shared station,
|
||||
or left blank), which scattered de-racking cards across the
|
||||
Racking / Masking / Plating columns. The operator-facing step
|
||||
NAME is unambiguous for these, so it wins OUTRIGHT — even over
|
||||
an explicit work-centre. Bake/oven steps that merely mention
|
||||
"de-rack" in their name are excluded so they stay in Baking.
|
||||
Priority chain (non-gating steps):
|
||||
1. step-NAME override for unambiguous de-rack / de-mask / bake
|
||||
steps (2026-06-03) — their recipe kind and/or work-centre is
|
||||
frequently wrong (tagged 'racking'/'mask', a shared station, or
|
||||
left blank), scattering cards across the Racking / Masking /
|
||||
Plating columns. The operator-facing NAME is unambiguous, so it
|
||||
wins OUTRIGHT — even over an explicit work-centre. Bake/oven
|
||||
steps that merely mention "de-rack" stay in Baking. See spec
|
||||
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
||||
2. work_centre.area_kind (explicit operator setup)
|
||||
3. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
|
||||
4. catch-all 'plating' (data integrity issue if we land here)
|
||||
|
||||
The kind taxonomy remains the source of truth for every area
|
||||
EXCEPT de-rack/de-mask (step 1). See spec
|
||||
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
||||
Gating/marker steps (kind `code == 'gating'` — the "Ready for X"
|
||||
steps) have NO physical location; the taxonomy maps them to
|
||||
'receiving', which made a mid-recipe gate snap the job's card back
|
||||
to the first column (Racking -> "Ready for processing" jumped to
|
||||
Receiving, so the job looked like it vanished — 2026-06-02). A
|
||||
gating step FALLS FORWARD to the next non-gating step's column
|
||||
(it's "ready for [that stage]"), keeping the card moving
|
||||
left->right. If nothing real follows, it falls back to the last
|
||||
real stage.
|
||||
"""
|
||||
for step in self:
|
||||
# 1. Name override (de-rack/de-mask -> De-Racking, bake/oven ->
|
||||
# Baking) — unambiguous; the authored kind / work-centre is
|
||||
# frequently wrong/blank for these. See _fp_area_from_step_name.
|
||||
name_area = self._fp_area_from_step_name(step.name)
|
||||
if name_area:
|
||||
step.area_kind = name_area
|
||||
continue
|
||||
# 2. Explicit work_centre setup
|
||||
if step.work_centre_id and step.work_centre_id.area_kind:
|
||||
step.area_kind = step.work_centre_id.area_kind
|
||||
continue
|
||||
# 3. Kind taxonomy
|
||||
node = step.recipe_node_id
|
||||
if node and node.kind_id and node.kind_id.area_kind:
|
||||
step.area_kind = node.kind_id.area_kind
|
||||
continue
|
||||
# 4. Catch-all — only reached for orphaned steps (no
|
||||
# work_centre AND no recipe_node).
|
||||
step.area_kind = 'plating'
|
||||
step.area_kind = step._fp_resolve_area_kind()
|
||||
|
||||
def _fp_raw_area_kind(self):
|
||||
"""Area from this step's OWN name / work_centre / kind only — no
|
||||
look-ahead and no dependence on the computed `area_kind` field (so
|
||||
the gating fall-forward below can't recurse).
|
||||
|
||||
Name override (de-rack/de-mask -> De-Racking, bake/oven -> Baking)
|
||||
wins OUTRIGHT: the authored kind / work-centre is frequently
|
||||
wrong/blank for these. See _fp_area_from_step_name."""
|
||||
self.ensure_one()
|
||||
name_area = self._fp_area_from_step_name(self.name)
|
||||
if name_area:
|
||||
return name_area
|
||||
if self.work_centre_id and self.work_centre_id.area_kind:
|
||||
return self.work_centre_id.area_kind
|
||||
node = self.recipe_node_id
|
||||
if node and node.kind_id and node.kind_id.area_kind:
|
||||
return node.kind_id.area_kind
|
||||
return 'plating'
|
||||
|
||||
def _fp_is_gating_step(self):
|
||||
"""True for a 'Ready for X' marker step (no physical location).
|
||||
Detected via the STABLE kind code, never the display name."""
|
||||
self.ensure_one()
|
||||
node = self.recipe_node_id
|
||||
return bool(node and node.kind_id and node.kind_id.code == 'gating')
|
||||
|
||||
def _fp_resolve_area_kind(self):
|
||||
"""Column for this step: its own raw area, EXCEPT a gating marker
|
||||
falls forward to the next non-gating step's column."""
|
||||
self.ensure_one()
|
||||
if not self._fp_is_gating_step():
|
||||
return self._fp_raw_area_kind()
|
||||
siblings = self.job_id.step_ids
|
||||
later = siblings.filtered(
|
||||
lambda s: s.sequence > self.sequence and not s._fp_is_gating_step()
|
||||
).sorted('sequence')
|
||||
if later:
|
||||
return later[0]._fp_raw_area_kind()
|
||||
earlier = siblings.filtered(
|
||||
lambda s: s.sequence < self.sequence and not s._fp_is_gating_step()
|
||||
).sorted('sequence')
|
||||
if earlier:
|
||||
return earlier[-1]._fp_raw_area_kind()
|
||||
return self._fp_raw_area_kind()
|
||||
|
||||
@staticmethod
|
||||
def _fp_area_from_step_name(name):
|
||||
@@ -266,6 +335,9 @@ class FpJobStep(models.Model):
|
||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
||||
# Partial-flow: arriving parts clear the predecessor gate.
|
||||
'incoming_move_ids.qty_moved',
|
||||
'incoming_move_ids.from_step_id',
|
||||
)
|
||||
def _compute_blocker(self):
|
||||
for step in self:
|
||||
@@ -701,6 +773,52 @@ class FpJobStep(models.Model):
|
||||
).sorted('sequence')
|
||||
return candidates[:1] or self.env['fp.job.step']
|
||||
|
||||
def _fp_try_autofinish_on_drain(self):
|
||||
"""Best-effort auto-finish when a step has drained to zero parked
|
||||
parts (2026-06-02 partial-order handling).
|
||||
|
||||
Called by the Move controller after a bulk move commits. When the
|
||||
last parts leave an in_progress step it should close itself — one
|
||||
fewer tap for the operator. But finishing runs the full gate chain
|
||||
(required inputs, sign-off, contract review, receiving, and the
|
||||
post-shop close gates on the last step). If any gate isn't
|
||||
satisfied we must NOT fail the move that already succeeded — so we
|
||||
swallow the UserError and leave the step in_progress for the
|
||||
operator to finish manually (the board will show it "running, 0
|
||||
here", which reads as "finish me").
|
||||
|
||||
Fires for any step that actually moved parts OUT and drained to
|
||||
zero — INCLUDING the first/seeded stage (its qty comes from the
|
||||
qty_at_step seed, not a real incoming move). Returns True if the
|
||||
step finished.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
return False
|
||||
# qty_at_step is a non-stored compute off the move rows — force a
|
||||
# re-read so we see the just-committed outgoing move.
|
||||
self.invalidate_recordset(['qty_at_step'])
|
||||
if self.qty_at_step != 0:
|
||||
return False
|
||||
# Guard: only auto-finish a step that genuinely moved parts OUT (a
|
||||
# real outgoing move, excluding self-loop measurement moves). The
|
||||
# earlier guard checked _fp_has_real_incoming() — the WRONG
|
||||
# direction: the first/seeded stage (e.g. Racking) is fed by the
|
||||
# qty_at_step seed, not an incoming move, so it never auto-finished
|
||||
# when all its parts were sent forward. Checking for a real
|
||||
# OUTGOING move covers the seeded first stage correctly.
|
||||
if not self.move_ids.filtered(
|
||||
lambda m: m.to_step_id != self and (m.qty_moved or 0) > 0):
|
||||
return False
|
||||
try:
|
||||
self.button_finish()
|
||||
return True
|
||||
except UserError:
|
||||
# Gates still pending (missing prompts / sign-off / etc.) —
|
||||
# leave the step in_progress for a manual finish. The move
|
||||
# itself stands.
|
||||
return False
|
||||
|
||||
def _fp_has_uncaptured_step_inputs(self):
|
||||
"""True when the recipe step has REQUIRED step_input prompts
|
||||
whose values haven't been recorded yet.
|
||||
|
||||
@@ -124,8 +124,18 @@ class FpTabletMoveController(http.Controller):
|
||||
hasattr(to_step, '_fp_should_block_predecessors')
|
||||
and to_step._fp_should_block_predecessors()
|
||||
):
|
||||
# Partial-flow (2026-06-02): only an unfinished step STRICTLY
|
||||
# BETWEEN from_step and to_step blocks the move (you'd be skipping
|
||||
# an incomplete intermediate stage). The from_step itself is
|
||||
# in-progress BY DEFINITION when advancing partial parts out of
|
||||
# it — counting it (or any earlier step) as an "unfinished
|
||||
# predecessor" blocked every partial advance to a not-yet-started
|
||||
# next step. Steps before from_step are irrelevant: the parts
|
||||
# being moved are physically at from_step, ready for the next
|
||||
# stage. Backward moves (rework: from > to) yield an empty range
|
||||
# and are never predecessor-blocked.
|
||||
unfinished = to_step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence < to_step.sequence
|
||||
lambda s: from_step.sequence < s.sequence < to_step.sequence
|
||||
and s.state not in ('done', 'skipped', 'cancelled')
|
||||
)
|
||||
if unfinished:
|
||||
@@ -147,7 +157,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 +201,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 +229,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 +316,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 +380,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 +390,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)}
|
||||
|
||||
@@ -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,109 @@ 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)
|
||||
# A stage shows ONLY where parts physically are (qty_here > 0 —
|
||||
# which includes the first-active step's qty_at_step seed) OR where
|
||||
# a step is actively being worked (in_progress / paused — e.g.
|
||||
# drained to zero but not yet finished). A merely `ready` / `pending`
|
||||
# step with NO parts is a FUTURE stage and must NOT show — otherwise
|
||||
# the job appears in every not-yet-started step at once (these
|
||||
# recipes seed all downstream steps to `ready`, so 6 ready steps =
|
||||
# 6 phantom cards; bug on WO-30061). Strict sequential progress
|
||||
# falls out for free because the qty_at_step seed always sits on the
|
||||
# lowest-sequence non-terminal step and advances as each completes.
|
||||
being_worked = any(
|
||||
s.state in ('in_progress', 'paused') for s in steps
|
||||
)
|
||||
if qty_here > 0 or being_worked:
|
||||
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 +371,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 +383,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 +401,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,
|
||||
|
||||
@@ -90,6 +90,11 @@ class FpWorkspaceController(http.Controller):
|
||||
# Drives the embedded rack-split panel inside this step's row.
|
||||
'is_racking': step.area_kind == 'racking',
|
||||
'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 '',
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,14 @@ import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||
import { RackingPanel } from "./components/racking_panel";
|
||||
import { FpMovePartsDialog } from "./move_parts_dialog";
|
||||
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
||||
import { FileModel } from "@web/core/file_viewer/file_model";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
@@ -248,7 +249,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",
|
||||
@@ -263,6 +278,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({
|
||||
@@ -304,6 +321,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +504,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@ $_gate-text-hex: #b06600;
|
||||
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
|
||||
.o_fp_gate_body { flex: 1; }
|
||||
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
|
||||
.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; }
|
||||
.o_fp_gate_reason { color: var(--bs-secondary-color, #666); font-size: 0.78rem; margin-top: 0.1rem; }
|
||||
.o_fp_gate_jump { flex-shrink: 0; }
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
.o_fp_hc_row label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
}
|
||||
|
||||
@@ -34,18 +34,18 @@ $_kc-hover-hex: #f5f5f7;
|
||||
}
|
||||
|
||||
.o_fp_kcard_h2 {
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_qty {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 0.7rem; color: var(--text-secondary, #777);
|
||||
font-size: 0.7rem; color: var(--bs-secondary-color, #777);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.o_fp_kcard_due { color: var(--text-secondary, #999); }
|
||||
.o_fp_kcard_due { color: var(--bs-secondary-color, #999); }
|
||||
|
||||
.o_fp_kcard_bar {
|
||||
height: 4px; background: rgba(0,0,0,0.08);
|
||||
@@ -74,7 +74,7 @@ $_kc-hover-hex: #f5f5f7;
|
||||
}
|
||||
|
||||
.o_fp_kcard_wc {
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ $_pin-dot-fill-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
|
||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
|
||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--bs-secondary-color, #666); text-align: center; }
|
||||
|
||||
.o_fp_pin_dots {
|
||||
display: flex;
|
||||
@@ -83,8 +83,8 @@ $_pin-dot-fill-hex: #1d1d1f;
|
||||
&:disabled { opacity: 0.5; cursor: wait; }
|
||||
}
|
||||
|
||||
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
|
||||
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
|
||||
|
||||
@keyframes o_fp_pin_shake_kf {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,7 +21,7 @@ $_sig-canvas-border-hex: #d8dadd;
|
||||
|
||||
.o_fp_sig_ctx {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
}
|
||||
|
||||
.o_fp_sig_canvas {
|
||||
@@ -36,6 +36,6 @@ $_sig-canvas-border-hex: #d8dadd;
|
||||
|
||||
.o_fp_sig_hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_loading {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
|
||||
> div { margin-top: 0.6rem; }
|
||||
}
|
||||
@@ -87,8 +87,8 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_ws_wo { font-weight: 700; font-size: 1.3rem; letter-spacing: 0.01em; }
|
||||
.o_fp_ws_dot { color: var(--text-secondary, #999); }
|
||||
.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); font-size: 0.95rem; }
|
||||
.o_fp_ws_dot { color: var(--bs-secondary-color, #999); }
|
||||
.o_fp_ws_cust, .o_fp_ws_part { color: var(--bs-secondary-color, #555); font-size: 0.95rem; }
|
||||
|
||||
.o_fp_ws_pill {
|
||||
background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
|
||||
@@ -97,7 +97,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
color: var(--bs-secondary-color, #555);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_bar_label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--bs-secondary-color, #888);
|
||||
margin-top: 0.35rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -256,7 +256,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
|
||||
> div { margin-top: 0.5rem; }
|
||||
}
|
||||
@@ -289,9 +289,9 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
|
||||
.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
|
||||
.o_fp_ws_step_num { color: var(--bs-secondary-color, #999); font-size: 0.78rem; min-width: 50px; }
|
||||
.o_fp_ws_step_name { font-weight: 600; }
|
||||
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
|
||||
.o_fp_ws_step_meta { color: var(--bs-secondary-color, #999); font-size: 0.78rem; margin-left: auto; }
|
||||
|
||||
.o_fp_ws_step_badge {
|
||||
background: #0071e3;
|
||||
@@ -314,7 +314,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
|
||||
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--bs-secondary-color, #555); font-style: italic; }
|
||||
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||
|
||||
// ---- Masking reference tiles (tap → full-screen FileViewer) -----------
|
||||
@@ -402,7 +402,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
|
||||
.o_fp_ws_step_excluded {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--bs-secondary-color, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary, #777);
|
||||
color: var(--bs-secondary-color, #777);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
}
|
||||
@@ -464,14 +464,14 @@ $_ws-text-hex: #1d1d1f;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #777);
|
||||
color: var(--bs-secondary-color, #777);
|
||||
}
|
||||
|
||||
.o_fp_ws_note .author { font-weight: 600; }
|
||||
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
|
||||
.o_fp_ws_note .body { color: var(--bs-secondary-color, #555); margin-top: 0.15rem; }
|
||||
|
||||
.o_fp_ws_empty_small {
|
||||
color: var(--text-secondary, #999);
|
||||
color: var(--bs-secondary-color, #999);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -574,7 +574,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #777);
|
||||
color: var(--bs-secondary-color, #777);
|
||||
}
|
||||
|
||||
.o_fp_ws_ship_fields {
|
||||
@@ -645,8 +645,8 @@ $_ws-text-hex: #1d1d1f;
|
||||
.o_fp_ws_rcv_status {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #78350f;
|
||||
background-color: color-mix(in srgb, #f59e0b 18%, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
@@ -664,7 +664,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #555);
|
||||
color: var(--bs-secondary-color, #555);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -725,7 +725,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -794,7 +794,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -840,7 +840,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_photos {
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--bs-secondary-color, #666);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -876,7 +876,7 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.o_fp_dmg_label { font-weight: 600; color: var(--text-secondary, #555); }
|
||||
.o_fp_dmg_label { font-weight: 600; color: var(--bs-secondary-color, #555); }
|
||||
.o_fp_dmg_req { color: #dc2626; }
|
||||
|
||||
.o_fp_dmg_pills {
|
||||
@@ -980,8 +980,8 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_timer_over {
|
||||
background: #fee2e2;
|
||||
color: #7f1d1d;
|
||||
background-color: color-mix(in srgb, #ef4444 16%, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -1002,17 +1002,25 @@ $_ws-text-hex: #1d1d1f;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// NOTE: Odoo's backend CSS does NOT define --bs-body-color /
|
||||
// --bs-secondary-color / --bs-*-bg as custom properties (verified: 0
|
||||
// definitions in the compiled bundle — they're SCSS literals + two
|
||||
// bundles + [data-bs-theme]). So var(--bs-body-color, #hex) ALWAYS
|
||||
// resolves to the dark #hex fallback, in light AND dark mode. The fix
|
||||
// for dialog text is to INHERIT the modal's theme-correct colour (the
|
||||
// dialog title and the "Count the Parts" list items do exactly this and
|
||||
// are readable in both modes). Tinted boxes use translucent rgba() so
|
||||
// they work over whatever the live theme background is.
|
||||
.o_fp_finish_block_step {
|
||||
font-size: 1.1rem;
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
background-color: rgba(245, 158, 11, 0.16);
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_msg {
|
||||
color: var(--text-secondary, #333);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_list {
|
||||
@@ -1027,9 +1035,9 @@ $_ws-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_action_note {
|
||||
color: var(--text-secondary, #555);
|
||||
// Inherit text colour; translucent neutral box works in both themes.
|
||||
font-style: italic;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #f3f4f6;
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
}
|
||||
}
|
||||
.toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -86,8 +88,10 @@
|
||||
cursor: pointer;
|
||||
color: $plant-text;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
i { font-size: 15px; line-height: 1; }
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||
@@ -98,6 +102,24 @@
|
||||
color: #5e4400;
|
||||
font-weight: 700;
|
||||
}
|
||||
// Scan pair — matched look. "Scan QR" (camera, the primary way to
|
||||
// scan a printed job sticker) is accent-filled so it stands out;
|
||||
// "Enter Code" (manual / hardware scanner-gun) is the accent-tinted
|
||||
// secondary. Matched FA icons (fa-qrcode / fa-keyboard-o), no emoji.
|
||||
&.o_fp_qr_btn {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-color: #1d4ed8;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
i { color: #fff; }
|
||||
&:hover { box-shadow: 0 3px 8px rgba(29, 78, 216, 0.32); }
|
||||
}
|
||||
&.scan-alt {
|
||||
background: linear-gradient(135deg, $plant-mine-bg 0%, $plant-card-bg 100%);
|
||||
border-color: $plant-mine-border;
|
||||
font-weight: 600;
|
||||
i { color: #1d4ed8; }
|
||||
}
|
||||
}
|
||||
|
||||
// 8 tiles — Work Orders, At My Station, Bakes Due, On Hold,
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
<!-- 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 -->
|
||||
<div class="card-chips">
|
||||
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
|
||||
|
||||
@@ -2,75 +2,48 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<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_field">
|
||||
<label>Part Count</label>
|
||||
<input type="number" t-model.number="state.qty"
|
||||
t-att-min="1" t-att-max="state.qtyAvailable"/>
|
||||
<span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
|
||||
<!-- Destination banner — operator sees exactly where parts go,
|
||||
nothing to guess. -->
|
||||
<div class="o_fp_move_route">
|
||||
<span class="route-from" t-esc="state.fromStep.name"/>
|
||||
<span class="route-arrow"> → </span>
|
||||
<span class="route-to" t-esc="state.toStep.name"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>From Node</label>
|
||||
<span t-esc="state.fromStep.name"/>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field" t-if="state.fromStep.tank_name">
|
||||
<label>From Station</label>
|
||||
<span t-esc="state.fromStep.tank_name"/>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>Transfer Type</label>
|
||||
<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/>
|
||||
<!-- Qty stepper — no keyboard. Defaults to all parked here. -->
|
||||
<div class="o_fp_move_qty">
|
||||
<label>How many to send?</label>
|
||||
<div class="o_fp_qty_stepper">
|
||||
<button class="qty-btn" t-on-click="decQty"
|
||||
t-att-disabled="state.qty <= 1">−</button>
|
||||
<span class="qty-value" t-esc="state.qty"/>
|
||||
<button class="qty-btn" t-on-click="incQty"
|
||||
t-att-disabled="state.qty >= state.qtyAvailable">+</button>
|
||||
<button class="qty-all" t-on-click="setQtyAll">
|
||||
All (<t t-esc="state.qtyAvailable"/>)
|
||||
</button>
|
||||
</div>
|
||||
<span class="o_fp_qty_hint"><t t-esc="state.qtyAvailable"/> parked here</span>
|
||||
</div>
|
||||
|
||||
<!-- To Station (tank) — only when the recipe offers a choice -->
|
||||
<div class="o_fp_move_field"
|
||||
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
||||
<label>To Station</label>
|
||||
<select t-model.number="state.toTankId">
|
||||
<t t-foreach="state.toStep.tank_options"
|
||||
t-as="tk" t-key="tk.id">
|
||||
<t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
|
||||
<option t-att-value="tk.id"><t t-esc="tk.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
<span/>
|
||||
</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>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_compliance_prompts"
|
||||
t-if="state.transitionPrompts.length">
|
||||
<h5>Compliance Prompts</h5>
|
||||
<!-- Compliance prompts — only when the recipe author required
|
||||
them. Pickers/checkboxes, minimal free text. -->
|
||||
<div class="o_fp_compliance_prompts" t-if="state.transitionPrompts.length">
|
||||
<h5>Required before sending</h5>
|
||||
<t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
|
||||
<div class="o_fp_move_field">
|
||||
<label>
|
||||
@@ -94,13 +67,12 @@
|
||||
</t>
|
||||
</select>
|
||||
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
|
||||
<span t-else=""/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Blockers — inline resolve where possible -->
|
||||
<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">
|
||||
<div class="o_fp_blocker_row"
|
||||
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
|
||||
@@ -114,6 +86,39 @@
|
||||
</div>
|
||||
</t>
|
||||
</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 t-if="state.loading">Loading…</div>
|
||||
@@ -126,7 +131,7 @@
|
||||
t-att-disabled="!canCommit"
|
||||
t-att-title="blockerTooltip"
|
||||
t-on-click="onCommit">
|
||||
MOVE (<t t-esc="state.qty"/>)
|
||||
SEND (<t t-esc="state.qty"/>)
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
|
||||
@@ -23,13 +23,18 @@
|
||||
<button t-att-class="modeClass('manager')"
|
||||
t-on-click="() => this.setMode('manager')">Manager</button>
|
||||
</div>
|
||||
<!-- Text/wedge scan drawer toggle. Camera path
|
||||
is the QrScanner inline below — it
|
||||
opens its own modal + decoder. -->
|
||||
<button class="toolbar-btn"
|
||||
t-att-class="state.showScan ? 'toolbar-btn active' : 'toolbar-btn'"
|
||||
t-on-click="toggleScan">⌨️ Scan Code</button>
|
||||
<QrScanner cssClass="'toolbar-btn'" label="'📷 Camera'"/>
|
||||
<!-- "Scan QR" = the QrScanner camera path (the
|
||||
primary way to scan a printed job sticker).
|
||||
The component renders its own fa-qrcode
|
||||
icon, so the label must be plain text — an
|
||||
emoji here would double up the icon.
|
||||
"Enter Code" = the manual / hardware-scanner-
|
||||
gun text drawer (a wedge gun types the code;
|
||||
no camera). -->
|
||||
<QrScanner cssClass="'toolbar-btn'" label="'Scan QR'"/>
|
||||
<button class="toolbar-btn scan-alt"
|
||||
t-att-class="state.showScan ? 'active' : ''"
|
||||
t-on-click="toggleScan"><i class="fa fa-keyboard-o me-1"/>Enter Code</button>
|
||||
<button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user