Compare commits
8 Commits
005daade55
...
a60506a645
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60506a645 | ||
|
|
8b9b4d60ad | ||
|
|
a90eace4d0 | ||
|
|
7c2ae84e32 | ||
|
|
63d692b322 | ||
|
|
1a3ca8704e | ||
|
|
d6ebcb6233 | ||
|
|
48805b5988 |
@@ -228,6 +228,8 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
|
||||
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
|
||||
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
|
||||
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
|
||||
20. **OWL templates expose `Math` but NOT `String` / `Number` / `Array` / `Object` / `Boolean` / `JSON` / `parseInt` / `parseFloat`**: writing `t-on-click="() => this._press(String(d))"` (or similar coercion inside any template expression) throws `Uncaught TypeError: v2 is not a function` at click time — `v2` is OWL's compiled reference to a global that doesn't exist in template scope. The click handler dies before its body runs, so the bug looks like "nothing happens when I press" (no error in the UI, only DevTools shows the trace). **Fixes, in order of preference**: (a) eliminate the coercion entirely — store data in the right type up front, e.g. `t-foreach="['1','2','3']"` instead of `[1,2,3]` so `d` is already a string. (b) Use a JS-side coercion: pass the raw value to the handler and call `String(digit)` inside the component method. (c) Use a pure-expression workaround like string concatenation: `'' + d` does work because `+` is an operator, not a function. **Do NOT try to monkey-patch `String` onto the component (e.g. `this.String = String`) or onto `env` — leaks the global into every component and is fragile across OWL upgrades.** Bit us 2026-05-23 on `pin_pad.xml` — operators couldn't tap PIN digits at all because the click handler died on `String(d)`; the SCSS, reactivity, and `_press` method were all fine, the template scope was the entire bug. Same trap applies to OWL templates anywhere in the codebase: `move_parts_dialog.xml`, `manager_dashboard.xml`, `fp_record_inputs_dialog.xml`, etc. — grep all `t-on-click`, `t-att-*`, and `t-out` expressions for `String(`, `Number(`, `Array(`, `parseInt(`, `parseFloat(`, `JSON.` before merging.
|
||||
21. **`ir.actions.act_window_close` is a no-op when the current action was opened with `target: "current"`**: replacing the current action wipes the breadcrumb backstack, so there's nothing to close back to. The user clicks "Back" and nothing happens (no error, no navigation). This bites every OWL client-action surface that calls another client action via `doAction({..., target: "current"})` — the destination has no way to return to the source. **Fix pattern for "Back" buttons in OWL client actions**: navigate EXPLICITLY to the landing/parent action by tag, e.g. `this.action.doAction({ type: "ir.actions.client", tag: "fp_shopfloor_landing", target: "current" })` — works regardless of how the action was reached (kanban tap, QR scan, smart button, direct URL). **Do NOT rely on `act_window_close`, `history.back()`, or `this.env.config.breadcrumbs`** — all three are unreliable across navigation paths. Bit us 2026-05-23 on the Job Workspace Back button after the kanban opened the workspace with `target: "current"`. The same pattern applies to every other "Back" button in shopfloor / manager / portal OWL surfaces — explicit destination via `tag:` is the only robust answer.
|
||||
|
||||
## Naming
|
||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||
@@ -418,6 +420,107 @@ Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs
|
||||
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
|
||||
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
|
||||
|
||||
## Shop Floor — Plant View kanban (2026-05-23 redesign)
|
||||
|
||||
**Default Shop Floor surface** for new installs (gated by feature flag
|
||||
`ir.config_parameter['fusion_plating_shopfloor.layout']`, values `legacy`
|
||||
or `v2`). Legacy per-step kanban (`fp_shopfloor_landing`) remains
|
||||
accessible by flipping the flag back to `legacy` in Settings → Fusion
|
||||
Plating.
|
||||
|
||||
**Why redesign:** the per-step kanban produced one card per recipe step
|
||||
per column, so a 14-step recipe spawned 9+ cards for ONE job across the
|
||||
board. With 17 active jobs the board showed 100+ duplicate cards across
|
||||
narrow columns. The new design is **one card per `fp.job`** at the
|
||||
**department level** — recipe step count no longer drives layout width.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md`
|
||||
**Plan:** `docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md`
|
||||
|
||||
### Layout — 9 fixed columns in process sequence
|
||||
|
||||
`Receiving → Masking → Blasting → Racking → Plating → Baking →
|
||||
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`).
|
||||
|
||||
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
|
||||
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
|
||||
Drying) roll up into the **Plating** column. The tank chip on the card
|
||||
distinguishes them.
|
||||
|
||||
**Spec D4:** De-Masking folds into De-Racking (no separate column).
|
||||
|
||||
**Spec D5:** Contract Review (paperwork) cards live in Receiving with a
|
||||
purple "📋 QA-005" chip — they're admin gates, not physical work.
|
||||
|
||||
### Card state catalog — 13 mutually-exclusive states
|
||||
|
||||
`fp.job.card_state` is a stored Char computed in `_compute_card_state`
|
||||
(see `fusion_plating_jobs/models/fp_job.py`). Explicit precedence
|
||||
dispatch matching spec §6.2 — first match wins:
|
||||
|
||||
`no_parts → on_hold → awaiting_signoff → awaiting_qc → bake_due →
|
||||
predecessor_locked → idle_warning → done → contract_review →
|
||||
running_mine/running → ready_mine/ready`
|
||||
|
||||
Each state has a distinct background tint + left-border color + chip +
|
||||
mini-timeline marker color. See `_plant_card.scss` for the mapping. The
|
||||
"mine" variants (`ready_mine`, `running_mine`) light up only when the
|
||||
active step's work centre is in `res.users.paired_work_centre_ids` (the
|
||||
M2M holds one row in MVP, mirrors the existing single-station picker).
|
||||
|
||||
### Backend — single endpoint, denormalized payload
|
||||
|
||||
`/fp/landing/plant_kanban` (controller in
|
||||
`fusion_plating_shopfloor/controllers/plant_kanban.py`) returns
|
||||
`{ok, mode, paired_station, kpis, columns, cards}` in one JSONRPC call.
|
||||
Frontend has zero per-card RPCs — every card field comes pre-formatted
|
||||
from the controller's `_render_card`. State-chip text (with elapsed
|
||||
times, operator names, hours-idle) is interpolated server-side.
|
||||
|
||||
### Frontend — OWL component tree
|
||||
|
||||
```
|
||||
FpPlantKanban (client action 'fp_plant_kanban')
|
||||
└── FpTabletLock (PIN gate wrapper)
|
||||
├── PlantHeader (KPIs + filter chips + mode toggle + station picker)
|
||||
└── Board (9 × Column)
|
||||
├── FpColumnHeader (with 'You're here' badge for paired column)
|
||||
└── FpPlantCard[] (each with FpMiniTimeline)
|
||||
```
|
||||
|
||||
Polls every 10s. Filter state persists in localStorage. All 13 card
|
||||
states styled via `.state-<name>` CSS modifier classes on a single
|
||||
shared `.o_fp_plant_card` base. The mini-timeline renders 9 colored
|
||||
dots driven by `fp.job.mini_timeline_json` (Python emits the array
|
||||
shape — frontend just maps state → CSS class).
|
||||
|
||||
### Critical implementation gotchas (project rules applied)
|
||||
|
||||
- **OWL templates only expose `Math` as a JS global** (Rule 20). All
|
||||
coercion (String, Number, parseInt) MUST happen in JS — `tag_chip_class()`
|
||||
/ `progress_style` etc. live in plant_card.js, not in the XML.
|
||||
- **SCSS @import is forbidden** (Rule 8). `_plant_tokens.scss` loads
|
||||
FIRST in the manifest's `web.assets_backend`; subsequent component
|
||||
partials get the `$plant-*` vars via the concatenated bundle.
|
||||
- **Dark mode** via `$o-webclient-color-scheme == dark` compile-time
|
||||
branch in `_plant_tokens.scss` (NOT runtime class selectors).
|
||||
|
||||
### How to switch back to legacy
|
||||
|
||||
```sql
|
||||
UPDATE ir_config_parameter SET value = 'legacy'
|
||||
WHERE key = 'fusion_plating_shopfloor.layout';
|
||||
```
|
||||
|
||||
Or use Settings → Fusion Plating → Shop Floor Layout. Both surfaces
|
||||
write the same `ir.config_parameter` key.
|
||||
|
||||
## Deployment
|
||||
|
||||
### odoo-entech (LXC 111 on pve-worker5)
|
||||
@@ -1132,6 +1235,9 @@ Each script is self-contained — builds a fresh SO + job, walks the scenario, a
|
||||
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
|
||||
| **S20** | Tablet Station UX hardening — three real-world UX gaps surfaced during a persona walk on the Tablet + Manager Desk client actions | (a) **Scrap reason dropped**: `/fp/shopfloor/bump_qty_scrapped` accepted operator's typed reason via `window.prompt`, passed it through context as `fp_scrap_reason` — but `fp.job.write` never read it, so the auto-spawned Hold's description had the generic "OPERATOR: replace this text with the actual reason" placeholder instead of what Carlos typed. Audit trail lost what just happened on the floor. (b) **KPI/panel mismatch**: tablet KPI strip showed plant-wide totals ("Quality Holds: 12") but the Holds panel below was scoped to the operator's own jobs (might show 0). Operator stares at a big red 12, scrolls down, sees nothing — confused/distrustful. (c) **UserError stack-trace leak**: when `start_wo` hit an S14 predecessor lock (or any other `button_start`-side guard), the raw `UserError` propagated through the JSON-RPC handler and operator got a Python stack-trace dialog instead of the nice `setMessage("...", "danger")` flash. Same hole on `stop_wo`, `start_bake`, `end_bake`, `mark_gate`, `bump_qty_done`, `bump_qty_scrapped`. | (a) `fp.job.write` now reads `self.env.context.get('fp_scrap_reason')` and prepends `Operator reason: <text>` to the Hold description so the audit row captures what the operator actually typed. (b) Tablet KPI strip now reuses `my_job_ids_for_kpi` (the operator's own steps) for `awaiting_bakes`, `bake_in_progress`, `missed`, `open_holds` — same scope as the panels below, so the strip never lies. Manager dashboard keeps its own plant-wide KPI set. (c) Wrapped every action endpoint in `try: ... except UserError as e: return {'ok': False, 'error': str(e.args[0])}` — operator now gets the clean `setMessage` flash with the real guard text ("Step 'X' requires predecessors done first…") instead of a stack-trace popup. | `fusion_plating_jobs 19.0.6.22.0`, `fusion_plating_shopfloor 19.0.24.4.0` | persona walk via `sim_tablet_actions.py` + `sim_reverify.py` (asserts: typed reason ends up in hold.description, KPI=panel for holds, `start_wo` returns `{ok:False, error:"..."}` for locked step) |
|
||||
| **S20** | **Tablet usability pass** — operators were squinting at the tablet, scanning back-and-forth between recipe binders and the screen because the tablet showed step names but no targets, no live timer, no predecessor visibility. QC fail left parts in limbo with no Hold record. Manager Desk showed feel-good KPIs but hid the compliance bombs (missed bakes, stale steps, locked steps, holds, pending QC missing PDF) | Tablet `My Queue` rows had no `instructions`, `thickness_target`, `dwell_time_minutes`, `bake_setpoint_temp`, `requires_signoff` — operators kept scanning the QR code just to read the bake temperature. Steps with `requires_predecessor_done=True` (S14) showed a green Start that always failed with a UserError. Active step "duration" was a stale number that only refreshed every 30s. Holds and bake windows showed plant-wide noise from other crews. **No banner alerted Carlos when his job had a pending QC** (Lisa was not called → QC sat for hours). **No way to bump qty_done or scrap from the tablet** → S17 hold auto-spawn never fired because operators didn't update the field. **`action_fail` on QC marked the check failed but spawned no Hold** — AS9100 disposition trail broken. **Manager Desk KPIs were missing 7 compliance metrics**: stale paused/in-progress steps (cron data), missed bake windows, open holds, predecessor-locked steps, pending QCs, QCs missing Fischerscope PDF, draft cert pipeline | **Carlos's Shopfloor Tablet** — every queue row now carries the recipe-author fields (instructions snippet, thickness target chip, dwell-time chip, bake-temp chip, sign-off badge) so operators read the targets inline. Predecessor-blocked steps render with a 🔒 lock icon, an "Awaiting [step name]" notice, and a disabled `Locked` button (no more Start-then-fail). Active step now shows a **live ticking HH:MM:SS clock** (1s interval, computed from `date_started_iso` JS-side; flips to red on >1.5× planned duration) plus `+1 Done` and `Scrap` buttons that hit two new endpoints (`/fp/shopfloor/bump_qty_done`, `/fp/shopfloor/bump_qty_scrapped` — scrap prompts for reason and S17 auto-spawns the Hold). New **Pending QC banner** lists open QCs for my jobs with line-progress + Fischerscope-PDF status badge, and a tap deep-links into Lisa's mobile QC checklist. Holds and bake windows are now **scoped to my jobs first** (fall back to facility-wide for managers). **QC checklist** — `action_fail` now auto-creates a `fusion.plating.quality.hold` with `hold_reason='qc_failure'` (new selection value), populated description listing the failed checks, idempotent on retry. **Manager Desk** — 7 new clickable compliance KPI tiles: Missed Bakes (S15), Open Holds (S17 + QC fail), Stale Steps (S10/S16 cron data), Locked Steps (S14), Pending QC + "X need PDF" (S19 + missing-Fischerscope), Draft Certs + "Y today" (cert pipeline). Each tile drills into a list filtered to the relevant exception | `fusion_plating_shopfloor 19.0.24.3.0`, `fusion_plating_quality 19.0.4.8.0` | `sim_tablet_walk.py`, `sim_timer_pred_test.py`, `sim_qc_fail_hold.py`, `sim_manager_qc_fail.py` (one-off persona walkthroughs) |
|
||||
| **S21** | Riya finished steps on WO-30051 without filling in mandatory recipe-author prompts — Incoming Inspection skipped "Take and Upload Photos" (1/5 missed), Check Sulfamate Nickel Area skipped both masking-verification booleans (2/3 missed). AS9100 audit trail broken on a per-step basis. | (a) `_fp_has_uncaptured_step_inputs` returned False as soon as ANY move with input values existed since `date_started` — too coarse, let operators clear the dialog re-open by saving a single prompt. (b) `button_finish` had NO gate enforcing required step_input coverage — only Contract Review + Receiving gates fired. (c) OWL `Record Inputs` dialog `onSave()` had no client-side check for required prompts either, so operators got zero feedback when leaving fields blank. (d) Also caught: `fp_job_step.py` had **two `def button_finish` in the same class** — Python silently kept only the second definition, so the bake.window auto-spawn + duration-overrun warning at line 596 had been dead code for the entire WO-30051 era. | **Server gate** — new `_fp_check_step_inputs_complete()` on `fp.job.step` raises `UserError` listing every missing required step_input by name. Hooked into `button_finish` ahead of the existing Contract Review + Receiving gates. New helper `_fp_missing_required_step_inputs()` returns the recordset of required prompts with NO recorded value across any move from this step (centralised — used by both the gate and the dialog re-open helper). `_fp_has_uncaptured_step_inputs()` tightened to delegate to the new helper. **Client gate** — `onSave()` on `FpRecordInputsDialog` mirrors the server check when `advanceAfter=true` (Finish & Next path) so operators see a sticky red "Cannot finish step — N required prompts missing: ..." notification instantly rather than after a server roundtrip. Partial saves via the per-row Record button (`advanceAfter=false`) remain unblocked — operators can still capture progress and come back to fill the rest. **Manager bypass** — `fp_skip_required_inputs_gate=True` (documented deviations / paper-form catch-up); posts chatter audit naming the user. **Dead-code merge** — the duplicate `button_finish` at line 596 was deleted; its bake.window auto-spawn + duration-overrun chatter logic was folded into the canonical `button_finish` (which now runs in order: required-inputs gate → CR gate → receiving gate → `super()` → post-finish side effects). **Critical lesson — never put two `def <name>` in the same `models.Model` class body**. Python silently keeps the last one; the earlier definition becomes dead code with no warning. Always grep for duplicates after any structural edit on a long model file. | `fusion_plating_jobs 19.0.10.22.0` | Smoke: `step._fp_missing_required_step_inputs()` on any in_progress step returns the prompt recordset that would block finish. Server: try `step.button_finish()` on a step with required prompts unrecorded — should raise UserError listing them. Manager bypass: `step.with_context(fp_skip_required_inputs_gate=True).button_finish()` succeeds + posts audit. |
|
||||
| **S22** | Deep-audit finding F1 (2026-05-23) — `fp.job.step.requires_signoff` was 100% unenforced on entech: 42 of 42 done steps with the field set had `signoff_user_id IS NULL`. Recipe authors believed they'd gated aerospace / Nadcap steps; reality was the field was decorative. Pre-Sub-11 the `mrp.workorder.x_fc_signoff_user_id` had working logic, but Sub 11's MRP cutout removed bridge_mrp without porting the gate. | `signoff_user_id` was defined `readonly=True` on `fp.job.step` (from `fusion_plating/models/fp_job_step.py`) but **no code anywhere wrote to it**. No autosign on finish, no UI button, no `action_signoff`. Deep audit caught this because the 42/42 = 100% NULL ratio is the dead giveaway — when a "required" field has zero non-NULL rows across 42 records, the field's enforcement code is missing entirely. | **Three-piece fix on `fp.job.step`**: (1) `_fp_autosign_if_required()` — auto-sets `signoff_user_id = env.user.id` for the user clicking Finish, idempotent (preserves a supervisor's pre-sign via `action_signoff`). (2) `_fp_check_signoff_complete()` — raises `UserError` when `requires_signoff=True` and `signoff_user_id` is still NULL after the autosign helper has run (i.e. migration scripts, background crons with no env.user). (3) `action_signoff()` — explicit sign-off action for the case where a supervisor reviews and signs BEFORE the operator clicks Finish. Same-user re-click is a no-op; a DIFFERENT user re-signing overwrites the prior signer and posts a chatter reassignment ("Sign-off on step X reassigned from A to B"). Both helpers hook into `button_finish` AFTER `_fp_check_step_inputs_complete` and BEFORE the Contract-Review gate. **Manager bypass** — `fp_skip_signoff_gate=True` (documented deviations); posts chatter naming the user. **Lesson — for ANY "required" Boolean field that gates downstream behaviour, ALWAYS deep-audit the enforcement path: search the codebase for writes to the gated field, not just the boolean.** If zero writes exist, the gate is structural / decorative only. Grep the codebase periodically for `_check_*` helpers whose triggering field has no inverse writer. | `fusion_plating_jobs 19.0.10.23.0` | Verified end-to-end on entech: autosign sets signoff_user_id, gate raises UserError with the right message, bypass posts chatter audit, action_signoff sets + posts chatter, and the S21 required-inputs gate still fires (no regression). |
|
||||
| **S23 (shipped)** | Deep-audit bonus finding (2026-05-23) — `fp.job.step.requires_transition_form` had the same dormant-field shape as S22's signoff bug. The bypass context flag `fp_skip_transition_form` was already wired into the move controller's audit trail, but **no actual gate ever fired** because `_blockers_for_move` only enumerated `rack_required` + `predecessor_lock`. 0 of 286 moves on entech had this set (recipe authors hadn't enabled it), so no current audit gap — but the next recipe author who flips the toggle would discover the same cosmetic-only behaviour Riya found on S21. Caught preventively rather than reactively. NB: numbering conflicts with the open-scenarios list (also lists S23) — accept; the open list will be renumbered in a future doc-cleanup pass. | (a) `_blockers_for_move` in `fusion_plating_shopfloor/controllers/move_controller.py` had no `transition_form_required` case, only rack + predecessor. (b) The Move Rack controller `_do_move_rack_commit` didn't capture transition prompts at all — even if `requires_transition_form` were enforced on Move Parts, rack moves silently bypassed it. (c) The model layer `fp.job.step.move` had no helper to compute "missing required transition inputs", so any backend caller (wizards, scripts) had no way to enforce the contract. | **Model layer** — added two helpers to `fp.job.step.move` (canonical location): `_fp_missing_required_transition_inputs()` returns the recordset of required transition_input prompts on `to_step.recipe_node_id` that have no captured value on the move. `_fp_check_transition_inputs_complete()` raises `UserError` listing the missing prompts, manager bypass via `fp_skip_transition_form=True` (consistent with the existing audit-trail flag, NOT a new flag name), posts chatter on the move record on bypass. **Controller wiring** — `move_parts_commit` calls the gate AFTER `_capture_prompt_value` (so the operator gets credit for whatever they filled in; rollback unwinds the move + values on failure). `move_rack_commit` pre-rejects with a clear message ("use Move Parts so the form can be filled in") because rack moves have no per-batch prompt-capture UI. **Design choice** — gate is invoked explicitly by callers rather than via `create()` override; values are written in a separate call after the move row, so a model-level `create()` hook would always misfire. Future backend wizards / scripts MUST call `_fp_check_transition_inputs_complete()` after capturing prompt values, or pass `fp_skip_transition_form=True` if intentionally bypassing. **Two-layer pattern lesson** — when a recipe-author flag (here `requires_transition_form`) has BOTH a quick path (Move Rack — no form UI) AND a rich path (Move Parts — full form UI), the quick path MUST either implement the form OR reject the operation. A silent quick-path bypass defeats the whole gate. | `fusion_plating 19.0.20.9.0`, `fusion_plating_shopfloor 19.0.30.3.0` | Verified live on entech: helpers callable, move-parts commit raises on missing required prompts, move-rack commit rejects up-front when `to_step.requires_transition_form=True`, manager bypass via context flag posts move-chatter audit. |
|
||||
|
||||
### Manager-bypass context flags
|
||||
|
||||
@@ -1145,6 +1251,9 @@ When you need to override a guard (documented customer deviation, emergency rewo
|
||||
| `fp_skip_bake_gate=True` | bake.window pending check on `button_mark_done` (S15) |
|
||||
| `fp_skip_predecessor_check=True` | requires_predecessor_done check on `button_start` (S14) |
|
||||
| `fp_skip_missed_window=True` | missed_window block on `bake.window.action_start_bake` (S6) |
|
||||
| `fp_skip_required_inputs_gate=True` | required step_input prompts check on `fp.job.step.button_finish` (S21). Posts chatter audit naming the user. |
|
||||
| `fp_skip_signoff_gate=True` | `requires_signoff` + `signoff_user_id` check on `fp.job.step.button_finish` (S22). Posts chatter audit naming the user. Note: button_finish auto-sets signoff_user_id to the finisher first (via `_fp_autosign_if_required`); this bypass only matters when even the autosign can't fire (migration scripts, background crons with no env.user). |
|
||||
| `fp_skip_transition_form=True` | `requires_transition_form` + required transition_input coverage check on `fp.job.step.move._fp_check_transition_inputs_complete` (S23). Also drops the existing rack-vs-transition-form pre-reject on `move_rack_commit`. Posts chatter audit on the move record. Manager-only — controller checks the `fusion_plating.group_fusion_plating_manager` membership before honoring the flag. |
|
||||
|
||||
### Daily / hourly crons added by battle tests
|
||||
|
||||
@@ -1156,13 +1265,13 @@ When you need to override a guard (documented customer deviation, emergency rewo
|
||||
|
||||
### Open scenarios — flagged for next session
|
||||
|
||||
- **S21** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
|
||||
- **S22** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
|
||||
- **S23** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
|
||||
- **S24** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
|
||||
- **S25** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
|
||||
- **S26** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
|
||||
- **S27** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
|
||||
- **S23** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step (renumbered from S22 when S22 was claimed for the signoff gate)
|
||||
- **S24** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
|
||||
- **S25** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
|
||||
- **S26** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
|
||||
- **S27** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
|
||||
- **S28** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
|
||||
- **S29** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict; renumbered from S21 → S28 → S29)
|
||||
|
||||
### Tablet UI / persona-coverage gaps (S20 audit follow-ups)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
||||
# Shop Floor Plant View — Redesign
|
||||
|
||||
**Date:** 2026-05-23
|
||||
**Status:** Design — approved through brainstorming, awaiting plan
|
||||
**Replaces:** the current Shop Floor kanban (per-step grouping, one card per step)
|
||||
**Affects:** `fusion_plating_shopfloor` (primary), `fusion_plating` (work centre taxonomy), `fusion_plating_jobs` (active-step + workflow-state computes)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The current Shop Floor kanban groups cards by individual `fp.job.step.work_centre_id`. Every ready/pending step of a job spawns a separate card in its respective column. A 14-step recipe (e.g. `ENP-ALUM-BASIC` on WO-30019) produces **9 cards across 9 columns for ONE job**. With 17 active jobs on the floor, the board shows 100+ cards across 10+ narrow columns, most of which contain duplicates of the same WO.
|
||||
|
||||
Confirmed by the user via screenshots taken 2026-05-23:
|
||||
|
||||
> "the same job is appearing in multiple places, there can be 20 steps in any job and we cannot just make 20 columns for those jobs"
|
||||
|
||||
Net effect:
|
||||
- Operators can't scan the board — duplicates drown the signal
|
||||
- Recipes with many steps (15+) make the board explode horizontally
|
||||
- "Where is WO-30019 right now?" is impossible to answer at a glance
|
||||
- The mode toggle (Station / All Plant) is cosmetic — both produce the same cluttered output
|
||||
|
||||
The redesign re-anchors the kanban on **one card per job** at the **department level**, and scales to any recipe step count.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals & non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Every active fp.job appears in EXACTLY ONE column** at all times. No duplication.
|
||||
2. **Fixed 9-column layout** that doesn't grow with recipe step count.
|
||||
3. **Columns always render in process sequence** (Receiving → … → Shipping), regardless of card distribution. Empty columns still show.
|
||||
4. **Operator paired to a station sees their work highlighted** but can also see the whole plant — "Where is everything right now?" is the central operator question.
|
||||
5. **Every floor state the audit + battle-test catalog exposes is visually distinguishable on the card** (13 states total).
|
||||
6. **Scales infinitely**: a 5-step recipe and a 30-step recipe both produce single cards moving across the same 9 columns.
|
||||
7. **Tablet-first** — readable on a 1080p wall-mounted tablet without horizontal scroll.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- **Replacing the Job Workspace** (the full-screen single-WO surface). The kanban is the entry point; the Workspace remains the place where work happens. Card tap opens the Workspace.
|
||||
- **Replacing the Manager Dashboard** (`fp_manager_dashboard` with workflow funnel + at-risk + heatmap). The kanban's "Manager" mode is a filter on the same board; the dedicated dashboard stays separate.
|
||||
- **Drag-and-drop step advancement** from the kanban. State transitions happen inside the Workspace or via Move dialogs. The kanban reflects state, doesn't drive it.
|
||||
- **Per-tank columns**. Tanks are surfaced as chips on the card, not as columns.
|
||||
|
||||
---
|
||||
|
||||
## 3. Decisions locked during brainstorming (2026-05-23)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| D1 | **Plant-wide view with mine highlighted** is the operator default (over "filter to my station only"). Operators help each other and cover stations; visibility matters more than filtering. |
|
||||
| D2 | **9 fixed columns** by process area (Receiving, Masking, Blasting, Racking, Plating, Baking, De-Racking, Final inspection, Shipping). |
|
||||
| D3 | **All wet steps roll up into Plating** — Soak Clean, Electroclean, Acid Dip, Etch, Desmut, Zincate, Rinse, Water Break Test, E-Nickel Plating, Chrome, Anodize, Black Oxide, Drying. The tank chip on the card distinguishes them. |
|
||||
| D4 | **De-Masking folds into De-Racking** — same operator action in this shop's workflow; no separate column. |
|
||||
| D5 | **Contract Review (paperwork) cards live in Receiving** with a purple paperwork chip. Same for any pre-physical-work admin gate. |
|
||||
| D6 | **Variant C card design** — full-width vertical card with WO header, customer/PN/qty/PO line, recipe + spec, tag chips (Rush/FAIR/VIP), current step name, tank + state chip row, 9-column mini-timeline, progress bar + operator pill + icons. |
|
||||
| D7 | **13 card states** distinguishable by background tint, left-border color, state chip text/color, and timeline marker color. Full catalog in §6. |
|
||||
| D8 | **Columns appear in sequence and never reorder** — even empty columns show. The sequence is the visual mental model of the floor. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Column layout
|
||||
|
||||
### 4.1 Fixed column sequence
|
||||
|
||||
The board always renders these 9 columns in this exact order, left-to-right:
|
||||
|
||||
```
|
||||
1. Receiving 2. Masking 3. Blasting 4. Racking 5. Plating
|
||||
6. Baking 7. De-Racking 8. Final inspection 9. Shipping
|
||||
```
|
||||
|
||||
Columns are first-class entities, not derived from data. If no jobs are in Blasting, the column still appears with a "0" badge — it's a placeholder reminding the operator where Blasting sits in the flow.
|
||||
|
||||
### 4.2 Step-kind → column mapping
|
||||
|
||||
Each `fp.job.step` routes to exactly one column based on its `recipe_node_id.default_kind`. The mapping table:
|
||||
|
||||
| Column | Step kinds routed here |
|
||||
|---|---|
|
||||
| **Receiving** | `incoming_inspection`, `contract_review`, `gating`, `ready_for_processing`, any step where `state = 'pending'` and the job's first physical step hasn't started |
|
||||
| **Masking** | `masking` |
|
||||
| **Blasting** | `blasting`, `bead_blast`, `media_blast` |
|
||||
| **Racking** | `racking` |
|
||||
| **Plating** | `soak_clean`, `electroclean`, `acid_dip`, `etch`, `desmut`, `zincate`, `rinse`, `water_break_test`, `e_nickel_plate`, `chrome`, `anodize`, `black_oxide`, `drying`, `activation`, any step whose `work_centre.kind = 'wet_line'` |
|
||||
| **Baking** | `bake`, `oven_bake`, `post_bake_relief` |
|
||||
| **De-Racking** | `de_rack`, `de_mask`, `unrack` |
|
||||
| **Final inspection** | `post_plate_inspection`, `final_inspection`, `thickness_qc`, `fair`, `dimensional_check`, any step whose `work_centre.kind = 'inspect'` |
|
||||
| **Shipping** | `shipping`, `pack_ship` |
|
||||
|
||||
### 4.3 Implementation — `area_kind` field
|
||||
|
||||
Add a new Selection field on `fp.work.centre`:
|
||||
|
||||
```python
|
||||
area_kind = fields.Selection([
|
||||
('receiving', 'Receiving'),
|
||||
('masking', 'Masking'),
|
||||
('blasting', 'Blasting'),
|
||||
('racking', 'Racking'),
|
||||
('plating', 'Plating'),
|
||||
('baking', 'Baking'),
|
||||
('de_racking', 'De-Racking'),
|
||||
('inspection', 'Final inspection'),
|
||||
('shipping', 'Shipping'),
|
||||
], string='Floor Column', help='Which Shop Floor column this work centre belongs to. Drives the plant-view kanban.')
|
||||
```
|
||||
|
||||
`fp.job.step` already carries a `recipe_node_id` and (optionally) a `work_centre_id`. The kanban grouping resolves a step's column via:
|
||||
|
||||
```
|
||||
step.area_kind = step.work_centre_id.area_kind
|
||||
or _DEFAULT_KIND_BY_RECIPE_KIND.get(step.recipe_node_id.default_kind)
|
||||
or 'plating' # safe catch-all for unmapped wet steps
|
||||
```
|
||||
|
||||
A `post_init_hook` backfills `area_kind` on existing `fp.work.centre` records by matching their `kind` (`wet_line`/`bake`/`mask`/`rack`/`inspect`) against the new taxonomy. Unmapped centres get flagged for manual review.
|
||||
|
||||
### 4.4 Column visibility rules
|
||||
|
||||
- Always show all 9 columns in order.
|
||||
- Show the column-header count even when zero (`0` in grey, less prominent).
|
||||
- The operator's paired-station column gets a yellow tint + "📍 You're here" badge — see §7.
|
||||
|
||||
---
|
||||
|
||||
## 5. Card design — Variant C
|
||||
|
||||
### 5.1 Anatomy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ WO-30049 ⭐ Due May 16 · 3d │ ← WO + due
|
||||
│ ABC Manufacturing │ ← customer
|
||||
│ PN 9876699373 Rev A · Qty 5 · PO 4501882 │ ← part/qty/PO
|
||||
│ Recipe: ENP-ALUM-BASIC · AMS-2404 Type II │ ← recipe + spec
|
||||
│ [RUSH] [FAIR] │ ← tag chips
|
||||
│ Racking │ ← current step name
|
||||
│ [Rack Station 1] [● Ready] │ ← tank + state chips
|
||||
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← mini-timeline
|
||||
│ Rec Mask Blast [Rack] Plat Bake D-R Insp Ship│ ← timeline labels
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Step 4/14 ▓▓░░░░░░░░░ [GS] 🔏 │ ← progress + operator + icons
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Field-by-field
|
||||
|
||||
| Element | Source | Notes |
|
||||
|---|---|---|
|
||||
| WO # | `fp.job.display_wo_name` | Big, bold. `⭐` suffix appears when card is at operator's paired station. Tappable — opens Job Workspace. |
|
||||
| Due date | `fp.job.commitment_date` | Format "Due May 16 · 3d" (relative). Turns red + `⚠` when overdue. |
|
||||
| Customer | `fp.job.partner_id.name` | Single line; truncate with ellipsis if too long. |
|
||||
| PN / Qty / PO | `fp.job.part_catalog_id.part_number` + `.revision` · `fp.job.qty` · `fp.job.sale_order_id.x_fc_po_number` | One line, comma-separated. |
|
||||
| Recipe + spec | `fp.job.recipe_id.name` · `fp.job.customer_spec_id.code` | Muted small text. |
|
||||
| Tag chips | derived | Multi: Rush (partner flag) / FAIR (customer_spec.x_fc_requires_first_article) / VIP (partner flag) / AS9100 (job aerospace flag). Only renders when applicable. |
|
||||
| Current step name | `fp.job.active_step_id.name` or first ready step | Operator-facing label of the step the job is at. |
|
||||
| Tank chip | `fp.job.active_step_id.work_centre_id.code` or `.tank_id.code` | Blue chip. Specific tank/station. |
|
||||
| State chip | computed (§6) | One of 13 states. Color matches state. |
|
||||
| Mini-timeline | derived (§8) | 9-step bar showing the journey across columns. |
|
||||
| Step X / Y | `fp.job.active_step_id.sequence` / `count(fp.job.step_ids)` | Recipe progress, not the same as the 9-col timeline. |
|
||||
| Progress bar | computed | Filled to `active_step.sequence / total_steps`. Color matches state. |
|
||||
| Operator pill | `fp.job.active_step_id.assigned_user_id` | Initials avatar. Hidden when ready (no operator engaged yet). |
|
||||
| Icon row | derived | Compact status flags (see §5.3). |
|
||||
|
||||
### 5.3 Icon row catalog
|
||||
|
||||
| Icon | Meaning | Trigger |
|
||||
|---|---|---|
|
||||
| 🔏 | Sign-off required | `step.requires_signoff` AND not yet signed |
|
||||
| ⏰ | Bake window approaching | upstream wet step done, `bake_required_by - now < 1h` |
|
||||
| 🔥 | Bake compliance gate | active step kind = `bake` |
|
||||
| 💬 | Recent chatter activity | `job.message_post` in last 24h |
|
||||
| 🔒 | Predecessor locked | `step.requires_predecessor_done` AND upstream not done |
|
||||
| 📋 | Required inputs unrecorded | `step._fp_missing_required_step_inputs()` returns non-empty |
|
||||
| 📷 | Photo required but missing | step has a `photo` input prompt unrecorded |
|
||||
| 🚚 | Inbound shipment tracking | `state = no_parts` AND `x_fc_receiving.x_fc_carrier_tracking` present |
|
||||
| 📜 | Cert ready / issued | `state = done` AND `fp.certificate.state in ('issued','sent')` |
|
||||
| ↳ | Jump to blocker | tappable; navigates to the predecessor step in the Workspace |
|
||||
|
||||
Icons only render when their condition is true. Max 3-4 visible per card; overflow into a `⋯` tooltip.
|
||||
|
||||
---
|
||||
|
||||
## 6. Card states — exhaustive catalog
|
||||
|
||||
13 mutually-exclusive states, computed server-side per job. Each card carries exactly one state; precedence rules below resolve conflicts.
|
||||
|
||||
### 6.1 State definitions
|
||||
|
||||
| # | State | Background | Left border | State chip | Timeline marker | Triggered when |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `ready_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "● Ready to start" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND `active_step.work_centre_id IN operator_paired_stations` |
|
||||
| 2 | `running_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "▶ Running 8m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND `active_step.work_centre_id IN operator_paired_stations` |
|
||||
| 3 | `ready` | `#ffffff` (white) | none | "● Ready" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND NOT mine |
|
||||
| 4 | `running` | `#ffffff` (white) | none | "▶ Running 3m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND NOT mine |
|
||||
| 5 | `on_hold` | `#fff5f5` (red) | `#dc3545` (red, 4px) | "🔴 Quality Hold" (red) | `current.hold` (red) | `fusion.plating.quality.hold` exists on the job with `state = 'open'` |
|
||||
| 6 | `predecessor_locked` | `#f8f9fa` (grey) | none | "🔒 Waiting on Blasting" (grey) | `current.locked` (grey) | `step._fp_should_block_predecessors()` returns True AND any earlier-sequence step not done/skipped/cancelled |
|
||||
| 7 | `bake_due` | `#fff8e1` (orange) | `#ff9800` (orange, 4px) | "⏰ Bake window in 23m" (orange) | `current.bake` (orange) | `fusion.plating.bake.window` for this job has `bake_required_by - now < 1h` AND `state = 'awaiting_bake'` |
|
||||
| 8 | `awaiting_signoff` | `#f5f0ff` (purple) | `#6f42c1` (purple, 4px) | "🔏 Awaiting QA sign-off" (purple) | `current.signoff` (purple) | `step.requires_signoff` AND `step.state = 'done'` AND `step.signoff_user_id IS NULL` (S22 gate) |
|
||||
| 9 | `idle_warning` | `#fef9e7` (amber) | `#e6a800` (amber, 4px) | "⏸ Idle 14h · Carlos" (amber) | `current.idle` (amber) | `step.state = 'in_progress'` AND `now - step.last_activity_at > 8h` (S16 cron) |
|
||||
| 10 | `awaiting_qc` | `#e7f5fc` (cyan) | `#17a2b8` (cyan, 4px) | "🔬 QC pending · 2/6 items" (cyan) | `current.qc` (cyan) | `fusion.plating.quality.check` exists with `state IN ('draft','in_progress')` AND no other higher-precedence state |
|
||||
| 11 | `no_parts` | `#f5f5f5` (grey, dashed) | `#6c757d` (grey, 4px, dashed) | "📦 Parts in transit · 2d" (grey) | `current.no_parts` (grey) | `fp.job.state = 'confirmed'` AND inbound `fp.receiving.state = 'draft'` AND no step has started yet |
|
||||
| 12 | `contract_review` | `#ffffff` (white) | none | "📋 QA-005 Awaiting QA Manager" (purple) | `current.paperwork` (purple) | `active_step.recipe_node_id.default_kind = 'contract_review'` AND not complete |
|
||||
| 13 | `done` | `#f0f9f4` (green) | `#28a745` (green, 4px) | "✓ Ready for pickup" (green) | `current.done` (green) | active step is in `Shipping` column AND `fp.job.state = 'done'` |
|
||||
|
||||
### 6.2 Precedence rules
|
||||
|
||||
When multiple state triggers fire simultaneously, the resolver iterates through this **explicit precedence list** and takes the first match:
|
||||
|
||||
```
|
||||
1. no_parts (can't co-occur with anything else; checked first)
|
||||
2. on_hold (compliance bomb — always wins over operational states)
|
||||
3. awaiting_signoff (S22 gate — blocks advancement even when step.state='done')
|
||||
4. awaiting_qc (quality gate — sticky until QC closes)
|
||||
5. bake_due (time-sensitive compliance window)
|
||||
6. predecessor_locked (soft block on a step that's data-ready but workflow-locked)
|
||||
7. idle_warning (long-running supersedes plain running)
|
||||
8. done (terminal state — only reached if none of the above apply)
|
||||
9. contract_review (paperwork — used at job entry before physical work)
|
||||
10. running_mine (more specific than running)
|
||||
11. ready_mine (more specific than ready)
|
||||
12. running (operational default for active work)
|
||||
13. ready (operational default for next-up work)
|
||||
```
|
||||
|
||||
The numeric ordering here is the dispatch order in `_fp_resolve_card_state`, not a severity ranking. Examples:
|
||||
|
||||
- Job both on-hold AND awaiting-signoff → `on_hold` (rule 2 fires before rule 3)
|
||||
- Job both bake-due AND running_mine → `bake_due` (rule 5 fires before rule 10)
|
||||
- Job in_progress for 14h at the operator's station → `idle_warning` (rule 7 fires before rule 10)
|
||||
|
||||
Implementation in §9.3 mirrors this list exactly — keep them synchronized.
|
||||
|
||||
### 6.3 Mine resolution
|
||||
|
||||
A card is "mine" when **any of the following** is true:
|
||||
|
||||
1. `active_step.work_centre_id.id IN operator.paired_work_centre_ids` (operator paired to that specific station)
|
||||
2. `active_step.assigned_user_id == operator.id` (job is personally assigned to operator)
|
||||
3. `active_step.area_kind == operator.preferred_area_kind` (operator's profile lists this department, for cross-trained operators)
|
||||
|
||||
For **MVP, use rule 1 only**. Rules 2-3 are post-MVP enhancements.
|
||||
|
||||
The `res.users.paired_work_centre_ids` is a Many2many on the data model (so it's forward-compatible with cross-trained operators), but the **MVP pairing UX keeps the existing single-station dropdown** (`fp_shopfloor_tech_store.currentStationId`). On unlock, the M2M holds exactly one record — the selected station. A Phase 2 enhancement adds a multi-select picker so cross-trained operators can pair to 2-4 stations at once; the resolver above already supports that without further code change.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sticky header
|
||||
|
||||
The header pins to the top of the kanban and remains visible during scroll.
|
||||
|
||||
### 7.1 Layout
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 🏭 Shop Floor [📍 Racking — Garry Singh ▾] [Station|All Plant|Manager]│
|
||||
│ [📷 Scan QR] [🔓 Hand Off] [⚙] │
|
||||
├────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [17 Active] [3 At My Station] [2 Bakes Due ≤2h] [1 On Hold] [2 Overdue]│
|
||||
├────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [🔎 Search WO #, customer, part #, PO…] │
|
||||
│ [All] [My Station] [Running] [Blocked] [Overdue] [FAIR] │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 KPI strip — 5 tiles, clickable filters
|
||||
|
||||
| Tile | Source | Click behavior |
|
||||
|---|---|---|
|
||||
| **Active Jobs** | `count(fp.job WHERE state IN ('confirmed','in_progress'))` | Filter chip "All" → shows everything |
|
||||
| **At My Station** | count of cards with `state IN ('ready_mine','running_mine')` | Filter chip "My Station" → only mine |
|
||||
| **Bakes Due ≤2h** | count of cards with `state = 'bake_due'` AND `bake_required_by - now < 2h` | Highlights orange cards |
|
||||
| **On Hold** | count of cards with `state = 'on_hold'` | Filter to red cards; clicking opens Quality Holds list |
|
||||
| **Overdue** | count of cards where `commitment_date < today` AND `state != 'done'` | Filter to overdue |
|
||||
|
||||
Each tile is a button. Active tile shows a darker border + filled chip indicator.
|
||||
|
||||
### 7.3 Filter chips
|
||||
|
||||
Below KPIs, a row of toggleable filter chips. Multiple can be active (intersected with AND):
|
||||
|
||||
- **All** (default; clears others)
|
||||
- **My Station** (cards where `state IN ('ready_mine','running_mine')`)
|
||||
- **Running** (`active_step.state = 'in_progress'`)
|
||||
- **Blocked** (`state IN ('on_hold','predecessor_locked','awaiting_signoff','awaiting_qc','no_parts')`)
|
||||
- **Overdue** (`commitment_date < today` AND `state != 'done'`)
|
||||
- **FAIR** (partner or spec requires FAIR; flagged via tag)
|
||||
|
||||
Chip state persists per operator per browser session (localStorage), so an operator who always filters to "My Station" doesn't have to re-set it each shift.
|
||||
|
||||
### 7.4 Station picker
|
||||
|
||||
The `[📍 Racking — Garry Singh ▾]` button:
|
||||
- Shows the operator's current paired station + their name
|
||||
- Dropdown lets them switch to a different station they're certified on (from their `paired_work_centre_ids`)
|
||||
- "All stations" option clears pairing
|
||||
- Disabled when the operator hasn't signed in (lock screen takes precedence)
|
||||
|
||||
### 7.5 Mode toggle
|
||||
|
||||
Three modes:
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| **Station** | Cards at the paired station's column get the yellow `mine` treatment. Column header shows "📍 You're here". Other columns visible but neutral. |
|
||||
| **All Plant** | No "mine" highlight anywhere. Pure plant overview. Use case: supervisor walking the floor without paired station. |
|
||||
| **Manager** | Same as All Plant + adds bottleneck heatmap row at top (`fp.work.centre.bottleneck_score` driven). KPI strip swaps to manager-specific tiles (Late Risk, Avg Wait, etc.). |
|
||||
|
||||
Manager mode is gated by `fusion_plating.group_fusion_plating_manager`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Mini-timeline derivation
|
||||
|
||||
The 9-step bar on each card is **not** the recipe step count — it's a fixed 9-element array keyed by the 9 columns. Logic:
|
||||
|
||||
```python
|
||||
def _compute_mini_timeline(self):
|
||||
"""Returns list of 9 dicts, one per column, with state in {'done','current','upcoming','hold','locked','bake','signoff','idle','qc','no_parts','done','paperwork'}."""
|
||||
timeline = []
|
||||
job_steps = self.step_ids.sorted('sequence')
|
||||
active = self.active_step_id
|
||||
active_area = active.area_kind if active else None
|
||||
for area in COLUMN_SEQUENCE: # ['receiving', 'masking', 'blasting', ...]
|
||||
steps_in_area = job_steps.filtered(lambda s: s.area_kind == area)
|
||||
if not steps_in_area:
|
||||
# area not used by this recipe — still show as 'upcoming' to keep alignment
|
||||
timeline.append({'area': area, 'state': 'upcoming'})
|
||||
continue
|
||||
if all(s.state in ('done', 'skipped') for s in steps_in_area):
|
||||
timeline.append({'area': area, 'state': 'done'})
|
||||
elif area == active_area:
|
||||
# The card's state determines the current marker color
|
||||
timeline.append({'area': area, 'state': 'current', 'variant': self.card_state})
|
||||
else:
|
||||
timeline.append({'area': area, 'state': 'upcoming'})
|
||||
return timeline
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Recipes that skip a column (e.g. a job that doesn't need Masking) still render that column slot as "upcoming" grey — visual alignment matters more than perfect accuracy.
|
||||
- The `variant` field on the current marker tells the renderer which color to use (matches the card-state color: yellow / red / orange / purple / etc.).
|
||||
|
||||
---
|
||||
|
||||
## 9. Backend changes
|
||||
|
||||
### 9.1 New / modified fields
|
||||
|
||||
| Model | Field | Type | Purpose |
|
||||
|---|---|---|---|
|
||||
| `fp.work.centre` | `area_kind` | Selection (9 values) | Routes each work centre to one of the 9 columns |
|
||||
| `fp.job.step` | `area_kind` | Char, computed, stored, indexed | Related from `work_centre_id.area_kind` with fallback to `recipe_node_id.default_kind` lookup |
|
||||
| `fp.job` | `card_state` | Char, computed, stored, indexed | The 13-state classifier; computed via `_compute_card_state` with the precedence rules in §6.2 |
|
||||
| `fp.job` | `mini_timeline_json` | Text, computed | JSON-serialized output of `_compute_mini_timeline` |
|
||||
| `fp.job.step` | `last_activity_at` | Datetime, indexed | Updated on any state transition / move / chatter post; drives idle-warning detection (S16) |
|
||||
| `res.users` | `paired_work_centre_ids` | M2M `fp.work.centre` | Operator's certified stations; resolved on PIN unlock |
|
||||
|
||||
`area_kind` Selection values (used by both `fp.work.centre` and `fp.job.step`):
|
||||
|
||||
```python
|
||||
COLUMN_SEQUENCE = [
|
||||
('receiving', 'Receiving'),
|
||||
('masking', 'Masking'),
|
||||
('blasting', 'Blasting'),
|
||||
('racking', 'Racking'),
|
||||
('plating', 'Plating'),
|
||||
('baking', 'Baking'),
|
||||
('de_racking', 'De-Racking'),
|
||||
('inspection', 'Final inspection'),
|
||||
('shipping', 'Shipping'),
|
||||
]
|
||||
```
|
||||
|
||||
### 9.2 New endpoint — `/fp/landing/plant_kanban`
|
||||
|
||||
Replaces the existing `/fp/landing/kanban`. Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"mode": "station",
|
||||
"paired_station": {"id": 12, "name": "Rack Station 1", "area_kind": "racking"},
|
||||
"kpis": {
|
||||
"active_jobs": 17,
|
||||
"at_my_station": 3,
|
||||
"bakes_due_soon": 2,
|
||||
"on_hold": 1,
|
||||
"overdue": 2
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
"area_kind": "receiving",
|
||||
"label": "Receiving",
|
||||
"is_mine": false,
|
||||
"card_ids": [2885, 2886, 2887]
|
||||
},
|
||||
{
|
||||
"area_kind": "masking",
|
||||
"label": "Masking",
|
||||
"is_mine": false,
|
||||
"card_ids": [2884]
|
||||
},
|
||||
...
|
||||
],
|
||||
"cards": {
|
||||
"2885": {
|
||||
"wo_name": "WO-30049",
|
||||
"is_mine": true,
|
||||
"card_state": "ready_mine",
|
||||
"due_date": "2026-05-16",
|
||||
"due_label": "Due May 16 · 3d",
|
||||
"is_overdue": false,
|
||||
"customer": "ABC Manufacturing",
|
||||
"part_number": "9876699373",
|
||||
"part_revision": "A",
|
||||
"qty": 5,
|
||||
"po_number": "4501882",
|
||||
"recipe_name": "ENP-ALUM-BASIC",
|
||||
"spec_code": "AMS-2404 Type II",
|
||||
"tags": ["rush", "fair"],
|
||||
"step_name": "Racking",
|
||||
"step_seq": 4,
|
||||
"step_total": 14,
|
||||
"tank_label": "Rack Station 1",
|
||||
"state_chip": {"label": "● Ready to start", "kind": "ready"},
|
||||
"operator": {"id": null, "name": null, "initials": null},
|
||||
"duration_label": null,
|
||||
"icons": ["signoff_required"],
|
||||
"mini_timeline": [
|
||||
{"area": "receiving", "state": "done"},
|
||||
{"area": "masking", "state": "done"},
|
||||
{"area": "blasting", "state": "done"},
|
||||
{"area": "racking", "state": "current", "variant": "ready_mine"},
|
||||
...
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Design choices:
|
||||
- **Two-tier structure** (`columns` + `cards`) keeps payload small when 2 cards happen to be at the same step — no per-column-per-card duplication.
|
||||
- **`card_state` is server-computed** — frontend just maps state → CSS class.
|
||||
- **`mini_timeline` is server-computed** — frontend renders the 9 dots without knowing the recipe shape.
|
||||
- **Operator info is denormalized** — initials, name, color hash all in the payload so the frontend doesn't fan out RPCs.
|
||||
|
||||
### 9.3 State computation — `_compute_card_state`
|
||||
|
||||
Matches the precedence list in §6.2 exactly. Both must stay in sync.
|
||||
|
||||
```python
|
||||
def _compute_card_state(self):
|
||||
for job in self:
|
||||
# Edge: job has no active step (all pending or all done)
|
||||
if not job.active_step_id:
|
||||
# rule 1
|
||||
if job.state == 'confirmed' and job._fp_inbound_not_received():
|
||||
job.card_state = 'no_parts'
|
||||
else:
|
||||
# Fallback to first pending step's kind; otherwise contract_review
|
||||
job.card_state = 'contract_review'
|
||||
continue
|
||||
|
||||
step = job.active_step_id
|
||||
|
||||
# rule 1 — no_parts (even with an active step, if inbound is still draft)
|
||||
if job._fp_inbound_not_received():
|
||||
job.card_state = 'no_parts'
|
||||
continue
|
||||
# rule 2 — on_hold
|
||||
if job._fp_has_open_hold():
|
||||
job.card_state = 'on_hold'
|
||||
continue
|
||||
# rule 3 — awaiting_signoff (S22)
|
||||
if (step.requires_signoff and step.state == 'done'
|
||||
and not step.signoff_user_id):
|
||||
job.card_state = 'awaiting_signoff'
|
||||
continue
|
||||
# rule 4 — awaiting_qc
|
||||
if job._fp_has_pending_qc():
|
||||
job.card_state = 'awaiting_qc'
|
||||
continue
|
||||
# rule 5 — bake_due
|
||||
if job._fp_bake_window_due_soon():
|
||||
job.card_state = 'bake_due'
|
||||
continue
|
||||
# rule 6 — predecessor_locked
|
||||
if (step._fp_should_block_predecessors()
|
||||
and step._fp_has_unfinished_predecessors()):
|
||||
job.card_state = 'predecessor_locked'
|
||||
continue
|
||||
# rule 7 — idle_warning (S16)
|
||||
if step.state == 'in_progress' and step._fp_is_idle(threshold_hours=8):
|
||||
job.card_state = 'idle_warning'
|
||||
continue
|
||||
# rule 8 — done (terminal, only reached when nothing above fires)
|
||||
if step.area_kind == 'shipping' and job.state == 'done':
|
||||
job.card_state = 'done'
|
||||
continue
|
||||
# rule 9 — contract_review
|
||||
if step.recipe_node_id.default_kind == 'contract_review':
|
||||
job.card_state = 'contract_review'
|
||||
continue
|
||||
# rules 10/12 — running (mine vs not)
|
||||
if step.state == 'in_progress':
|
||||
job.card_state = ('running_mine' if job._fp_is_mine()
|
||||
else 'running')
|
||||
continue
|
||||
# rules 11/13 — ready (mine vs not)
|
||||
if step.state == 'ready':
|
||||
job.card_state = ('ready_mine' if job._fp_is_mine()
|
||||
else 'ready')
|
||||
continue
|
||||
# Safe default
|
||||
job.card_state = 'ready'
|
||||
```
|
||||
|
||||
Each `_fp_*` helper is a small method on `fp.job` (or `fp.job.step`) that encapsulates one precedence check. Centralizing them this way means future audits can extend the catalog without touching the dispatch.
|
||||
|
||||
### 9.4 Helpers
|
||||
|
||||
| Helper | Returns | Source data |
|
||||
|---|---|---|
|
||||
| `_fp_inbound_not_received()` | bool | `fp.receiving` linked via SO; `state = 'draft'` |
|
||||
| `_fp_has_open_hold()` | bool | `fusion.plating.quality.hold` with `state = 'open'` linked via `job_id` |
|
||||
| `_fp_has_pending_qc()` | bool | `fusion.plating.quality.check` with `state IN ('draft','in_progress')` linked via `job_id` |
|
||||
| `_fp_bake_window_due_soon()` | bool | `fusion.plating.bake.window` linked, `bake_required_by - now < 1h`, `state = 'awaiting_bake'` |
|
||||
| `step._fp_is_idle(threshold_hours=8)` | bool | `now - last_activity_at > threshold` |
|
||||
| `_fp_is_mine()` | bool | `active_step.work_centre_id IN env.user.paired_work_centre_ids` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Frontend changes
|
||||
|
||||
### 10.1 OWL component structure
|
||||
|
||||
New / modified files in `fusion_plating_shopfloor/static/src/`:
|
||||
|
||||
```
|
||||
js/
|
||||
plant_kanban.js (new — replaces shopfloor_landing.js)
|
||||
components/
|
||||
plant_card.js (new — Variant C card component)
|
||||
mini_timeline.js (new — 9-step horizontal bar)
|
||||
column_header.js (new — column header with "📍 You're here" badge)
|
||||
kpi_tile.js (new — clickable KPI button)
|
||||
filter_chip.js (new — toggleable filter chip)
|
||||
xml/
|
||||
plant_kanban.xml (new)
|
||||
components/
|
||||
plant_card.xml (new)
|
||||
mini_timeline.xml (new)
|
||||
column_header.xml (new)
|
||||
kpi_tile.xml (new)
|
||||
filter_chip.xml (new)
|
||||
scss/
|
||||
plant_kanban.scss (new — board layout + sticky header)
|
||||
components/
|
||||
_plant_card.scss (new — 13 card-state styles)
|
||||
_mini_timeline.scss (new — timeline dots)
|
||||
_column_header.scss (new)
|
||||
_kpi_tile.scss (new)
|
||||
_filter_chip.scss (new)
|
||||
```
|
||||
|
||||
### 10.2 Component tree
|
||||
|
||||
```
|
||||
FpPlantKanban (top-level client action)
|
||||
├── FpTabletLock (existing wrapper for PIN gate)
|
||||
└── (when unlocked)
|
||||
├── PlantHeader
|
||||
│ ├── StationPicker
|
||||
│ ├── ModeToggle
|
||||
│ ├── ToolbarButtons (Scan / Hand Off / Settings)
|
||||
│ ├── KpiStrip (5 × KpiTile)
|
||||
│ └── FilterRow (search input + 6 × FilterChip)
|
||||
└── Board
|
||||
└── 9 × Column
|
||||
├── ColumnHeader
|
||||
└── PlantCard[]
|
||||
├── CardHeader (WO, due)
|
||||
├── CardBody (customer, PN, recipe, tags)
|
||||
├── CardStep (step name + chips)
|
||||
├── MiniTimeline
|
||||
└── CardFooter (progress + operator + icons)
|
||||
```
|
||||
|
||||
### 10.3 Card state CSS
|
||||
|
||||
All 13 states share the base `.plant-card` class with state-specific modifier classes:
|
||||
|
||||
```scss
|
||||
.plant-card {
|
||||
background: $card-bg;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
// ... base layout
|
||||
|
||||
&.state-ready_mine, &.state-running_mine {
|
||||
background: #fffaeb;
|
||||
border-left: 4px solid #f0a500;
|
||||
padding-left: 9px;
|
||||
}
|
||||
&.state-on_hold {
|
||||
background: #fff5f5;
|
||||
border-left: 4px solid #dc3545;
|
||||
padding-left: 9px;
|
||||
}
|
||||
&.state-bake_due {
|
||||
background: #fff8e1;
|
||||
border-left: 4px solid #ff9800;
|
||||
padding-left: 9px;
|
||||
}
|
||||
&.state-awaiting_signoff {
|
||||
background: #f5f0ff;
|
||||
border-left: 4px solid #6f42c1;
|
||||
padding-left: 9px;
|
||||
}
|
||||
&.state-idle_warning {
|
||||
background: #fef9e7;
|
||||
border-left: 4px solid #e6a800;
|
||||
padding-left: 9px;
|
||||
}
|
||||
&.state-awaiting_qc {
|
||||
background: #e7f5fc;
|
||||
border-left: 4px solid #17a2b8;
|
||||
padding-left: 9px;
|
||||
}
|
||||
&.state-predecessor_locked {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
&.state-no_parts {
|
||||
background: #f5f5f5;
|
||||
border: 1px dashed #999;
|
||||
border-left: 4px solid #6c757d;
|
||||
padding-left: 9px;
|
||||
}
|
||||
&.state-done {
|
||||
background: #f0f9f4;
|
||||
border-left: 4px solid #28a745;
|
||||
padding-left: 9px;
|
||||
}
|
||||
// state-ready, state-running, state-contract_review: default neutral white
|
||||
}
|
||||
```
|
||||
|
||||
Dark-mode SCSS branch follows the project pattern (`$o-webclient-color-scheme == dark` block) with adjusted hex values.
|
||||
|
||||
### 10.4 Auto-refresh
|
||||
|
||||
Polling every 10s via `setInterval`. On each tick:
|
||||
1. Fetch `/fp/landing/plant_kanban` with current mode + filter state in the request payload.
|
||||
2. Diff against current state.
|
||||
3. Apply changes to OWL reactive state — cards that moved columns animate the transition (fade-out from old column, fade-in at new column over 200ms).
|
||||
|
||||
Hand-Off, mode toggle, station-picker, and filter chip changes trigger an immediate refresh.
|
||||
|
||||
### 10.5 Card tap behavior
|
||||
|
||||
Single tap on a card → opens Job Workspace (`fp_job_workspace` client action) with the WO pre-loaded. No quick-action sheet on tablet (would compete with the Workspace's own action rail).
|
||||
|
||||
Card has a small "ℹ" icon in the top-right that opens a quick-info popover (for supervisor walk-bys who want details without leaving the kanban). Post-MVP.
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration & rollout
|
||||
|
||||
### 11.1 Database migration
|
||||
|
||||
```python
|
||||
# fusion_plating/migrations/19.0.21.0.0/post-migrate.py
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Backfill fp.work.centre.area_kind from existing kind values."""
|
||||
cr.execute("""
|
||||
UPDATE fp_work_centre
|
||||
SET area_kind = CASE kind
|
||||
WHEN 'wet_line' THEN 'plating'
|
||||
WHEN 'bake' THEN 'baking'
|
||||
WHEN 'mask' THEN 'masking'
|
||||
WHEN 'rack' THEN 'racking'
|
||||
WHEN 'inspect' THEN 'inspection'
|
||||
ELSE 'plating'
|
||||
END
|
||||
WHERE area_kind IS NULL
|
||||
""")
|
||||
# Log unmapped centres for manual review
|
||||
cr.execute("""
|
||||
SELECT id, name FROM fp_work_centre WHERE area_kind IS NULL
|
||||
""")
|
||||
for row in cr.fetchall():
|
||||
_logger.warning("Work centre %s (%s) has no area_kind — defaulted to 'plating'", row[0], row[1])
|
||||
```
|
||||
|
||||
### 11.2 Feature flag
|
||||
|
||||
New config setting `x_fc_shopfloor_layout` on `res.config.settings`:
|
||||
- `legacy` (default during rollout) — existing landing
|
||||
- `v2` — new plant view
|
||||
|
||||
Once validated on entech, default flips to `v2` and legacy code can be removed in a follow-up cleanup.
|
||||
|
||||
The client action `fp_shopfloor_landing` resolver chooses which OWL component to mount based on this setting.
|
||||
|
||||
### 11.3 Rollout sequence
|
||||
|
||||
1. Ship migration + backend (`area_kind`, `card_state`, `mini_timeline_json`, helpers, endpoint) under the v2 flag.
|
||||
2. Ship OWL components under the v2 flag. Both screens coexist.
|
||||
3. QA on entech: flip `x_fc_shopfloor_layout = 'v2'`, validate end-to-end.
|
||||
4. Run battle-test scenarios (S1-S23) against the new view to confirm no regression.
|
||||
5. Flip default to `v2` site-wide.
|
||||
6. After 2 weeks of stable v2, remove legacy code.
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing strategy
|
||||
|
||||
### 12.1 Unit tests
|
||||
|
||||
- `test_card_state_computation` — for each of the 13 states, construct an `fp.job` in that exact data shape, assert `card_state` resolves correctly
|
||||
- `test_card_state_precedence` — overlay multiple triggers (e.g. on-hold + bake-due), assert precedence rules produce the documented winner
|
||||
- `test_area_kind_routing` — for each step kind in the mapping table, assert it routes to the correct column
|
||||
- `test_mini_timeline` — for a 14-step recipe at various points, assert the 9-element output matches expectations (including skipped columns rendered as upcoming)
|
||||
- `test_one_card_per_job_invariant` — across a realistic 17-job board, assert no two entries in `cards{}` share the same `fp.job.id`
|
||||
|
||||
### 12.2 Persona walks
|
||||
|
||||
Re-run the battle-test scenarios that drove this redesign:
|
||||
|
||||
- **S20 walk** — operator persona traversal of the tablet. Confirm: card density readable, "mine" highlight obvious, can find a specific WO in <5s via search.
|
||||
- **S22 / S23 simulations** — finish a step that needs sign-off / transition form, confirm the card transitions to `awaiting_signoff` / `awaiting_qc` state correctly.
|
||||
- **20-step-recipe regression** — load a synthetic job with 25+ recipe steps, confirm it occupies one and only one card on the board.
|
||||
|
||||
### 12.3 Visual snapshot tests
|
||||
|
||||
Per state, a Playwright/headless-chromium snapshot of a single card at fixed viewport. Diff against checked-in golden images on every PR. Catches accidental CSS regressions.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions (deferred)
|
||||
|
||||
These don't block MVP but should be tracked for the follow-up plan.
|
||||
|
||||
| # | Question | Suggested resolution |
|
||||
|---|---|---|
|
||||
| Q1 | Drag-and-drop card between columns? | **No for MVP.** State transitions happen via the Workspace action rail or Move dialogs. The kanban reflects state, doesn't drive it. |
|
||||
| Q2 | Empty-column auto-collapse? | **No.** Column position = mental model. Collapsing breaks the sequence. |
|
||||
| Q3 | Sort within column? | **MVP: most urgent first** — overdue → bake-due → ready → running → idle → locked → done. Post-MVP: operator-toggleable. |
|
||||
| Q4 | Card tap → quick-action sheet vs. open Workspace? | **MVP: open Workspace.** Quick-action sheet is a post-MVP enhancement. |
|
||||
| Q5 | Manager mode KPI tile swap? | **Phase 2.** MVP ships with the same 5 KPI tiles in all modes. Phase 2 adds manager-specific tiles (late-risk %, avg wait per station, bottleneck score). |
|
||||
| Q6 | Sibling jobs (WO-30029-01 / -02) visual grouping? | **No special treatment for MVP.** Each is its own card. If siblings clutter, post-MVP adds a "group siblings" toggle. |
|
||||
| Q7 | Bottleneck heatmap row in manager mode? | **Phase 2.** Reuses existing `fp.work.centre.bottleneck_score`. |
|
||||
| Q8 | Mobile (phone) breakpoint? | **Phase 2.** MVP optimized for 1080p tablet. Phone view = collapse to single-column scroll. |
|
||||
|
||||
---
|
||||
|
||||
## 14. Summary
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Layout | 9 fixed columns in sequence (Receiving → … → Shipping) |
|
||||
| Card model | One card per `fp.job`, always in the column matching the active step's `area_kind` |
|
||||
| Card density | Variant C — full info with mini-timeline |
|
||||
| State catalog | 13 mutually-exclusive states with precedence rules |
|
||||
| Operator focus | Plant-wide view, paired-station column + "mine" cards highlighted |
|
||||
| Backend touch | New `area_kind` Selection, new `card_state` compute, new `/fp/landing/plant_kanban` endpoint |
|
||||
| Frontend touch | New OWL component tree under `fp_plant_kanban` client action |
|
||||
| Rollout | Feature flag `x_fc_shopfloor_layout`, parallel deployment, flip default after entech validation |
|
||||
| Recipe-step scaling | Doesn't matter — 5-step or 50-step recipes both produce one card moving across 9 fixed columns |
|
||||
|
||||
The redesign solves the "one job in N columns" problem by re-anchoring grouping at the department level and decoupling the kanban from recipe step count. Every floor scenario in the audit + battle-test catalog (S1-S23) maps to one of the 13 documented states.
|
||||
|
||||
Implementation plan to follow.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.20.8.0',
|
||||
'version': '19.0.21.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -24,15 +24,37 @@
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code"><![CDATA[
|
||||
# Resolve in priority order: user pref → company default → Sale Orders fallback.
|
||||
# Resolve in priority order:
|
||||
# 1. user.x_fc_plating_landing_action_id (per-user override)
|
||||
# 2. company.x_fc_default_landing_action_id (company default)
|
||||
# 3. Shop Floor plant-view kanban (when x_fc_shopfloor_layout='v2')
|
||||
# 4. Sale Orders (when v2 flag unset / legacy)
|
||||
# 5. Process recipes (configurator absent)
|
||||
user = env.user
|
||||
target = False
|
||||
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
|
||||
target = user.x_fc_plating_landing_action_id.sudo()
|
||||
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
|
||||
target = env.company.x_fc_default_landing_action_id.sudo()
|
||||
|
||||
if not target:
|
||||
target = env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False)
|
||||
# 2026-05-23 — plant-view dispatch. Read the layout flag and pick the
|
||||
# appropriate Shop Floor action. Falls through to Sale Orders if no
|
||||
# client action is registered (e.g. shopfloor module not installed).
|
||||
layout = env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_plating_shopfloor.layout', default='legacy',
|
||||
)
|
||||
if layout == 'v2':
|
||||
target = env.ref(
|
||||
'fusion_plating_shopfloor.action_fp_plant_kanban',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
# Legacy or v2-missing → fall through to Sale Orders
|
||||
if not target:
|
||||
target = env.ref(
|
||||
'fusion_plating_configurator.action_fp_sale_orders',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
|
||||
if target:
|
||||
action = target.sudo().read()[0]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# 19.0.21.0.0 — Plant-view Shop Floor kanban redesign.
|
||||
# Backfill fp.work.centre.area_kind from the existing `kind` taxonomy so
|
||||
# every routing station has a defined Floor Column on day 1. Admins can
|
||||
# override afterwards via Configuration → Shop Setup → Routing Stations.
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Backfill area_kind on existing fp.work.centre rows.
|
||||
|
||||
Mapping is intentionally permissive: every existing kind maps to a
|
||||
sensible default. Unmapped (e.g. 'other') falls to 'plating' as the
|
||||
safe wet-shop catch-all and is logged for review.
|
||||
"""
|
||||
cr.execute("""
|
||||
UPDATE fp_work_centre
|
||||
SET area_kind = CASE kind
|
||||
WHEN 'wet_line' THEN 'plating'
|
||||
WHEN 'bake' THEN 'baking'
|
||||
WHEN 'mask' THEN 'masking'
|
||||
WHEN 'rack' THEN 'racking'
|
||||
WHEN 'inspect' THEN 'inspection'
|
||||
ELSE 'plating'
|
||||
END
|
||||
WHERE area_kind IS NULL
|
||||
""")
|
||||
|
||||
# Log any rows that landed on the catch-all so the admin can review.
|
||||
cr.execute("""
|
||||
SELECT id, name, code, kind
|
||||
FROM fp_work_centre
|
||||
WHERE area_kind = 'plating'
|
||||
AND kind = 'other'
|
||||
""")
|
||||
rows = cr.fetchall()
|
||||
if rows:
|
||||
_logger.warning(
|
||||
"%d fp.work.centre rows had kind='other' and were defaulted "
|
||||
"to area_kind='plating'; review and adjust if needed: %s",
|
||||
len(rows),
|
||||
', '.join(
|
||||
'%s (id=%s, code=%s)' % (r[1], r[0], r[2])
|
||||
for r in rows[:10]
|
||||
),
|
||||
)
|
||||
_logger.info("Backfilled area_kind on fp.work.centre")
|
||||
@@ -3,7 +3,10 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpJobStepMove(models.Model):
|
||||
@@ -74,6 +77,113 @@ class FpJobStepMove(models.Model):
|
||||
string='Transition Input Values',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Stamp last_activity_at on from_step + to_step so the plant-view
|
||||
idle gate (S16) sees moves as activity. Without this, a step that
|
||||
only ever gets moves (no chatter, no state edits) eventually
|
||||
trips the 8-hour idle warning falsely.
|
||||
"""
|
||||
moves = super().create(vals_list)
|
||||
Step = self.env['fp.job.step']
|
||||
step_ids = set()
|
||||
for m in moves:
|
||||
if m.from_step_id:
|
||||
step_ids.add(m.from_step_id.id)
|
||||
if m.to_step_id:
|
||||
step_ids.add(m.to_step_id.id)
|
||||
if step_ids:
|
||||
Step.browse(list(step_ids)).sudo().with_context(
|
||||
tracking_disable=True,
|
||||
).write({'last_activity_at': fields.Datetime.now()})
|
||||
return moves
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# S23 — required transition-input gate
|
||||
# ------------------------------------------------------------------
|
||||
# When the destination step has requires_transition_form=True, the
|
||||
# recipe author wants chain-of-custody attestations captured on the
|
||||
# move (location, photo, customer WO #, etc.). Same dormant-field
|
||||
# shape as S22's signoff bug — the field existed but nothing enforced
|
||||
# it. Callers (tablet controllers, future backend wizards) MUST call
|
||||
# _fp_check_transition_inputs_complete() after writing values to
|
||||
# transition_input_value_ids.
|
||||
#
|
||||
# We can't gate on create() because values are written in a separate
|
||||
# call after the move row. Model-level enforcement would require
|
||||
# either a deferred-commit pattern or a write hook; explicit caller
|
||||
# invocation is the simplest contract.
|
||||
|
||||
def _fp_missing_required_transition_inputs(self):
|
||||
"""Return the recordset of required transition_input prompts on
|
||||
the to_step's recipe node that have NO captured value on this
|
||||
move. Centralised helper — used by the gate below and by future
|
||||
diagnostics."""
|
||||
self.ensure_one()
|
||||
Prompt = self.env['fusion.plating.process.node.input']
|
||||
to_step = self.to_step_id
|
||||
if not to_step or not to_step.recipe_node_id:
|
||||
return Prompt
|
||||
if not to_step.requires_transition_form:
|
||||
return Prompt
|
||||
prompts = to_step.recipe_node_id.input_ids
|
||||
if 'kind' in prompts._fields:
|
||||
prompts = prompts.filtered(
|
||||
lambda i: i.kind == 'transition_input')
|
||||
if 'collect' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.collect)
|
||||
required_prompts = prompts.filtered(lambda i: i.required)
|
||||
if not required_prompts:
|
||||
return Prompt
|
||||
recorded_input_ids = set(
|
||||
self.transition_input_value_ids.mapped('node_input_id.id')
|
||||
)
|
||||
return required_prompts.filtered(
|
||||
lambda p: p.id not in recorded_input_ids
|
||||
)
|
||||
|
||||
def _fp_check_transition_inputs_complete(self):
|
||||
"""Raise UserError when the destination step has
|
||||
requires_transition_form=True and required transition_input
|
||||
prompts haven't been recorded on this move. Audit gate — same
|
||||
shape as fp.job.step._fp_check_step_inputs_complete (S21) and
|
||||
._fp_check_signoff_complete (S22).
|
||||
|
||||
Manager bypass via context fp_skip_transition_form=True
|
||||
(consistent with the existing audit-trail flag on the tablet
|
||||
controllers). Bypasses are posted to chatter on the move
|
||||
record naming the user.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_transition_form'):
|
||||
for move in self:
|
||||
if not move.to_step_id.requires_transition_form:
|
||||
continue
|
||||
move.message_post(body=Markup(_(
|
||||
'Transition-form gate bypassed by %s. '
|
||||
'Documented deviation — required prompts not '
|
||||
'recorded on this move.'
|
||||
)) % self.env.user.name)
|
||||
return
|
||||
for move in self:
|
||||
missing = move._fp_missing_required_transition_inputs()
|
||||
if not missing:
|
||||
continue
|
||||
names = ', '.join(
|
||||
'"%s"' % (p.name or '').strip() for p in missing
|
||||
)
|
||||
raise UserError(_(
|
||||
'Move to step "%(step)s" cannot be committed — '
|
||||
'%(n)s required transition prompt(s) not recorded: '
|
||||
'%(names)s. Fill them in the Move dialog before '
|
||||
'committing. Managers can override via context flag '
|
||||
'fp_skip_transition_form=True for documented '
|
||||
'deviations.'
|
||||
) % {
|
||||
'step': move.to_step_id.name,
|
||||
'n': len(missing),
|
||||
'names': names,
|
||||
})
|
||||
|
||||
|
||||
class FpJobStepMoveInputValue(models.Model):
|
||||
"""Captured value for one transition-input prompt.
|
||||
|
||||
@@ -48,6 +48,26 @@ class FpWorkCentre(models.Model):
|
||||
required=True,
|
||||
default='other',
|
||||
)
|
||||
area_kind = fields.Selection(
|
||||
[
|
||||
('receiving', 'Receiving'),
|
||||
('masking', 'Masking'),
|
||||
('blasting', 'Blasting'),
|
||||
('racking', 'Racking'),
|
||||
('plating', 'Plating'),
|
||||
('baking', 'Baking'),
|
||||
('de_racking', 'De-Racking'),
|
||||
('inspection', 'Final inspection'),
|
||||
('shipping', 'Shipping'),
|
||||
],
|
||||
string='Floor Column',
|
||||
help='Which Shop Floor column this work centre belongs to. '
|
||||
'Drives the plant-view kanban grouping — any job whose '
|
||||
'active step uses this work centre routes into this column. '
|
||||
'See docs/superpowers/specs/2026-05-23-shopfloor-plant-view-'
|
||||
'design.md §4.2 for the mapping rules.',
|
||||
index=True,
|
||||
)
|
||||
cost_per_hour = fields.Monetary(
|
||||
currency_field='currency_id',
|
||||
help='Used for fp.job.step cost rollups.',
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
<label>Estimated Duration (min)</label>
|
||||
<input type="number" class="form-control" min="0" step="1"
|
||||
t-att-value="state.selectedNode.estimated_duration || 0"
|
||||
t-on-change="(ev) => { state.selectedNode.estimated_duration = parseFloat(ev.target.value) || 0; }"/>
|
||||
t-on-change="(ev) => { state.selectedNode.estimated_duration = (+ev.target.value) || 0; }"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_re_field">
|
||||
@@ -380,7 +380,7 @@
|
||||
<label for="fp_re_workflow_state">Triggers Workflow State</label>
|
||||
<select id="fp_re_workflow_state"
|
||||
class="form-select"
|
||||
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value=""
|
||||
t-att-selected="!state.selectedNode.triggers_workflow_state_id">
|
||||
— None (use default-kind matching) —
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
t-if="state.workflowStates and state.workflowStates.length">
|
||||
<label>Triggers Workflow State</label>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value="" t-att-selected="!state.editTriggersWorkflowStateId">— None (use Step Type) —</option>
|
||||
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
|
||||
<option t-att-value="ws.id"
|
||||
@@ -598,7 +598,7 @@
|
||||
t-if="state.workflowStates and state.workflowStates.length">
|
||||
<label class="form-label">Triggers Workflow State</label>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value=""
|
||||
t-att-selected="!state.libraryEditor.triggers_workflow_state_id">
|
||||
— None (use default-kind matching) —
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.20.0',
|
||||
'version': '19.0.10.24.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# 19.0.10.24.0 — Plant-view Shop Floor kanban redesign.
|
||||
# Backfill fp.job.step.last_activity_at from write_date so existing
|
||||
# in-progress steps don't immediately trip the S16 idle-warning gate
|
||||
# (8 hours since last activity) on first compute after deploy.
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
UPDATE fp_job_step
|
||||
SET last_activity_at = write_date
|
||||
WHERE last_activity_at IS NULL
|
||||
""")
|
||||
cr.execute("SELECT count(*) FROM fp_job_step WHERE last_activity_at IS NULL")
|
||||
remaining = cr.fetchone()[0]
|
||||
if remaining:
|
||||
_logger.warning(
|
||||
"%d fp.job.step rows still have NULL last_activity_at after "
|
||||
"backfill (no write_date?). These will trip the idle gate "
|
||||
"on first compute.", remaining,
|
||||
)
|
||||
_logger.info("Backfilled last_activity_at on fp.job.step from write_date")
|
||||
@@ -10,6 +10,8 @@
|
||||
# qc_check_id is deferred to Task 2.7 (the underlying QC model still
|
||||
# lives in fusion_plating_bridge_mrp; we'll address its sourcing then).
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
@@ -20,6 +22,15 @@ from odoo.exceptions import UserError
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Plant-view kanban — fixed 9-column sequence (spec §4.1). The order
|
||||
# here is the visual order on the board AND the order in the
|
||||
# mini-timeline strip. Never reorder; columns are first-class identity.
|
||||
_COLUMN_SEQUENCE = [
|
||||
'receiving', 'masking', 'blasting', 'racking', 'plating',
|
||||
'baking', 'de_racking', 'inspection', 'shipping',
|
||||
]
|
||||
|
||||
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
@@ -138,6 +149,213 @@ class FpJob(models.Model):
|
||||
'defensive). Drives JobWorkspace landing focus.',
|
||||
)
|
||||
|
||||
# ===== 2026-05-23 Plant-view kanban — card_state + mini_timeline ====
|
||||
|
||||
card_state = fields.Char(
|
||||
string='Card State (plant view)',
|
||||
compute='_compute_card_state',
|
||||
store=True,
|
||||
index=True,
|
||||
help='One of 13 mutually-exclusive states driving the plant-view '
|
||||
'kanban card chrome. See spec §6 for the catalog and the '
|
||||
'explicit precedence dispatch. Stored for fast filter '
|
||||
'queries (count by state, filter "blocked", etc.).',
|
||||
)
|
||||
|
||||
mini_timeline_json = fields.Text(
|
||||
string='Mini-Timeline (JSON)',
|
||||
compute='_compute_mini_timeline_json',
|
||||
help='Serialized 9-element array, one per Shop Floor column, '
|
||||
'each {area, state, variant?}. Card UI reads this to render '
|
||||
'the bottom timeline strip without knowing recipe shape.',
|
||||
)
|
||||
|
||||
# ----- Precedence helpers (spec §6.2 + §9.4) -----------------------
|
||||
# Each returns a bool. _compute_card_state calls them in precedence
|
||||
# order and the first truthy one wins. Centralized here so future
|
||||
# audit-found states can be added by writing one new helper + one new
|
||||
# rule in the dispatcher.
|
||||
|
||||
def _fp_inbound_not_received(self):
|
||||
"""no_parts — job confirmed, customer's parts in transit."""
|
||||
self.ensure_one()
|
||||
if self.state != 'confirmed':
|
||||
return False
|
||||
so = self.sale_order_id
|
||||
if not so or 'x_fc_receiving_ids' not in so._fields:
|
||||
return False
|
||||
return any(r.state == 'draft' for r in so.x_fc_receiving_ids)
|
||||
|
||||
def _fp_has_open_hold(self):
|
||||
"""on_hold — fusion.plating.quality.hold open on this job."""
|
||||
self.ensure_one()
|
||||
if 'fusion.plating.quality.hold' not in self.env:
|
||||
return False
|
||||
Hold = self.env['fusion.plating.quality.hold']
|
||||
return bool(Hold.search_count([
|
||||
('job_id', '=', self.id),
|
||||
('state', '=', 'open'),
|
||||
]))
|
||||
|
||||
def _fp_has_pending_qc(self):
|
||||
"""awaiting_qc — quality check in draft / in_progress on this job."""
|
||||
self.ensure_one()
|
||||
if 'fusion.plating.quality.check' not in self.env:
|
||||
return False
|
||||
QC = self.env['fusion.plating.quality.check']
|
||||
return bool(QC.search_count([
|
||||
('job_id', '=', self.id),
|
||||
('state', 'in', ('draft', 'in_progress')),
|
||||
]))
|
||||
|
||||
def _fp_bake_window_due_soon(self, threshold_hours=1):
|
||||
"""bake_due — bake.window awaiting_bake with deadline < threshold."""
|
||||
self.ensure_one()
|
||||
if 'fusion.plating.bake.window' not in self.env:
|
||||
return False
|
||||
Window = self.env['fusion.plating.bake.window']
|
||||
cutoff = fields.Datetime.now() + datetime.timedelta(hours=threshold_hours)
|
||||
domain = [
|
||||
('state', '=', 'awaiting_bake'),
|
||||
('bake_required_by', '<=', cutoff),
|
||||
]
|
||||
# bake.window's link to a job varies across installs — fall back
|
||||
# to SO when no direct fp.job link exists.
|
||||
if 'job_id' in Window._fields:
|
||||
domain.append(('job_id', '=', self.id))
|
||||
elif self.sale_order_id and 'sale_order_id' in Window._fields:
|
||||
domain.append(('sale_order_id', '=', self.sale_order_id.id))
|
||||
else:
|
||||
return False
|
||||
return bool(Window.search_count(domain))
|
||||
|
||||
def _fp_is_mine(self, user=None):
|
||||
"""*_mine variants — active step's work centre is in operator's
|
||||
paired stations. MVP holds 1 row in paired_work_centre_ids; Phase 2
|
||||
multi-station picker can populate multiple."""
|
||||
self.ensure_one()
|
||||
user = user or self.env.user
|
||||
step = self.active_step_id
|
||||
if not step or not step.work_centre_id:
|
||||
return False
|
||||
if 'paired_work_centre_ids' not in user._fields:
|
||||
return False
|
||||
return step.work_centre_id.id in user.paired_work_centre_ids.ids
|
||||
|
||||
# ----- card_state compute -------------------------------------------
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'active_step_id',
|
||||
'active_step_id.state',
|
||||
'active_step_id.requires_signoff',
|
||||
'active_step_id.signoff_user_id',
|
||||
'active_step_id.last_activity_at',
|
||||
'active_step_id.area_kind',
|
||||
'active_step_id.recipe_node_id.default_kind',
|
||||
)
|
||||
def _compute_card_state(self):
|
||||
"""Dispatch matching spec §6.2 / §9.3 explicit precedence list."""
|
||||
for job in self:
|
||||
# Edge: no active step (all pending or all done)
|
||||
if not job.active_step_id:
|
||||
if (job.state == 'confirmed'
|
||||
and job._fp_inbound_not_received()):
|
||||
job.card_state = 'no_parts'
|
||||
else:
|
||||
job.card_state = 'contract_review'
|
||||
continue
|
||||
|
||||
step = job.active_step_id
|
||||
|
||||
# Rule 1 — no_parts
|
||||
if job._fp_inbound_not_received():
|
||||
job.card_state = 'no_parts'
|
||||
continue
|
||||
# Rule 2 — on_hold
|
||||
if job._fp_has_open_hold():
|
||||
job.card_state = 'on_hold'
|
||||
continue
|
||||
# Rule 3 — awaiting_signoff (S22)
|
||||
if (step.requires_signoff and step.state == 'done'
|
||||
and not step.signoff_user_id):
|
||||
job.card_state = 'awaiting_signoff'
|
||||
continue
|
||||
# Rule 4 — awaiting_qc
|
||||
if job._fp_has_pending_qc():
|
||||
job.card_state = 'awaiting_qc'
|
||||
continue
|
||||
# Rule 5 — bake_due
|
||||
if job._fp_bake_window_due_soon():
|
||||
job.card_state = 'bake_due'
|
||||
continue
|
||||
# Rule 6 — predecessor_locked
|
||||
if (step._fp_should_block_predecessors()
|
||||
and step._fp_has_unfinished_predecessors()):
|
||||
job.card_state = 'predecessor_locked'
|
||||
continue
|
||||
# Rule 7 — idle_warning (S16)
|
||||
if (step.state == 'in_progress'
|
||||
and step._fp_is_idle(threshold_hours=8)):
|
||||
job.card_state = 'idle_warning'
|
||||
continue
|
||||
# Rule 8 — done
|
||||
if step.area_kind == 'shipping' and job.state == 'done':
|
||||
job.card_state = 'done'
|
||||
continue
|
||||
# Rule 9 — contract_review
|
||||
if (step.recipe_node_id
|
||||
and step.recipe_node_id.default_kind == 'contract_review'):
|
||||
job.card_state = 'contract_review'
|
||||
continue
|
||||
# Rules 10/12 — running (mine vs not)
|
||||
if step.state == 'in_progress':
|
||||
job.card_state = ('running_mine' if job._fp_is_mine()
|
||||
else 'running')
|
||||
continue
|
||||
# Rules 11/13 — ready (mine vs not)
|
||||
if step.state == 'ready':
|
||||
job.card_state = ('ready_mine' if job._fp_is_mine()
|
||||
else 'ready')
|
||||
continue
|
||||
# Safe default
|
||||
job.card_state = 'ready'
|
||||
|
||||
# ----- mini-timeline compute ----------------------------------------
|
||||
|
||||
@api.depends(
|
||||
'step_ids.state',
|
||||
'step_ids.area_kind',
|
||||
'active_step_id',
|
||||
'card_state',
|
||||
)
|
||||
def _compute_mini_timeline_json(self):
|
||||
"""9-element JSON array, one per Shop Floor column."""
|
||||
for job in self:
|
||||
active_area = (job.active_step_id.area_kind
|
||||
if job.active_step_id else None)
|
||||
timeline = []
|
||||
for area in _COLUMN_SEQUENCE:
|
||||
steps_in_area = job.step_ids.filtered(
|
||||
lambda s: s.area_kind == area,
|
||||
)
|
||||
if not steps_in_area:
|
||||
# Recipe doesn't visit this area — show as upcoming
|
||||
# to keep visual alignment across cards
|
||||
timeline.append({'area': area, 'state': 'upcoming'})
|
||||
continue
|
||||
if all(s.state in ('done', 'skipped') for s in steps_in_area):
|
||||
timeline.append({'area': area, 'state': 'done'})
|
||||
elif area == active_area:
|
||||
timeline.append({
|
||||
'area': area,
|
||||
'state': 'current',
|
||||
'variant': job.card_state or '',
|
||||
})
|
||||
else:
|
||||
timeline.append({'area': area, 'state': 'upcoming'})
|
||||
job.mini_timeline_json = json.dumps(timeline)
|
||||
|
||||
@api.depends(
|
||||
'date_deadline',
|
||||
'step_ids.state',
|
||||
@@ -1485,25 +1703,26 @@ class FpJob(models.Model):
|
||||
def _fp_create_portal_job(self):
|
||||
"""Create the fusion.plating.portal.job mirror record.
|
||||
|
||||
Initial state derived from the fp.job state via the same map
|
||||
used by write() — so a job that's already 'in_progress' when
|
||||
the portal mirror is created (e.g. a manual catch-up create)
|
||||
doesn't reset to 'received'.
|
||||
Seeded with 'received' then handed to
|
||||
`fusion.plating.portal.job._fp_recompute_portal_state` — that
|
||||
helper is the single source of truth for portal state and
|
||||
derives it from the WO + shipment + invoice signals, so a
|
||||
catch-up create on an already-in-progress job lands on the
|
||||
right state rather than stuck on 'received'.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.portal_job_id:
|
||||
return # already exists — idempotent
|
||||
Portal = self.env['fusion.plating.portal.job'].sudo()
|
||||
initial_state = self._FP_JOB_STATE_TO_PORTAL_STATE.get(
|
||||
self.state, 'received',
|
||||
)
|
||||
portal = Portal.create({
|
||||
'name': self.name,
|
||||
'partner_id': self.partner_id.id,
|
||||
'state': initial_state,
|
||||
'state': 'received',
|
||||
'x_fc_job_id': self.id,
|
||||
})
|
||||
self.portal_job_id = portal.id
|
||||
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||
portal._fp_recompute_portal_state()
|
||||
|
||||
def _fp_create_qc_check_if_needed(self):
|
||||
"""If customer has x_fc_requires_qc=True, spawn a QC check via
|
||||
@@ -1528,9 +1747,17 @@ class FpJob(models.Model):
|
||||
try:
|
||||
QC.create_for_job(self)
|
||||
except Exception as e:
|
||||
# F7 — surface silent failures on the job's chatter so the
|
||||
# operator sees the gap and creates the QC manually. Logging
|
||||
# to /var/log/odoo/odoo-server.log alone meant nobody noticed
|
||||
# (2CM's WH/JOB/00002 silently lost its QC check this way).
|
||||
_logger.warning(
|
||||
"Job %s: create_for_job failed: %s", self.name, e,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'QC check auto-create failed: %(e)s. '
|
||||
'Create the QC check manually from the Quality menu.'
|
||||
) % {'e': e})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# button_mark_done — Task 2.8
|
||||
@@ -1745,10 +1972,18 @@ class FpJob(models.Model):
|
||||
# partner_id is the customer.
|
||||
Template._dispatch(event, self, partner=self.partner_id)
|
||||
except Exception as e:
|
||||
# F7 — surface on chatter. A missed customer notification
|
||||
# (e.g. "your parts have shipped") is invisible to the
|
||||
# operator until the customer complains; the chatter post
|
||||
# gives accounting / sales a recoverable signal.
|
||||
_logger.warning(
|
||||
"Job %s: notification %s dispatch failed: %s",
|
||||
self.name, event, e,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Notification dispatch failed for event "%(ev)s": %(e)s. '
|
||||
'Send manually if the customer expected an update.'
|
||||
) % {'ev': event, 'e': e})
|
||||
|
||||
def _fp_create_delivery(self):
|
||||
"""Create a draft fusion.plating.delivery linked to this job.
|
||||
@@ -1787,9 +2022,16 @@ class FpJob(models.Model):
|
||||
delivery = Delivery.create(vals)
|
||||
self.delivery_id = delivery.id
|
||||
except Exception as e:
|
||||
# F7 — surface on chatter. Without this, the operator sees
|
||||
# "Job marked done" but no delivery record exists, and the
|
||||
# next milestone advance fails silently.
|
||||
_logger.warning(
|
||||
"Job %s: failed to auto-create delivery: %s", self.name, e,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Delivery auto-create failed: %(e)s. '
|
||||
'Create the delivery manually from the Logistics menu.'
|
||||
) % {'e': e})
|
||||
|
||||
def _fp_resolve_delivery_defaults(self, Delivery):
|
||||
"""Build the create-vals for a fresh delivery, OR the
|
||||
|
||||
@@ -17,6 +17,62 @@ from odoo.exceptions import UserError
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Mapping from fp.process.node.default_kind → fp.work.centre.area_kind.
|
||||
# Used as fallback by fp.job.step.area_kind compute when the step has no
|
||||
# work_centre_id or its work_centre has no area_kind set. Authoritative
|
||||
# source per the plant-view spec §4.2.
|
||||
# 2026-05-23 — Shop Floor plant-view redesign.
|
||||
_STEP_KIND_TO_AREA = {
|
||||
# Receiving (admin / pre-physical-work)
|
||||
'receiving': 'receiving',
|
||||
'incoming_inspection': 'receiving',
|
||||
'contract_review': 'receiving',
|
||||
'gating': 'receiving',
|
||||
'ready_for_processing': 'receiving',
|
||||
# Masking
|
||||
'masking': 'masking',
|
||||
# Blasting
|
||||
'blasting': 'blasting',
|
||||
'bead_blast': 'blasting',
|
||||
'media_blast': 'blasting',
|
||||
# Racking
|
||||
'racking': 'racking',
|
||||
# Plating (everything wet — rolled up into one column per spec D3)
|
||||
'soak_clean': 'plating',
|
||||
'electroclean': 'plating',
|
||||
'acid_dip': 'plating',
|
||||
'etch': 'plating',
|
||||
'desmut': 'plating',
|
||||
'zincate': 'plating',
|
||||
'rinse': 'plating',
|
||||
'water_break_test': 'plating',
|
||||
'activation': 'plating',
|
||||
'e_nickel_plate': 'plating',
|
||||
'chrome': 'plating',
|
||||
'anodize': 'plating',
|
||||
'black_oxide': 'plating',
|
||||
'drying': 'plating',
|
||||
# Baking
|
||||
'bake': 'baking',
|
||||
'oven_bake': 'baking',
|
||||
'post_bake_relief': 'baking',
|
||||
# De-Racking (folds in de-masking per spec D4)
|
||||
'de_rack': 'de_racking',
|
||||
'de_mask': 'de_racking',
|
||||
'unrack': 'de_racking',
|
||||
# Final inspection (post-plate inspection / FAIR / thickness QC)
|
||||
'inspection': 'inspection',
|
||||
'final_inspection': 'inspection',
|
||||
'post_plate_inspection':'inspection',
|
||||
'thickness_qc': 'inspection',
|
||||
'fair': 'inspection',
|
||||
'dimensional_check': 'inspection',
|
||||
# Shipping
|
||||
'shipping': 'shipping',
|
||||
'pack_ship': 'shipping',
|
||||
}
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
_inherit = 'fp.job.step'
|
||||
|
||||
@@ -53,6 +109,16 @@ class FpJobStep(models.Model):
|
||||
# Free-flow recipe — only the legacy per-step flag still gates.
|
||||
return bool(self.requires_predecessor_done)
|
||||
|
||||
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
|
||||
to drive the plant-view predecessor_locked card state."""
|
||||
self.ensure_one()
|
||||
return bool(self.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence < self.sequence
|
||||
and s.state not in ('done', 'skipped', 'cancelled')
|
||||
))
|
||||
|
||||
can_start = fields.Boolean(
|
||||
string='Can Start',
|
||||
compute='_compute_can_start',
|
||||
@@ -85,6 +151,73 @@ class FpJobStep(models.Model):
|
||||
)
|
||||
step.can_start = not bool(blocking)
|
||||
|
||||
# ===== 2026-05-23 plant-view redesign — area_kind + activity =========
|
||||
area_kind = fields.Selection(
|
||||
[
|
||||
('receiving', 'Receiving'),
|
||||
('masking', 'Masking'),
|
||||
('blasting', 'Blasting'),
|
||||
('racking', 'Racking'),
|
||||
('plating', 'Plating'),
|
||||
('baking', 'Baking'),
|
||||
('de_racking', 'De-Racking'),
|
||||
('inspection', 'Final inspection'),
|
||||
('shipping', 'Shipping'),
|
||||
],
|
||||
string='Floor Column',
|
||||
compute='_compute_area_kind',
|
||||
store=True,
|
||||
index=True,
|
||||
help='Which Shop Floor column this step belongs to. Resolved as: '
|
||||
'(1) work_centre.area_kind if set; else (2) fallback to '
|
||||
'_STEP_KIND_TO_AREA[recipe_node.default_kind]; else (3) the '
|
||||
'safe catch-all "plating". Drives plant-view kanban grouping.',
|
||||
)
|
||||
|
||||
@api.depends('work_centre_id.area_kind', 'recipe_node_id.default_kind')
|
||||
def _compute_area_kind(self):
|
||||
for step in self:
|
||||
if step.work_centre_id and step.work_centre_id.area_kind:
|
||||
step.area_kind = step.work_centre_id.area_kind
|
||||
continue
|
||||
kind = step.recipe_node_id.default_kind if step.recipe_node_id else False
|
||||
if kind and kind in _STEP_KIND_TO_AREA:
|
||||
step.area_kind = _STEP_KIND_TO_AREA[kind]
|
||||
continue
|
||||
step.area_kind = 'plating'
|
||||
|
||||
last_activity_at = fields.Datetime(
|
||||
string='Last Activity',
|
||||
index=True,
|
||||
help='Stamped on any state transition, move-out from this step, '
|
||||
'or chatter post. Drives the S16 idle-warning card state '
|
||||
'(in_progress with no activity for 8+ hours).',
|
||||
)
|
||||
|
||||
def _fp_is_idle(self, threshold_hours=8):
|
||||
"""True when this step is in_progress AND last_activity_at is older
|
||||
than `threshold_hours`. Drives the idle_warning card state."""
|
||||
self.ensure_one()
|
||||
if self.state != 'in_progress':
|
||||
return False
|
||||
if not self.last_activity_at:
|
||||
return False
|
||||
delta = fields.Datetime.now() - self.last_activity_at
|
||||
return delta.total_seconds() > threshold_hours * 3600
|
||||
|
||||
def message_post(self, **kwargs):
|
||||
"""Override: stamp last_activity_at so an operator note counts as
|
||||
activity (defeats false-positive idle warnings during long bakes
|
||||
where the only sign of life is the periodic operator note)."""
|
||||
res = super().message_post(**kwargs)
|
||||
try:
|
||||
self.sudo().with_context(tracking_disable=True).write({
|
||||
'last_activity_at': fields.Datetime.now(),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.debug("last_activity_at stamp on message_post failed: %s", exc)
|
||||
return res
|
||||
|
||||
# Gate visualizer — drives the OWL GateViz component on the tablet.
|
||||
# Returns kind of blocker + human reason + optional (model, id) jump
|
||||
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
||||
@@ -286,6 +419,14 @@ class FpJobStep(models.Model):
|
||||
if new_uid == old_uid:
|
||||
continue
|
||||
post_for.append((step, old_uid, new_uid))
|
||||
# Plant-view: stamp last_activity_at on every state transition so
|
||||
# the S16 idle gate has fresh data. Only stamp when state is in
|
||||
# vals AND it's actually changing (avoid no-op writes spamming
|
||||
# the timestamp).
|
||||
if 'state' in vals and 'last_activity_at' not in vals:
|
||||
new_state = vals['state']
|
||||
if any(step.state != new_state for step in self):
|
||||
vals = dict(vals, last_activity_at=fields.Datetime.now())
|
||||
result = super().write(vals)
|
||||
Users = self.env['res.users']
|
||||
for step, old_uid, new_uid in post_for:
|
||||
@@ -542,29 +683,179 @@ class FpJobStep(models.Model):
|
||||
return candidates[:1] or self.env['fp.job.step']
|
||||
|
||||
def _fp_has_uncaptured_step_inputs(self):
|
||||
"""True when the recipe step defines step_input prompts AND
|
||||
the user hasn't already saved values for this step's current
|
||||
run via the Record Inputs wizard.
|
||||
"""True when the recipe step has REQUIRED step_input prompts
|
||||
whose values haven't been recorded yet.
|
||||
|
||||
Previously this checked "any move with input values exists since
|
||||
date_started" — too coarse. Operator clicked Save on the dialog
|
||||
after filling ONE prompt and the helper went quiet, letting
|
||||
action_finish_and_advance bypass the dialog re-open even when
|
||||
4 of 5 required prompts were still empty (WO-30051 / Riya 2026-05-23).
|
||||
Now we count actual coverage per required input across every
|
||||
move recorded against this step.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return bool(self._fp_missing_required_step_inputs())
|
||||
|
||||
def _fp_missing_required_step_inputs(self):
|
||||
"""Return the recordset of REQUIRED step_input prompts on this
|
||||
step's recipe node that have NO value recorded across any move
|
||||
from this step. Centralised helper — used by both
|
||||
_fp_has_uncaptured_step_inputs (re-open dialog) and
|
||||
_fp_check_step_inputs_complete (raise UserError on finish).
|
||||
"""
|
||||
self.ensure_one()
|
||||
node = self.recipe_node_id
|
||||
Prompt = self.env['fusion.plating.process.node.input']
|
||||
if not node:
|
||||
return False
|
||||
return Prompt
|
||||
prompts = node.input_ids
|
||||
if 'kind' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
||||
if not prompts:
|
||||
return False
|
||||
# Has the operator already recorded values during this run?
|
||||
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
|
||||
# for this step since date_started.
|
||||
Move = self.env['fp.job.step.move']
|
||||
already = Move.search_count([
|
||||
('from_step_id', '=', self.id),
|
||||
('transfer_type', '=', 'step'),
|
||||
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
|
||||
])
|
||||
return already == 0
|
||||
if 'collect' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.collect)
|
||||
required_prompts = prompts.filtered(lambda i: i.required)
|
||||
if not required_prompts:
|
||||
return Prompt
|
||||
Value = self.env['fp.job.step.move.input.value']
|
||||
recorded_input_ids = set(Value.search([
|
||||
('move_id.from_step_id', '=', self.id),
|
||||
('node_input_id', 'in', required_prompts.ids),
|
||||
]).mapped('node_input_id.id'))
|
||||
return required_prompts.filtered(
|
||||
lambda p: p.id not in recorded_input_ids
|
||||
)
|
||||
|
||||
def _fp_autosign_if_required(self):
|
||||
"""Auto-set signoff_user_id to the current user when the step has
|
||||
requires_signoff=True and no signoff has been recorded yet.
|
||||
|
||||
Called from button_finish just before the signoff gate. Captures
|
||||
WHO finished the step as the signer-of-record. For shops that
|
||||
need separate operator+supervisor sign-off, call action_signoff()
|
||||
explicitly from a supervisor session BEFORE the operator clicks
|
||||
Finish — that pre-sets signoff_user_id and this helper becomes a
|
||||
no-op.
|
||||
|
||||
Idempotent — never overwrites an existing signoff_user_id, so a
|
||||
manager pre-signing via action_signoff is preserved through the
|
||||
operator's Finish click.
|
||||
"""
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
continue
|
||||
if step.signoff_user_id:
|
||||
continue # pre-signed (likely by a supervisor)
|
||||
# Use sudo because signoff_user_id is readonly=True at field
|
||||
# level; we still capture env.user.id (not SUPERUSER_ID) so
|
||||
# the audit trail shows who actually clicked.
|
||||
step.sudo().write({'signoff_user_id': self.env.user.id})
|
||||
|
||||
def _fp_check_signoff_complete(self):
|
||||
"""Raise UserError if the step has requires_signoff=True and
|
||||
signoff_user_id IS NULL. Aerospace / Nadcap need a named signer
|
||||
on every sign-off-required step; an unset signer breaks the
|
||||
audit chain.
|
||||
|
||||
Normally _fp_autosign_if_required (called from button_finish
|
||||
immediately before this gate) populates signoff_user_id with the
|
||||
finisher's id, so this gate only fires when:
|
||||
- The step is being finished via a code path that bypasses
|
||||
autosign (e.g. a migration script writing state='done').
|
||||
- The user has no env.user (background cron with no uid set).
|
||||
|
||||
Manager bypass via context fp_skip_signoff_gate=True for
|
||||
documented customer deviations. Bypasses are posted to chatter
|
||||
naming the user.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_signoff_gate'):
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
continue
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Sign-off gate bypassed on step "<b>%s</b>" by %s. '
|
||||
'Documented deviation — no signer recorded.'
|
||||
)) % (step.name, self.env.user.name))
|
||||
return
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
continue
|
||||
if step.signoff_user_id:
|
||||
continue
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot be finished — sign-off required '
|
||||
'but no signer recorded. Click "Sign Off" on the step '
|
||||
'(or have your supervisor sign before you finish). '
|
||||
'Managers can override via context flag '
|
||||
'fp_skip_signoff_gate=True for documented deviations.'
|
||||
) % {'step': step.name})
|
||||
|
||||
def action_signoff(self):
|
||||
"""Explicit sign-off action — sets signoff_user_id = env.user.id
|
||||
for the calling user. Use case: a supervisor reviews an operator's
|
||||
work and signs off BEFORE the operator clicks Finish. Once signed,
|
||||
the operator's Finish click passes the signoff gate without auto-
|
||||
assigning a different signer.
|
||||
|
||||
Idempotent — re-clicking by the same user is a no-op. A DIFFERENT
|
||||
user re-signing overwrites the prior signer (and chatters the change)
|
||||
so a senior supervisor can override a junior's premature sign-off
|
||||
without leaving the audit trail mute.
|
||||
"""
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
raise UserError(_(
|
||||
'Step "%s" does not require sign-off — nothing to sign.'
|
||||
) % step.name)
|
||||
prior = step.signoff_user_id
|
||||
if prior and prior.id == self.env.user.id:
|
||||
continue # idempotent
|
||||
step.sudo().write({'signoff_user_id': self.env.user.id})
|
||||
if prior:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Sign-off on step "<b>%s</b>" reassigned from %s to %s.'
|
||||
)) % (step.name, prior.name, self.env.user.name))
|
||||
else:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Step "<b>%s</b>" signed off by %s.'
|
||||
)) % (step.name, self.env.user.name))
|
||||
return True
|
||||
|
||||
def _fp_check_step_inputs_complete(self):
|
||||
"""Raise UserError if the step has REQUIRED step_input prompts
|
||||
that haven't been recorded yet. AS9100 / Nadcap need a complete
|
||||
per-step data trail; finishing a step with missing prompts breaks
|
||||
the audit chain.
|
||||
|
||||
Manager bypass via context fp_skip_required_inputs_gate=True
|
||||
(e.g. paper-form catch-up or documented customer deviation).
|
||||
Bypasses are posted to chatter naming the user.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_required_inputs_gate'):
|
||||
for step in self:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Required-inputs gate bypassed on step "<b>%s</b>" by %s. '
|
||||
'Documented deviation — review the step\'s prompts.'
|
||||
)) % (step.name, self.env.user.name))
|
||||
return
|
||||
for step in self:
|
||||
missing = step._fp_missing_required_step_inputs()
|
||||
if not missing:
|
||||
continue
|
||||
names = ', '.join('"%s"' % (p.name or '').strip() for p in missing)
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot be finished — %(n)s required '
|
||||
'input(s) not recorded yet: %(names)s. '
|
||||
'Click "Record Inputs" on the step row to enter the '
|
||||
'missing values, then finish. '
|
||||
'Managers can override via context flag '
|
||||
'fp_skip_required_inputs_gate=True for documented '
|
||||
'deviations.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'n': len(missing),
|
||||
'names': names,
|
||||
})
|
||||
|
||||
def _fp_open_input_wizard(self, advance_after=False):
|
||||
"""Open the Record Inputs OWL dialog (Sub 12e v4).
|
||||
@@ -593,93 +884,12 @@ class FpJobStep(models.Model):
|
||||
# _fp_open_input_wizard above adds the advance_after pathway used
|
||||
# only by action_finish_and_advance.
|
||||
|
||||
def button_finish(self):
|
||||
"""Override to:
|
||||
1) Auto-spawn a bake.window when a wet plating step finishes
|
||||
on a recipe that requires hydrogen-embrittlement relief
|
||||
(AS9100 / Nadcap compliance). Bake fields live on the
|
||||
recipe root post-promote-customer-spec.
|
||||
2) Post a chatter warning when duration_actual exceeds 1.5×
|
||||
duration_expected — silent overruns are a red flag for
|
||||
scheduling and costing.
|
||||
|
||||
Both actions are idempotent and never block the finish itself.
|
||||
"""
|
||||
result = super().button_finish()
|
||||
BW = self.env['fusion.plating.bake.window']
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
for step in self:
|
||||
if step.state != 'done':
|
||||
continue
|
||||
# Duration-overrun chatter alert.
|
||||
if step.duration_expected and step.duration_actual:
|
||||
ratio = step.duration_actual / step.duration_expected
|
||||
if ratio >= 1.5:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
|
||||
'expected %.0f min, actual %.0f min. Investigate: '
|
||||
'equipment issue, training gap, or recipe time '
|
||||
'estimate too tight.'
|
||||
)) % (step.name, ratio, step.duration_expected,
|
||||
step.duration_actual))
|
||||
recipe_root = step.job_id.recipe_id
|
||||
if not recipe_root:
|
||||
continue
|
||||
requires = getattr(recipe_root, 'requires_bake_relief', False)
|
||||
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
|
||||
if not requires or not window_hrs:
|
||||
continue
|
||||
# Trigger only on the actual plating-out step. We want
|
||||
# exactly ONE bake.window per job (not one per step that
|
||||
# happens to have "plate" in the name). Heuristic:
|
||||
# - step.kind == 'wet' (clean, recipe-authored signal); OR
|
||||
# - the step name contains "plating" as a word
|
||||
# Explicit excludes: inspection / bake / mask / rack steps
|
||||
# whose names might happen to mention plating in passing
|
||||
# (e.g. "Post-plate Inspection").
|
||||
name_l = (step.name or '').lower()
|
||||
kind_match = step.kind == 'wet'
|
||||
name_match = bool(re.search(r'\bplating\b', name_l))
|
||||
excluded = any(kw in name_l for kw in (
|
||||
'inspect', 'inspection', 'bake', 'mask', 'rack',
|
||||
))
|
||||
if (not kind_match and not name_match) or excluded:
|
||||
continue
|
||||
# Idempotency — only one bake.window per (job, step).
|
||||
existing = BW.sudo().search([
|
||||
('part_ref', '=', step.job_id.name),
|
||||
('lot_ref', '=', f'step-{step.id}'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
# Pick a bath: step.bath_id wins; fall back to the first
|
||||
# active bath in the facility (best-effort — operator can
|
||||
# correct on the bake.window record).
|
||||
bath = step.bath_id or Bath.sudo().search(
|
||||
[('facility_id', '=', step.facility_id.id)], limit=1,
|
||||
) if step.facility_id else False
|
||||
if not bath:
|
||||
bath = Bath.sudo().search([], limit=1)
|
||||
if not bath:
|
||||
_logger.warning(
|
||||
'Step %s: bake-window auto-spawn skipped — no bath '
|
||||
'configured.', step.name,
|
||||
)
|
||||
continue
|
||||
bw = BW.sudo().create({
|
||||
'bath_id': bath.id,
|
||||
'plate_exit_time': step.date_finished or fields.Datetime.now(),
|
||||
'window_hours': window_hrs,
|
||||
'part_ref': step.job_id.name,
|
||||
'lot_ref': f'step-{step.id}',
|
||||
'customer_ref': step.job_id.partner_id.display_name or '',
|
||||
'quantity': int(step.job_id.qty or 0),
|
||||
})
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Bake window <b>%s</b> auto-created — %.1fh window from '
|
||||
'plate exit. Required by %s.'
|
||||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||||
return result
|
||||
# NOTE — the earlier duplicate `button_finish` definition that held
|
||||
# the duration-overrun + bake.window auto-spawn logic has been merged
|
||||
# into the canonical button_finish further down (line ~1130). Python
|
||||
# was silently keeping only the LAST definition in this class body,
|
||||
# so the bake.window auto-spawn was dead code for the entire WO-30051
|
||||
# era. Don't re-introduce a second button_finish here.
|
||||
|
||||
# ==================================================================
|
||||
# Phase 2 multi-serial — auto-promote serials on step transitions
|
||||
@@ -1070,18 +1280,112 @@ class FpJobStep(models.Model):
|
||||
return result
|
||||
|
||||
def button_finish(self):
|
||||
# Policy B — block until QA-005 complete (when customer requires it).
|
||||
"""Canonical button_finish — gates first, then super(), then
|
||||
post-finish side effects.
|
||||
|
||||
Gates (raise UserError, blocking finish):
|
||||
- Required step_input prompts recorded (S21 / WO-30051 fix).
|
||||
Manager bypass: fp_skip_required_inputs_gate=True.
|
||||
- Sign-off recorded when recipe step has requires_signoff=True
|
||||
(S22 / F1 audit fix). Auto-sign captures the finisher when
|
||||
no supervisor has pre-signed. Manager bypass:
|
||||
fp_skip_signoff_gate=True.
|
||||
- Contract Review (QA-005) complete when customer requires it.
|
||||
- Receiving gate — parts physically on site for this WO.
|
||||
(Racking-inspection gate removed — racking is a recipe step
|
||||
now, not a separate workflow. _fp_check_racking_inspection_
|
||||
complete() is kept as a helper for diagnostics.)
|
||||
|
||||
Post-finish (idempotent, never blocks):
|
||||
- Promote attached serials from in_process -> inspected on the
|
||||
terminal step of the job.
|
||||
- Chatter warning when duration_actual >= 1.5x duration_expected.
|
||||
- Auto-spawn a bake.window for wet plating steps on recipes
|
||||
flagged requires_bake_relief.
|
||||
"""
|
||||
# ----- Gates ----------------------------------------------------
|
||||
# Order matters: cheapest checks first. Required-inputs is a pure
|
||||
# ORM query; contract review and receiving may touch related models.
|
||||
self._fp_check_step_inputs_complete()
|
||||
# Sign-off: auto-capture the finisher's uid first (no-op when a
|
||||
# supervisor pre-signed via action_signoff), THEN gate. Gate only
|
||||
# fires when both autosign and explicit sign-off skipped (e.g.
|
||||
# migration scripts, background crons).
|
||||
self._fp_autosign_if_required()
|
||||
self._fp_check_signoff_complete()
|
||||
self._fp_check_contract_review_complete()
|
||||
# Receiving gate — same helper as button_start, exempts CR steps.
|
||||
self._fp_check_receiving_gate()
|
||||
# NOTE: racking inspection gate removed — racking is now a recipe
|
||||
# step, not a separate inspection workflow. _fp_check_racking_
|
||||
# inspection_complete() is kept as a helper for diagnostics but
|
||||
# no longer enforced from button_finish.
|
||||
|
||||
result = super().button_finish()
|
||||
|
||||
# ----- Post-finish side effects --------------------------------
|
||||
BW = self.env['fusion.plating.bake.window']
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
for step in self:
|
||||
if step.state == 'done':
|
||||
step._fp_promote_serials_on_finish()
|
||||
if step.state != 'done':
|
||||
continue
|
||||
step._fp_promote_serials_on_finish()
|
||||
# Duration-overrun chatter alert.
|
||||
if step.duration_expected and step.duration_actual:
|
||||
ratio = step.duration_actual / step.duration_expected
|
||||
if ratio >= 1.5:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
|
||||
'expected %.0f min, actual %.0f min. Investigate: '
|
||||
'equipment issue, training gap, or recipe time '
|
||||
'estimate too tight.'
|
||||
)) % (step.name, ratio, step.duration_expected,
|
||||
step.duration_actual))
|
||||
# Bake-window auto-spawn — wet plating step + recipe flagged
|
||||
# requires_bake_relief. Heuristic identifies the actual
|
||||
# plate-out step (kind=wet OR "plating" as a word in name),
|
||||
# excluding inspection/bake/mask/rack steps that mention
|
||||
# plating in passing (e.g. "Post-plate Inspection").
|
||||
recipe_root = step.job_id.recipe_id
|
||||
if not recipe_root:
|
||||
continue
|
||||
requires = getattr(recipe_root, 'requires_bake_relief', False)
|
||||
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
|
||||
if not requires or not window_hrs:
|
||||
continue
|
||||
name_l = (step.name or '').lower()
|
||||
kind_match = step.kind == 'wet'
|
||||
name_match = bool(re.search(r'\bplating\b', name_l))
|
||||
excluded = any(kw in name_l for kw in (
|
||||
'inspect', 'inspection', 'bake', 'mask', 'rack',
|
||||
))
|
||||
if (not kind_match and not name_match) or excluded:
|
||||
continue
|
||||
existing = BW.sudo().search([
|
||||
('part_ref', '=', step.job_id.name),
|
||||
('lot_ref', '=', f'step-{step.id}'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
bath = step.bath_id or Bath.sudo().search(
|
||||
[('facility_id', '=', step.facility_id.id)], limit=1,
|
||||
) if step.facility_id else False
|
||||
if not bath:
|
||||
bath = Bath.sudo().search([], limit=1)
|
||||
if not bath:
|
||||
_logger.warning(
|
||||
'Step %s: bake-window auto-spawn skipped — no bath '
|
||||
'configured.', step.name,
|
||||
)
|
||||
continue
|
||||
bw = BW.sudo().create({
|
||||
'bath_id': bath.id,
|
||||
'plate_exit_time': step.date_finished or fields.Datetime.now(),
|
||||
'window_hours': window_hrs,
|
||||
'part_ref': step.job_id.name,
|
||||
'lot_ref': f'step-{step.id}',
|
||||
'customer_ref': step.job_id.partner_id.display_name or '',
|
||||
'quantity': int(step.job_id.qty or 0),
|
||||
})
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Bake window <b>%s</b> auto-created — %.1fh window from '
|
||||
'plate exit. Required by %s.'
|
||||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||||
return result
|
||||
|
||||
# ==================================================================
|
||||
|
||||
@@ -433,6 +433,31 @@ export class FpRecordInputsDialog extends Component {
|
||||
this.state.rows.splice(idx, 1);
|
||||
}
|
||||
|
||||
// Mirrors fp.job.step.input.wizard.line._has_value() Python helper.
|
||||
// Critical: the wizard SKIPS rows where _has_value() is False when
|
||||
// creating fp.job.step.move.input.value records, so the server-side
|
||||
// required-inputs gate considers them "not recorded". This client
|
||||
// check must match that semantic exactly or the server will reject
|
||||
// saves the operator thought were complete.
|
||||
_fpRowHasValue(row) {
|
||||
if (row.input_type === "photo") return !!row.photo_value;
|
||||
if (row.input_type === "multi_point_thickness") {
|
||||
return !!(row.point_1 || row.point_2 || row.point_3
|
||||
|| row.point_4 || row.point_5);
|
||||
}
|
||||
if (row.input_type === "bath_chemistry_panel") {
|
||||
return !!(row.panel_ph || row.panel_concentration
|
||||
|| row.panel_temperature || row.panel_bath_id);
|
||||
}
|
||||
if (row.input_type === "pass_fail") return !!row._passfail_chosen;
|
||||
// Boolean: value_boolean===true counts; untouched/false is
|
||||
// treated as no-value to match Python `any([..., self.value_
|
||||
// boolean, ...])`. Operators MUST affirmatively check the box.
|
||||
return !!(row.value_text || row.value_number
|
||||
|| row.value_boolean || row.value_date
|
||||
|| row.value_min || row.value_max);
|
||||
}
|
||||
|
||||
// The "current" initials value across all rows — a row counts as a
|
||||
// signature/initials field when ``_fpIsInitialsField`` is true.
|
||||
// Returns the most-recently-set value (last write wins) or empty.
|
||||
@@ -477,6 +502,26 @@ export class FpRecordInputsDialog extends Component {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Required-prompt gate when finishing the step (advanceAfter=true).
|
||||
// Mirrors fp.job.step._fp_check_step_inputs_complete server-side
|
||||
// so the operator sees the missing fields instantly instead of
|
||||
// getting a server roundtrip error after the save commits. Partial
|
||||
// saves are still allowed when the dialog is opened from the
|
||||
// per-row Record button (advanceAfter=false).
|
||||
if (this.props.advanceAfter) {
|
||||
const missing = this.state.rows
|
||||
.filter((r) => r.required && !this._fpRowHasValue(r))
|
||||
.map((r) => r.name || _t("(unnamed)"));
|
||||
if (missing.length) {
|
||||
this.notification.add(
|
||||
_t("Cannot finish step — %n required prompt(s) missing: %list")
|
||||
.replace("%n", missing.length)
|
||||
.replace("%list", missing.map((n) => `"${n}"`).join(", ")),
|
||||
{ type: "danger", sticky: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.state.saving = true;
|
||||
const payload = this.state.rows.map((r) => {
|
||||
// When the prompt expects a range entry (min + max readings),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.30.2.0',
|
||||
'version': '19.0.31.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
@@ -108,6 +108,33 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/shopfloor_landing.js',
|
||||
# ---- Plant View Kanban (2026-05-23 redesign) ---------------
|
||||
# Tokens MUST load first (project rule 8: SCSS @import is
|
||||
# forbidden in Odoo 19 custom code; manifest order is the
|
||||
# concatenation order, and tokens carry the $plant-* vars
|
||||
# used by every component partial below).
|
||||
'fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_mini_timeline.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_column_header.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kpi_tile.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_filter_chip.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_kanban.scss',
|
||||
# XML templates (must precede their JS consumers)
|
||||
'fusion_plating_shopfloor/static/src/xml/components/mini_timeline.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/plant_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/column_header.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kpi_tile.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/filter_chip.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/plant_kanban.xml',
|
||||
# JS — leaf components first, then card (imports timeline),
|
||||
# then top-level orchestrator (imports all).
|
||||
'fusion_plating_shopfloor/static/src/js/components/mini_timeline.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/plant_card.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/column_header.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kpi_tile.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/filter_chip.js',
|
||||
'fusion_plating_shopfloor/static/src/js/plant_kanban.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||
|
||||
@@ -9,3 +9,4 @@ from . import move_controller
|
||||
from . import workspace_controller
|
||||
from . import landing_controller
|
||||
from . import tablet_controller
|
||||
from . import plant_kanban
|
||||
|
||||
@@ -203,6 +203,13 @@ class FpTabletMoveController(http.Controller):
|
||||
for prompt_id, value in (prompt_values or {}).items():
|
||||
self._capture_prompt_value(move, int(prompt_id), value)
|
||||
|
||||
# S23 — required transition-input gate. Runs AFTER value capture
|
||||
# so the operator gets credit for whatever they filled in. Raises
|
||||
# UserError if to_step.requires_transition_form=True and any
|
||||
# required transition_input prompt has no value. Rollback unwinds
|
||||
# the move + value rows. Manager bypass: fp_skip_transition_form.
|
||||
move._fp_check_transition_inputs_complete()
|
||||
|
||||
# Advance qty_at_step counters
|
||||
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
|
||||
@@ -298,6 +305,42 @@ class FpTabletMoveController(http.Controller):
|
||||
rack = Rack.browse(rack_id)
|
||||
to_step = Step.browse(to_step_id)
|
||||
|
||||
# S23 — pre-check: rack moves don't capture transition prompts
|
||||
# (no per-move dialog), so if to_step.requires_transition_form
|
||||
# we must reject up-front and force the operator through Move
|
||||
# Parts (which has the form UI). Without this check, rack moves
|
||||
# silently bypass the audit gate that Move Parts enforces.
|
||||
if (to_step.requires_transition_form
|
||||
and not request.env.context.get('fp_skip_transition_form')):
|
||||
# Use the same model helper for consistency — build a dummy
|
||||
# in-memory move to compute "missing" set, then surface a
|
||||
# clear message that points operators at the right tool.
|
||||
recipe_node = to_step.recipe_node_id
|
||||
required_prompts = recipe_node.input_ids if recipe_node else (
|
||||
request.env['fusion.plating.process.node.input']
|
||||
)
|
||||
if 'kind' in required_prompts._fields:
|
||||
required_prompts = required_prompts.filtered(
|
||||
lambda i: i.kind == 'transition_input')
|
||||
required_prompts = required_prompts.filtered(
|
||||
lambda i: i.required)
|
||||
if required_prompts:
|
||||
names = ', '.join(
|
||||
'"%s"' % (p.name or '').strip()
|
||||
for p in required_prompts
|
||||
)
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" requires a transition form '
|
||||
'(%(n)s required prompt(s): %(names)s). '
|
||||
'Use Move Parts for one batch at a time so the form '
|
||||
'can be filled in, or have a manager override with '
|
||||
'context flag fp_skip_transition_form=True.'
|
||||
) % {
|
||||
'step': to_step.name,
|
||||
'n': len(required_prompts),
|
||||
'names': names,
|
||||
})
|
||||
|
||||
moves = []
|
||||
for batch in Step.search([('rack_id', '=', rack.id)]):
|
||||
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Plant-view Shop Floor kanban endpoint.
|
||||
|
||||
Returns {kpis, columns, cards} in one JSONRPC payload so the OWL
|
||||
FpPlantKanban component doesn't fan out per-card RPCs. One card per
|
||||
fp.job; cards grouped into the 9 fixed Shop Floor columns. See spec at
|
||||
docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Mirrors fusion_plating_jobs.models.fp_job._COLUMN_SEQUENCE exactly.
|
||||
# Keep these two in sync — the column order on the board IS the sequence.
|
||||
_COLUMN_LABELS = [
|
||||
('receiving', _('Receiving')),
|
||||
('masking', _('Masking')),
|
||||
('blasting', _('Blasting')),
|
||||
('racking', _('Racking')),
|
||||
('plating', _('Plating')),
|
||||
('baking', _('Baking')),
|
||||
('de_racking', _('De-Racking')),
|
||||
('inspection', _('Final inspection')),
|
||||
('shipping', _('Shipping')),
|
||||
]
|
||||
|
||||
# Sort priority within a column (overdue → bake_due → mine → ready/run
|
||||
# → idle → locked → done). Lower number wins (sorted ascending).
|
||||
_SORT_PRIORITY = {
|
||||
'on_hold': 0,
|
||||
'no_parts': 1,
|
||||
'bake_due': 2,
|
||||
'awaiting_signoff': 3,
|
||||
'awaiting_qc': 4,
|
||||
'ready_mine': 5,
|
||||
'running_mine': 6,
|
||||
'ready': 7,
|
||||
'running': 8,
|
||||
'idle_warning': 9,
|
||||
'predecessor_locked': 10,
|
||||
'contract_review': 11,
|
||||
'done': 12,
|
||||
}
|
||||
|
||||
|
||||
class PlantKanbanController(http.Controller):
|
||||
|
||||
@http.route('/fp/landing/plant_kanban', type='jsonrpc', auth='user')
|
||||
def plant_kanban(self, mode='station', filters=None):
|
||||
"""Returns the assembled board payload. See spec §9.2."""
|
||||
env = request.env
|
||||
user = env.user
|
||||
Job = env['fp.job']
|
||||
|
||||
# Resolve paired station (first row of M2M for MVP)
|
||||
paired = (user.paired_work_centre_ids[:1]
|
||||
if 'paired_work_centre_ids' in user._fields
|
||||
else env['fp.work.centre'])
|
||||
paired_area = paired.area_kind if paired else None
|
||||
|
||||
# Base domain — every job with active recipe steps
|
||||
domain = [
|
||||
('state', 'in', ('confirmed', 'in_progress', 'done')),
|
||||
]
|
||||
filters = filters or {}
|
||||
if filters.get('overdue'):
|
||||
domain.append(('date_deadline', '<', fields_today_ts()))
|
||||
domain.append(('state', '!=', 'done'))
|
||||
if filters.get('on_hold'):
|
||||
domain.append(('card_state', '=', 'on_hold'))
|
||||
if filters.get('running'):
|
||||
domain.append(('card_state', 'in', ('running', 'running_mine')))
|
||||
if filters.get('blocked'):
|
||||
domain.append(('card_state', 'in', (
|
||||
'on_hold', 'predecessor_locked', 'awaiting_signoff',
|
||||
'awaiting_qc', 'no_parts',
|
||||
)))
|
||||
if filters.get('mine'):
|
||||
domain.append(('card_state', 'in', ('ready_mine', 'running_mine')))
|
||||
if filters.get('fair'):
|
||||
# Match either part-catalog or partner level requires_first_article
|
||||
domain.append('|')
|
||||
domain.append(('customer_spec_id.x_fc_requires_first_article', '=', True))
|
||||
domain.append(('part_catalog_id.certificate_requirement', 'in', ('coc', 'coc_thickness')))
|
||||
|
||||
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).
|
||||
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)
|
||||
|
||||
# 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)]))
|
||||
|
||||
columns = [
|
||||
{
|
||||
'area_kind': area,
|
||||
'label': label,
|
||||
'is_mine': (area == paired_area),
|
||||
'card_ids': cards_by_area.get(area, []),
|
||||
}
|
||||
for area, label in _COLUMN_LABELS
|
||||
]
|
||||
|
||||
# KPI strip
|
||||
kpis = {
|
||||
'active_jobs': sum(1 for j in jobs if j.state != 'done'),
|
||||
'at_my_station': sum(
|
||||
1 for j in jobs
|
||||
if j.card_state in ('ready_mine', 'running_mine')
|
||||
),
|
||||
'bakes_due_soon': sum(
|
||||
1 for j in jobs if j.card_state == 'bake_due'
|
||||
),
|
||||
'on_hold': sum(
|
||||
1 for j in jobs if j.card_state == 'on_hold'
|
||||
),
|
||||
'overdue': sum(
|
||||
1 for j in jobs
|
||||
if j.date_deadline and j.date_deadline.date() < date.today()
|
||||
and j.state != 'done'
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'mode': mode,
|
||||
'paired_station': ({
|
||||
'id': paired.id,
|
||||
'name': paired.name,
|
||||
'area_kind': paired_area,
|
||||
} if paired else None),
|
||||
'kpis': kpis,
|
||||
'columns': columns,
|
||||
'cards': cards,
|
||||
}
|
||||
|
||||
|
||||
# ===== helpers ==========================================================
|
||||
|
||||
def fields_today_ts():
|
||||
"""Return today as the start-of-day datetime string for date_deadline
|
||||
comparisons (date_deadline is a Datetime in the schema)."""
|
||||
return datetime.combine(date.today(), datetime.min.time())
|
||||
|
||||
|
||||
def _resolve_card_area(job):
|
||||
"""Pick the column a card lives in.
|
||||
|
||||
Active-step area_kind wins. When there's no active step the card
|
||||
lives in Receiving (covers contract_review + no_parts edge cases).
|
||||
"""
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
# Fallback: receiving column
|
||||
return 'receiving'
|
||||
|
||||
|
||||
def _render_card(job, paired):
|
||||
"""Build the full card payload for one fp.job."""
|
||||
step = job.active_step_id
|
||||
try:
|
||||
timeline = json.loads(job.mini_timeline_json or '[]')
|
||||
except (TypeError, ValueError):
|
||||
timeline = []
|
||||
|
||||
# Cross-module field probes
|
||||
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
|
||||
|
||||
po_number = ''
|
||||
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
|
||||
step_name = step.name if step else _('—')
|
||||
step_seq = step.sequence if step else 0
|
||||
step_total = len(job.step_ids)
|
||||
tank_label = ''
|
||||
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)
|
||||
|
||||
# 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),
|
||||
}
|
||||
|
||||
# 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)
|
||||
and job.date_deadline.date() < date.today()
|
||||
and job.state != 'done'
|
||||
)
|
||||
|
||||
return {
|
||||
'job_id': job.id,
|
||||
'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 '',
|
||||
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
|
||||
if job.date_deadline else None),
|
||||
'due_label': due_label,
|
||||
'is_overdue': is_overdue,
|
||||
'customer': job.partner_id.name if job.partner_id else '',
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'part_revision': (part.revision if part and 'revision' in part._fields else '') or '',
|
||||
'qty': job.qty or 0,
|
||||
'po_number': po_number,
|
||||
'recipe_name': job.recipe_id.name if job.recipe_id else '',
|
||||
'spec_code': (spec.code if spec and 'code' in spec._fields else '') or '',
|
||||
'tags': tags,
|
||||
'step_name': step_name,
|
||||
'step_seq': step_seq,
|
||||
'step_total': step_total,
|
||||
'tank_label': tank_label,
|
||||
'state_chip': state_chip,
|
||||
'operator': operator,
|
||||
'icons': icons,
|
||||
'mini_timeline': timeline,
|
||||
}
|
||||
|
||||
|
||||
def _compute_tags(job, part, spec):
|
||||
tags = []
|
||||
partner = job.partner_id
|
||||
if partner:
|
||||
if 'x_fc_rush' in partner._fields and partner.x_fc_rush:
|
||||
tags.append('rush')
|
||||
if 'x_fc_vip' in partner._fields and partner.x_fc_vip:
|
||||
tags.append('vip')
|
||||
if spec and 'x_fc_requires_first_article' in spec._fields \
|
||||
and spec.x_fc_requires_first_article:
|
||||
tags.append('fair')
|
||||
if part and 'aerospace' in (part.name or '').lower():
|
||||
tags.append('as9100')
|
||||
return tags
|
||||
|
||||
|
||||
def _state_chip(card_state, step):
|
||||
"""Map card_state → {label, kind} for the chip on the card."""
|
||||
if card_state == 'ready':
|
||||
return {'label': _('● Ready'), 'kind': 'ready'}
|
||||
if card_state == 'ready_mine':
|
||||
return {'label': _('● Ready to start'), 'kind': 'ready'}
|
||||
if card_state == 'running':
|
||||
return {'label': _('▶ %s') % _running_elapsed(step), 'kind': 'running'}
|
||||
if card_state == 'running_mine':
|
||||
return {'label': _('▶ %s') % _running_elapsed(step), 'kind': 'running'}
|
||||
if card_state == 'on_hold':
|
||||
return {'label': _('🔴 Quality Hold'), 'kind': 'hold'}
|
||||
if card_state == 'awaiting_signoff':
|
||||
return {'label': _('🔏 Awaiting QA sign-off'), 'kind': 'signoff'}
|
||||
if card_state == 'awaiting_qc':
|
||||
return {'label': _('🔬 QC pending'), 'kind': 'qc'}
|
||||
if card_state == 'bake_due':
|
||||
return {'label': _('⏰ Bake window soon'), 'kind': 'due'}
|
||||
if card_state == 'predecessor_locked':
|
||||
return {'label': _('🔒 Waiting on predecessor'), 'kind': 'locked'}
|
||||
if card_state == 'idle_warning':
|
||||
op = (step.assigned_user_id.name.split()[0]
|
||||
if step and step.assigned_user_id else _('operator'))
|
||||
hrs = _idle_hours(step)
|
||||
return {'label': _('⏸ Idle %dh · %s') % (hrs, op), 'kind': 'idle'}
|
||||
if card_state == 'no_parts':
|
||||
return {'label': _('📦 Parts in transit'), 'kind': 'no_parts'}
|
||||
if card_state == 'contract_review':
|
||||
return {'label': _('📋 QA-005 review'), 'kind': 'paperwork'}
|
||||
if card_state == 'done':
|
||||
return {'label': _('✓ Ready for pickup'), 'kind': 'done'}
|
||||
return {'label': '', 'kind': ''}
|
||||
|
||||
|
||||
def _running_elapsed(step):
|
||||
"""Compact 'Running 8m' / 'Running 1h:45' label."""
|
||||
if not step or not step.date_started:
|
||||
return _('Running')
|
||||
delta = datetime.now() - step.date_started
|
||||
minutes = int(delta.total_seconds() / 60)
|
||||
if minutes < 60:
|
||||
return _('Running %dm') % minutes
|
||||
hours = minutes // 60
|
||||
rem = minutes % 60
|
||||
return _('Running %dh:%02d') % (hours, rem)
|
||||
|
||||
|
||||
def _idle_hours(step):
|
||||
if not step or not step.last_activity_at:
|
||||
return 0
|
||||
delta = datetime.now() - step.last_activity_at
|
||||
return int(delta.total_seconds() / 3600)
|
||||
|
||||
|
||||
def _due_label(deadline):
|
||||
"""'Due May 16 · 3d' style label."""
|
||||
if not deadline:
|
||||
return ''
|
||||
d = deadline.date() if hasattr(deadline, 'date') else deadline
|
||||
today = date.today()
|
||||
days = (d - today).days
|
||||
base = d.strftime('%b %d')
|
||||
if days == 0:
|
||||
return _('Due %s · today') % base
|
||||
if days == 1:
|
||||
return _('Due %s · tomorrow') % base
|
||||
if days > 1:
|
||||
return _('Due %s · %dd') % (base, days)
|
||||
return _('Due %s · %dd late') % (base, -days)
|
||||
|
||||
|
||||
def _icons(job, step):
|
||||
"""Compact icon row at the card footer."""
|
||||
icons = []
|
||||
if step:
|
||||
if step.requires_signoff and not step.signoff_user_id:
|
||||
icons.append('🔏')
|
||||
if step.recipe_node_id \
|
||||
and step.recipe_node_id.default_kind == 'bake':
|
||||
icons.append('🔥')
|
||||
if job.card_state == 'bake_due':
|
||||
icons.append('⏰')
|
||||
if job.card_state == 'no_parts':
|
||||
icons.append('🚚')
|
||||
if job.card_state == 'on_hold':
|
||||
icons.append('💬')
|
||||
if job.card_state == 'predecessor_locked':
|
||||
icons.append('🔒')
|
||||
if job.card_state == 'done':
|
||||
icons.append('📜')
|
||||
return icons
|
||||
|
||||
|
||||
def _initials_for(user):
|
||||
if not user or not user.name:
|
||||
return ''
|
||||
parts = user.name.strip().split()
|
||||
if len(parts) == 1:
|
||||
return parts[0][:2].upper()
|
||||
return (parts[0][0] + parts[-1][0]).upper()
|
||||
|
||||
|
||||
def _sort_key(card):
|
||||
"""Sort within a column: overdue first, then by state priority,
|
||||
then by due date (earlier = higher priority)."""
|
||||
return (
|
||||
0 if card['is_overdue'] else 1,
|
||||
_SORT_PRIORITY.get(card['card_state'], 99),
|
||||
card['due_date'] or '9999-12-31',
|
||||
)
|
||||
@@ -9,3 +9,4 @@ from . import fp_first_piece_gate
|
||||
from . import fp_operator_queue
|
||||
from . import fp_tank
|
||||
from . import res_users
|
||||
from . import res_config_settings
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Feature flags for fusion_plating_shopfloor.
|
||||
|
||||
Currently:
|
||||
- x_fc_shopfloor_layout — switches the Shop Floor client action
|
||||
between the legacy per-step kanban and the v2 plant-view kanban.
|
||||
Backed by ir.config_parameter so the landing-action resolver can
|
||||
read it cheaply on every action open without a recordset fetch.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
x_fc_shopfloor_layout = fields.Selection(
|
||||
[
|
||||
('legacy', 'Legacy (per-step kanban)'),
|
||||
('v2', 'Plant View (one card per job, 9 columns)'),
|
||||
],
|
||||
string='Shop Floor Layout',
|
||||
default='v2',
|
||||
config_parameter='fusion_plating_shopfloor.layout',
|
||||
help='Switches the Shop Floor client action between the legacy '
|
||||
'per-step kanban and the v2 plant view. Defaults to legacy '
|
||||
'during the parallel rollout; flip to v2 once validated. '
|
||||
'The landing-action resolver reads this from '
|
||||
'ir.config_parameter (key: fusion_plating_shopfloor.layout).',
|
||||
)
|
||||
@@ -45,6 +45,19 @@ class ResUsers(models.Model):
|
||||
'Null when not locked. Set after the configured fail '
|
||||
'threshold (default 5) is reached.',
|
||||
)
|
||||
paired_work_centre_ids = fields.Many2many(
|
||||
'fp.work.centre',
|
||||
'res_users_fp_work_centre_paired_rel',
|
||||
'user_id',
|
||||
'work_centre_id',
|
||||
string='Paired Work Centres',
|
||||
help='Stations the operator is currently paired to via the tablet. '
|
||||
'MVP holds exactly one row on day 1 (the dropdown-selected '
|
||||
'station). The Phase 2 multi-station picker can populate '
|
||||
'multiple. Drives the "is this card mine" check on the '
|
||||
'plant-view kanban (cards whose active_step.work_centre is '
|
||||
'in this M2M get the yellow ⭐ treatment).',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hash_tablet_pin(pin, salt=None):
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @odoo-module **/
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpColumnHeader extends Component {
|
||||
static template = "fusion_plating_shopfloor.ColumnHeader";
|
||||
static props = {
|
||||
column: { type: Object }, // {area_kind, label, is_mine, card_ids}
|
||||
};
|
||||
|
||||
get cardCount() {
|
||||
return this.props.column.card_ids.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @odoo-module **/
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpFilterChip extends Component {
|
||||
static template = "fusion_plating_shopfloor.FilterChip";
|
||||
static props = {
|
||||
label: { type: String },
|
||||
active: { type: Boolean },
|
||||
onToggle: { type: Function },
|
||||
};
|
||||
|
||||
onClick() {
|
||||
this.props.onToggle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @odoo-module **/
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpKpiTile extends Component {
|
||||
static template = "fusion_plating_shopfloor.KpiTile";
|
||||
static props = {
|
||||
value: { type: [Number, String] },
|
||||
label: { type: String },
|
||||
kind: { type: String, optional: true }, // urgent | warn | good | ''
|
||||
active: { type: Boolean, optional: true },
|
||||
onClick: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
get tileClass() {
|
||||
const classes = ["o_fp_kpi_tile"];
|
||||
if (this.props.kind) classes.push(this.props.kind);
|
||||
if (this.props.active) classes.push("active");
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.props.onClick) this.props.onClick();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** @odoo-module **/
|
||||
// =====================================================================
|
||||
// FpMiniTimeline — 9-step horizontal bar showing recipe journey.
|
||||
// Consumes mini_timeline JSON from /fp/landing/plant_kanban.
|
||||
// Per project rule 20: no String()/Number() in templates; classFor()
|
||||
// and labelFor() do all the formatting in JS.
|
||||
// =====================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
const AREA_LABELS = {
|
||||
receiving: "Rec",
|
||||
masking: "Mask",
|
||||
blasting: "Blast",
|
||||
racking: "Rack",
|
||||
plating: "Plat",
|
||||
baking: "Bake",
|
||||
de_racking: "D-R",
|
||||
inspection: "Insp",
|
||||
shipping: "Ship",
|
||||
};
|
||||
|
||||
// Map card_state variant → CSS modifier class on the current step
|
||||
const VARIANT_TO_CLASS = {
|
||||
on_hold: "hold",
|
||||
predecessor_locked: "locked",
|
||||
bake_due: "bake",
|
||||
awaiting_signoff: "signoff",
|
||||
idle_warning: "idle",
|
||||
awaiting_qc: "qc",
|
||||
no_parts: "noparts",
|
||||
done: "done",
|
||||
contract_review: "paperwork",
|
||||
// ready / running / *_mine → default yellow (no extra class)
|
||||
};
|
||||
|
||||
export class FpMiniTimeline extends Component {
|
||||
static template = "fusion_plating_shopfloor.MiniTimeline";
|
||||
static props = {
|
||||
timeline: { type: Array },
|
||||
};
|
||||
|
||||
labelFor(area) {
|
||||
return AREA_LABELS[area] || area;
|
||||
}
|
||||
|
||||
classFor(entry) {
|
||||
if (entry.state === "done") return "tl-step done";
|
||||
if (entry.state === "current") {
|
||||
const variant = (entry.variant || "").replace("_mine", "");
|
||||
const cls = VARIANT_TO_CLASS[variant] || "";
|
||||
return cls ? `tl-step current ${cls}` : "tl-step current";
|
||||
}
|
||||
return "tl-step";
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,12 @@ export class FpPinPad extends Component {
|
||||
async _press(digit) {
|
||||
if (this.state.submitting) return;
|
||||
if (this.state.pin.length >= 4) return;
|
||||
this.state.pin = this.state.pin + digit;
|
||||
// Defensive: coerce to string in JS rather than the template
|
||||
// because OWL templates don't expose `String` as a callable
|
||||
// (Critical Rule 20 in CLAUDE.md). Callers pass strings already
|
||||
// via the string array in pin_pad.xml; this is a belt-and-braces
|
||||
// guard for any future caller passing a numeric digit.
|
||||
this.state.pin = this.state.pin + String(digit);
|
||||
this.state.error = "";
|
||||
if (this.state.pin.length === 4) {
|
||||
await this._submit();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** @odoo-module **/
|
||||
// =====================================================================
|
||||
// FpPlantCard — Variant C card for the plant-view kanban.
|
||||
// Renders the full job summary + 9-step mini-timeline. Tap opens the
|
||||
// Job Workspace.
|
||||
//
|
||||
// All formatting / class composition happens in JS — per project rule
|
||||
// 20, OWL templates can't call String(), Number(), etc. as functions.
|
||||
// =====================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FpMiniTimeline } from "./mini_timeline";
|
||||
|
||||
const TAG_LABELS = {
|
||||
rush: "RUSH",
|
||||
fair: "FAIR",
|
||||
vip: "VIP",
|
||||
as9100: "AS9100",
|
||||
};
|
||||
|
||||
export class FpPlantCard extends Component {
|
||||
static template = "fusion_plating_shopfloor.PlantCard";
|
||||
static components = { FpMiniTimeline };
|
||||
static props = {
|
||||
card: { type: Object },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
get cardClass() {
|
||||
const c = this.props.card;
|
||||
const classes = ["o_fp_plant_card", "state-" + (c.card_state || "ready")];
|
||||
if (c.is_mine) classes.push("mine");
|
||||
if (c.is_overdue) classes.push("overdue");
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
get progressStyle() {
|
||||
const c = this.props.card;
|
||||
if (!c.step_total) return "width: 0%";
|
||||
const pct = Math.round((c.step_seq / c.step_total) * 100);
|
||||
return "width: " + pct + "%";
|
||||
}
|
||||
|
||||
tagChipClass(tag) {
|
||||
return "chip tag-" + tag;
|
||||
}
|
||||
|
||||
tagLabel(tag) {
|
||||
return TAG_LABELS[tag] || tag.toUpperCase();
|
||||
}
|
||||
|
||||
stateChipClass(kind) {
|
||||
return "chip kind-" + (kind || "ready");
|
||||
}
|
||||
|
||||
onCardClick() {
|
||||
const c = this.props.card;
|
||||
if (!c.job_id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
target: "current",
|
||||
params: { job_id: c.job_id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
// Auto-refresh: every 15s.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { Component, markup, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { fpRpc } from "./services/fp_rpc";
|
||||
@@ -64,6 +64,19 @@ export class FpJobWorkspace extends Component {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
|
||||
if (res && res.ok) {
|
||||
// Chatter bodies arrive as plain HTML strings off the RPC.
|
||||
// The template renders them via `t-out="msg.body"`, which
|
||||
// HTML-ESCAPES plain JS strings unless they're tagged with
|
||||
// markup() from @odoo/owl. Without this wrap the operator
|
||||
// sees literal `<p>` and `<b>` tags instead of formatted
|
||||
// text (caught 2026-05-23 — Notes panel showing raw HTML).
|
||||
if (res.chatter && res.chatter.length) {
|
||||
for (const m of res.chatter) {
|
||||
if (m && typeof m.body === "string") {
|
||||
m.body = markup(m.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.data = res;
|
||||
} else if (res && res.error) {
|
||||
this.notification.add(res.error, { type: "danger" });
|
||||
@@ -75,8 +88,18 @@ export class FpJobWorkspace extends Component {
|
||||
|
||||
// ---- Navigation --------------------------------------------------------
|
||||
onBack() {
|
||||
// Close workspace; return to whatever spawned the action
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
// The workspace is opened from the Landing kanban with
|
||||
// target: "current", which REPLACES the current action and
|
||||
// wipes the backstack. So `act_window_close` did nothing —
|
||||
// there's no parent action to close to. Navigate explicitly
|
||||
// to the Shop Floor Landing instead, which works whether the
|
||||
// workspace was opened from the kanban, a QR scan, the manager
|
||||
// dashboard, or a direct URL. (Bug caught 2026-05-23.)
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_shopfloor_landing",
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/** @odoo-module **/
|
||||
// =====================================================================
|
||||
// FpPlantKanban — top-level OWL action for the 2026-05-23 redesigned
|
||||
// Shop Floor. Mounts via the fp_plant_kanban client action; landing
|
||||
// resolver dispatches between this and the legacy fp_shopfloor_landing
|
||||
// based on the x_fc_shopfloor_layout config parameter.
|
||||
//
|
||||
// Architecture:
|
||||
// - Polls /fp/landing/plant_kanban every 10s
|
||||
// - Owns mode + filter + search state (filters persist in localStorage)
|
||||
// - 9 fixed columns; one card per fp.job
|
||||
// - Per project rule 20, no String()/Number()/etc. in templates —
|
||||
// all coercion happens here in JS-land.
|
||||
// =====================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
import { FpPlantCard } from "./components/plant_card";
|
||||
import { FpColumnHeader } from "./components/column_header";
|
||||
import { FpKpiTile } from "./components/kpi_tile";
|
||||
import { FpFilterChip } from "./components/filter_chip";
|
||||
|
||||
const LOCAL_FILTER_KEY = "fp_plant_kanban_filters";
|
||||
|
||||
export class FpPlantKanban extends Component {
|
||||
static template = "fusion_plating_shopfloor.PlantKanban";
|
||||
static props = ["*"];
|
||||
static components = {
|
||||
FpTabletLock,
|
||||
FpPlantCard,
|
||||
FpColumnHeader,
|
||||
FpKpiTile,
|
||||
FpFilterChip,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
// techStore may not be registered until first PIN unlock; guard with try.
|
||||
try {
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
} catch {
|
||||
this.techStore = null;
|
||||
}
|
||||
|
||||
this.state = useState({
|
||||
mode: "station",
|
||||
filters: this._loadFilters(),
|
||||
data: null,
|
||||
loading: true,
|
||||
search: "",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refresh();
|
||||
this._poll = setInterval(() => this.refresh(), 10000);
|
||||
});
|
||||
onWillUnmount(() => {
|
||||
if (this._poll) clearInterval(this._poll);
|
||||
});
|
||||
}
|
||||
|
||||
_loadFilters() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_FILTER_KEY);
|
||||
return raw ? JSON.parse(raw) : { all: true };
|
||||
} catch {
|
||||
return { all: true };
|
||||
}
|
||||
}
|
||||
_saveFilters() {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_FILTER_KEY, JSON.stringify(this.state.filters));
|
||||
} catch { /* localStorage may be disabled */ }
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const res = await rpc("/fp/landing/plant_kanban", {
|
||||
mode: this.state.mode,
|
||||
filters: this.state.filters,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.state.data = res;
|
||||
} else if (res && res.error) {
|
||||
this.notification.add(res.error, { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
toggleFilter(name) {
|
||||
if (name === "all") {
|
||||
this.state.filters = { all: true };
|
||||
} else {
|
||||
delete this.state.filters.all;
|
||||
this.state.filters[name] = !this.state.filters[name];
|
||||
const anyActive = Object.keys(this.state.filters)
|
||||
.some(k => this.state.filters[k]);
|
||||
if (!anyActive) {
|
||||
this.state.filters = { all: true };
|
||||
}
|
||||
}
|
||||
this._saveFilters();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.state.mode = mode;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
modeClass(mode) {
|
||||
return this.state.mode === mode ? "mode-btn active" : "mode-btn";
|
||||
}
|
||||
|
||||
onSearchInput(ev) {
|
||||
this.state.search = (ev.target.value || "").toLowerCase();
|
||||
}
|
||||
|
||||
filteredCardIds(column) {
|
||||
// Client-side search filter on top of the server-side filtered set.
|
||||
if (!this.state.search) return column.card_ids;
|
||||
const term = this.state.search;
|
||||
return column.card_ids.filter(id => {
|
||||
const c = this.state.data.cards[id];
|
||||
if (!c) return false;
|
||||
return (
|
||||
(c.wo_name || "").toLowerCase().includes(term)
|
||||
|| (c.customer || "").toLowerCase().includes(term)
|
||||
|| (c.part_number || "").toLowerCase().includes(term)
|
||||
|| (c.po_number || "").toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onHandOff() {
|
||||
if (this.techStore && this.techStore.lock) {
|
||||
this.techStore.lock();
|
||||
}
|
||||
}
|
||||
|
||||
onScanQr() {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_qr_scanner",
|
||||
target: "new",
|
||||
}).catch(() => {
|
||||
// QR scanner action may not be registered in all installs
|
||||
this.notification.add("QR scanner not available", { type: "warning" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_plant_kanban", FpPlantKanban);
|
||||
@@ -0,0 +1,80 @@
|
||||
// =====================================================================
|
||||
// Plant-view kanban — design tokens
|
||||
// MUST load BEFORE the component SCSS files. SCSS @import is forbidden
|
||||
// in custom Odoo 19 SCSS (project rule 8); the manifest concatenates
|
||||
// files in registration order, so this file's $vars are visible to
|
||||
// every later file.
|
||||
// =====================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// === Light-mode defaults ===
|
||||
$_plant-bg-hex: #f8f9fa;
|
||||
$_plant-card-bg-hex: #ffffff;
|
||||
$_plant-card-border-hex: #d8dadd;
|
||||
$_plant-text-hex: #1d1f1e;
|
||||
$_plant-muted-hex: #777;
|
||||
|
||||
$_plant-mine-bg-hex: #fffaeb;
|
||||
$_plant-mine-border-hex: #f0a500;
|
||||
$_plant-hold-bg-hex: #fff5f5;
|
||||
$_plant-hold-border-hex: #dc3545;
|
||||
$_plant-bake-bg-hex: #fff8e1;
|
||||
$_plant-bake-border-hex: #ff9800;
|
||||
$_plant-signoff-bg-hex: #f5f0ff;
|
||||
$_plant-signoff-border-hex: #6f42c1;
|
||||
$_plant-idle-bg-hex: #fef9e7;
|
||||
$_plant-idle-border-hex: #e6a800;
|
||||
$_plant-qc-bg-hex: #e7f5fc;
|
||||
$_plant-qc-border-hex: #17a2b8;
|
||||
$_plant-locked-bg-hex: #f8f9fa;
|
||||
$_plant-locked-border-hex: #6c757d;
|
||||
$_plant-noparts-bg-hex: #f5f5f5;
|
||||
$_plant-noparts-border-hex: #6c757d;
|
||||
$_plant-done-bg-hex: #f0f9f4;
|
||||
$_plant-done-border-hex: #28a745;
|
||||
|
||||
// === Dark-mode overrides (compile-time branch per project rule) ===
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_plant-bg-hex: #1a1d21 !global;
|
||||
$_plant-card-bg-hex: #22262d !global;
|
||||
$_plant-card-border-hex: #424245 !global;
|
||||
$_plant-text-hex: #f5f5f7 !global;
|
||||
$_plant-muted-hex: #adb5bd !global;
|
||||
|
||||
$_plant-mine-bg-hex: #3a2f10 !global;
|
||||
$_plant-hold-bg-hex: #3a1e1e !global;
|
||||
$_plant-bake-bg-hex: #3a2f10 !global;
|
||||
$_plant-signoff-bg-hex: #1f1730 !global;
|
||||
$_plant-idle-bg-hex: #2d2818 !global;
|
||||
$_plant-qc-bg-hex: #14252e !global;
|
||||
$_plant-locked-bg-hex: #2d3138 !global;
|
||||
$_plant-noparts-bg-hex: #2d3138 !global;
|
||||
$_plant-done-bg-hex: #14281a !global;
|
||||
}
|
||||
|
||||
// === CSS-custom-property wrappers so future themes can override ===
|
||||
$plant-bg: var(--fp-plant-bg, $_plant-bg-hex);
|
||||
$plant-card-bg: var(--fp-plant-card-bg, $_plant-card-bg-hex);
|
||||
$plant-card-border: var(--fp-plant-card-border, $_plant-card-border-hex);
|
||||
$plant-text: var(--fp-plant-text, $_plant-text-hex);
|
||||
$plant-muted: var(--fp-plant-muted, $_plant-muted-hex);
|
||||
|
||||
$plant-mine-bg: var(--fp-plant-mine-bg, $_plant-mine-bg-hex);
|
||||
$plant-mine-border: var(--fp-plant-mine-border, $_plant-mine-border-hex);
|
||||
$plant-hold-bg: var(--fp-plant-hold-bg, $_plant-hold-bg-hex);
|
||||
$plant-hold-border: var(--fp-plant-hold-border, $_plant-hold-border-hex);
|
||||
$plant-bake-bg: var(--fp-plant-bake-bg, $_plant-bake-bg-hex);
|
||||
$plant-bake-border: var(--fp-plant-bake-border, $_plant-bake-border-hex);
|
||||
$plant-signoff-bg: var(--fp-plant-signoff-bg, $_plant-signoff-bg-hex);
|
||||
$plant-signoff-border: var(--fp-plant-signoff-border, $_plant-signoff-border-hex);
|
||||
$plant-idle-bg: var(--fp-plant-idle-bg, $_plant-idle-bg-hex);
|
||||
$plant-idle-border: var(--fp-plant-idle-border, $_plant-idle-border-hex);
|
||||
$plant-qc-bg: var(--fp-plant-qc-bg, $_plant-qc-bg-hex);
|
||||
$plant-qc-border: var(--fp-plant-qc-border, $_plant-qc-border-hex);
|
||||
$plant-locked-bg: var(--fp-plant-locked-bg, $_plant-locked-bg-hex);
|
||||
$plant-locked-border: var(--fp-plant-locked-border, $_plant-locked-border-hex);
|
||||
$plant-noparts-bg: var(--fp-plant-noparts-bg, $_plant-noparts-bg-hex);
|
||||
$plant-noparts-border: var(--fp-plant-noparts-border, $_plant-noparts-border-hex);
|
||||
$plant-done-bg: var(--fp-plant-done-bg, $_plant-done-bg-hex);
|
||||
$plant-done-border: var(--fp-plant-done-border, $_plant-done-border-hex);
|
||||
@@ -0,0 +1,45 @@
|
||||
// _column_header.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_col_header {
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border-bottom: 0;
|
||||
|
||||
&.mine {
|
||||
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
|
||||
border-color: $plant-mine-border;
|
||||
}
|
||||
|
||||
.col-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.mine-badge {
|
||||
font-size: 9px; font-weight: 700;
|
||||
color: $plant-mine-border;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-name {
|
||||
font-size: 12px; font-weight: 700;
|
||||
color: $plant-text;
|
||||
text-transform: uppercase; letter-spacing: 0.02em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.col-count {
|
||||
font-size: 13px; font-weight: 700;
|
||||
color: $plant-muted;
|
||||
background: $plant-card-bg;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid $plant-card-border;
|
||||
min-width: 22px; text-align: center;
|
||||
}
|
||||
&.mine .col-count {
|
||||
border-color: $plant-mine-border;
|
||||
color: $plant-mine-border;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// _filter_chip.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_filter_chip {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 14px;
|
||||
color: $plant-muted;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&.active {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
&:hover:not(.active) { background: $plant-bg; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// _kpi_tile.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_kpi_tile {
|
||||
padding: 6px 10px;
|
||||
background: $plant-card-bg;
|
||||
border-radius: 6px;
|
||||
border: 1px solid $plant-card-border;
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
text-align: left;
|
||||
color: $plant-text;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover { background: $plant-bg; }
|
||||
&.active {
|
||||
border-color: $plant-mine-border;
|
||||
background: $plant-mine-bg;
|
||||
}
|
||||
&.urgent .kpi-val { color: $plant-hold-border; }
|
||||
&.warn .kpi-val { color: $plant-idle-border; }
|
||||
&.good .kpi-val { color: $plant-done-border; }
|
||||
|
||||
.kpi-val {
|
||||
font-size: 20px; font-weight: 700;
|
||||
color: $plant-text; line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.kpi-lbl {
|
||||
font-size: 9px; font-weight: 600;
|
||||
color: $plant-muted;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// _mini_timeline.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_mini_timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.tl-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px 0;
|
||||
|
||||
.tl-step {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 1.5px;
|
||||
cursor: help;
|
||||
|
||||
&.done { background: #28a745; }
|
||||
&.current {
|
||||
background: #f0a500;
|
||||
height: 11px;
|
||||
margin-top: -1.5px;
|
||||
box-shadow: 0 0 0 1px rgba(240, 165, 0, 0.25);
|
||||
&.hold { background: $plant-hold-border; }
|
||||
&.locked { background: $plant-locked-border; }
|
||||
&.bake { background: $plant-bake-border; }
|
||||
&.signoff { background: $plant-signoff-border; }
|
||||
&.idle { background: $plant-idle-border; }
|
||||
&.qc { background: $plant-qc-border; }
|
||||
&.noparts { background: $plant-noparts-border; }
|
||||
&.done { background: $plant-done-border; }
|
||||
&.paperwork { background: $plant-signoff-border; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tl-labels {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
font-size: 8px;
|
||||
color: $plant-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
&.current { color: $plant-mine-border; font-weight: 700; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_mini_timeline .tl-row .tl-step { background: #2d3138; }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// _plant_card.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_plant_card {
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: $plant-text;
|
||||
|
||||
&:hover { transform: translateY(-1px); box-shadow: 0 3px 8px rgba(0,0,0,0.12); }
|
||||
|
||||
// === Card state chrome ===
|
||||
&.mine,
|
||||
&.state-ready_mine,
|
||||
&.state-running_mine {
|
||||
background: $plant-mine-bg;
|
||||
border-left: 4px solid $plant-mine-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-on_hold {
|
||||
background: $plant-hold-bg;
|
||||
border-left: 4px solid $plant-hold-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-bake_due {
|
||||
background: $plant-bake-bg;
|
||||
border-left: 4px solid $plant-bake-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-awaiting_signoff {
|
||||
background: $plant-signoff-bg;
|
||||
border-left: 4px solid $plant-signoff-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-idle_warning {
|
||||
background: $plant-idle-bg;
|
||||
border-left: 4px solid $plant-idle-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-awaiting_qc {
|
||||
background: $plant-qc-bg;
|
||||
border-left: 4px solid $plant-qc-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-predecessor_locked {
|
||||
background: $plant-locked-bg;
|
||||
}
|
||||
&.state-no_parts {
|
||||
background: $plant-noparts-bg;
|
||||
border: 1px dashed #999;
|
||||
border-left: 4px solid $plant-noparts-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-done {
|
||||
background: $plant-done-bg;
|
||||
border-left: 4px solid $plant-done-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.overdue:not(.mine):not(.state-on_hold):not(.state-bake_due) {
|
||||
border-left: 4px solid $plant-hold-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
// === Sub-elements ===
|
||||
.card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; }
|
||||
.card-wo { font-size: 13px; font-weight: 700; color: $plant-text; }
|
||||
.card-due { font-size: 10px; color: $plant-muted; white-space: nowrap; }
|
||||
.card-due.overdue { color: $plant-hold-border; font-weight: 700; }
|
||||
.card-sub { font-size: 10px; color: $plant-muted; line-height: 1.3; }
|
||||
.card-sub-em { color: $plant-text; font-weight: 600; }
|
||||
.card-meta { font-size: 10px; color: $plant-muted; }
|
||||
.card-step { font-size: 12px; font-weight: 600; color: $plant-text; margin-top: 2px; }
|
||||
.card-chips { display: flex; flex-wrap: wrap; gap: 3px; }
|
||||
|
||||
.chip {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #f1f3f5;
|
||||
color: #4e4e4e;
|
||||
border: 1px solid #e5e7eb;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
&.tank { background: #e7f1ff; color: #0d4a8c; border-color: #cfe2ff; }
|
||||
&.kind-ready { background: #d1ecf1; color: #0c5460; border-color: #bee5eb; font-weight: 600; }
|
||||
&.kind-running{ background: #fff3cd; color: #856404; border-color: #ffeeba; font-weight: 600; }
|
||||
&.kind-hold { background: #f8d7da; color: #721c24; border-color: #f5c6cb; font-weight: 700; }
|
||||
&.kind-locked { background: #e2e3e5; color: #383d41; border-color: #d6d8db; font-weight: 600; }
|
||||
&.kind-due { background: #ffe9c6; color: #8a4a00; border-color: #ffd28a; font-weight: 700; }
|
||||
&.kind-signoff{ background: #e8d9ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 700; }
|
||||
&.kind-idle { background: #fff3cd; color: #856404; border-color: #ffeeba; font-weight: 700; }
|
||||
&.kind-qc { background: #c4e9f3; color: #0c5460; border-color: #a8dde9; font-weight: 700; }
|
||||
&.kind-no_parts { background: #e2e3e5; color: #383d41; border-color: #d6d8db; font-weight: 700; }
|
||||
&.kind-done { background: #d4edda; color: #155724; border-color: #c3e6cb; font-weight: 700; }
|
||||
&.kind-paperwork { background: #e8e0ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 600; }
|
||||
&.tag-rush { background: #ffe5e5; color: #b00; border-color: #ffcfcf; font-weight: 700; font-size: 9px; }
|
||||
&.tag-fair { background: #fff0d9; color: #8a4a00; border-color: #ffe0b3; font-weight: 700; font-size: 9px; }
|
||||
&.tag-vip { background: #e8e0ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 700; font-size: 9px; }
|
||||
&.tag-as9100 { background: #d4edda; color: #155724; border-color: #c3e6cb; font-weight: 700; font-size: 9px; }
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 6px; padding-top: 4px; margin-top: 2px;
|
||||
border-top: 1px solid #f1f3f5;
|
||||
font-size: 9px; color: $plant-muted;
|
||||
}
|
||||
.progress { display: flex; align-items: center; gap: 4px; flex: 1; }
|
||||
.progress-bar { flex: 1; max-width: 60px; height: 3px; background: #e5e7eb; border-radius: 1.5px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: $plant-mine-border; border-radius: 1.5px; }
|
||||
.operator-pill {
|
||||
display: inline-flex; align-items: center;
|
||||
background: #f1f3f5; border-radius: 8px;
|
||||
padding: 0 4px 0 1px; font-size: 9px; border: 1px solid #e5e7eb;
|
||||
}
|
||||
.operator-avatar {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: #4caf50; color: #fff;
|
||||
font-size: 7px; font-weight: 700;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.icon-row { display: flex; gap: 3px; font-size: 10px; }
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_plant_card .card-bottom { border-top-color: #424245; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// plant_kanban.scss — depends on _plant_tokens.scss and the component partials
|
||||
|
||||
.o_fp_plant_kanban {
|
||||
padding: 8px;
|
||||
background: $plant-bg;
|
||||
min-height: 100vh;
|
||||
color: $plant-text;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
.floor-header {
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.floor-header-top {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.floor-title { font-size: 16px; font-weight: 700; }
|
||||
.floor-controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.station-picker {
|
||||
padding: 5px 10px;
|
||||
background: $plant-mine-bg;
|
||||
border: 1px solid $plant-mine-border;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #856404;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
.mode-btn {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: $plant-card-bg;
|
||||
color: $plant-muted;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid $plant-card-border;
|
||||
font-family: inherit;
|
||||
&:last-child { border-right: 0; }
|
||||
&.active { background: #1d4ed8; color: #fff; }
|
||||
&:hover:not(.active) { background: $plant-bg; }
|
||||
}
|
||||
}
|
||||
.toolbar-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: $plant-text;
|
||||
font-family: inherit;
|
||||
&:hover { background: $plant-bg; }
|
||||
&.handoff {
|
||||
background: #ffc107;
|
||||
border-color: #d39e00;
|
||||
color: #856404;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
|
||||
|
||||
.search-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.search-input {
|
||||
flex: 1; min-width: 200px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px;
|
||||
background: $plant-card-bg;
|
||||
color: $plant-text;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
gap: 4px;
|
||||
min-height: 520px;
|
||||
}
|
||||
.col {
|
||||
background: $plant-bg;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 110px;
|
||||
&.mine {
|
||||
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
|
||||
border: 1px solid $plant-mine-border;
|
||||
}
|
||||
}
|
||||
.col-scroll {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
max-height: calc(100vh - 280px);
|
||||
min-height: 100px;
|
||||
}
|
||||
.col-empty {
|
||||
font-size: 10px;
|
||||
color: $plant-muted;
|
||||
font-style: italic;
|
||||
padding: 14px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: $plant-muted;
|
||||
}
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_plant_kanban .toolbar-btn.handoff {
|
||||
color: #856404; // keep gold legible on dark
|
||||
}
|
||||
}
|
||||
@@ -176,13 +176,42 @@ $_lan-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
// ---- Kanban board ------------------------------------------------------
|
||||
// Recipe authors keep adding work centres (Anodize, Strip, Etch, Bake,
|
||||
// Mask, Rack, Inspect, Ship…) so the kanban must accommodate both
|
||||
// FEW columns (early-shop layouts) AND MANY columns (mature shops with
|
||||
// 15+ stations). Two design moves to handle both:
|
||||
// 1. Columns use `flex: 1 0 200px` — basis 200px, GROW into spare
|
||||
// space (3 cols on a 1200px screen → each becomes 400px), but
|
||||
// NEVER SHRINK below 200px so 15+ cols stay readable and scroll
|
||||
// horizontally. Max 320px caps the growth so a single-column
|
||||
// kanban doesn't span 1200px of empty whitespace.
|
||||
// 2. Custom-styled horizontal scrollbar — the default browser bar
|
||||
// is invisible until hover on most platforms; users had no idea
|
||||
// more columns existed off-screen. Now there's a persistent thin
|
||||
// bar at the bottom of the board.
|
||||
.o_fp_landing_board {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 1rem 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
align-items: stretch;
|
||||
|
||||
// Custom scrollbar — visible enough that users notice more columns
|
||||
// exist off-screen without being obnoxiously large.
|
||||
&::-webkit-scrollbar { height: 10px; }
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $_lan-page-hex;
|
||||
border-radius: 5px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $_lan-border-hex;
|
||||
border-radius: 5px;
|
||||
&:hover { background: darken(#d8dadd, 10%); }
|
||||
}
|
||||
scrollbar-width: thin; // Firefox
|
||||
scrollbar-color: $_lan-border-hex $_lan-page-hex;
|
||||
}
|
||||
|
||||
.o_fp_landing_empty {
|
||||
@@ -194,13 +223,16 @@ $_lan-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_landing_col {
|
||||
flex: 0 0 240px;
|
||||
flex: 1 0 200px; // grow into spare, never shrink below 200px
|
||||
min-width: 200px;
|
||||
max-width: 320px; // cap growth so single col doesn't span 1200px
|
||||
background: $_lan-card-hex;
|
||||
border: 1px solid $_lan-border-hex;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
overflow: hidden; // contain inner sticky header within border-radius
|
||||
|
||||
&.o_fp_drop_target {
|
||||
outline: 2px dashed #0071e3;
|
||||
@@ -209,6 +241,14 @@ $_lan-text-hex: #1d1d1f;
|
||||
}
|
||||
|
||||
.o_fp_landing_col_head {
|
||||
// Sticky inside the column body so as the operator scrolls through
|
||||
// many cards, they always see WHICH station they're looking at.
|
||||
// (Caught 2026-05-23 — long card lists in Oven Baking made operators
|
||||
// lose track of which column they were scrolling.)
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: $_lan-card-hex;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-bottom: 1px solid $_lan-border-hex;
|
||||
display: flex;
|
||||
@@ -218,7 +258,14 @@ $_lan-text-hex: #1d1d1f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.o_fp_landing_col_name { flex: 1; }
|
||||
.o_fp_landing_col_name {
|
||||
flex: 1;
|
||||
// Truncate long work-centre names instead of wrapping (which would
|
||||
// push the count badge to a second line and shift card content).
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.o_fp_landing_col_count {
|
||||
background: $_lan-page-hex;
|
||||
@@ -226,6 +273,7 @@ $_lan-text-hex: #1d1d1f;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary, #777);
|
||||
flex-shrink: 0; // don't squeeze the count when the name is long
|
||||
}
|
||||
|
||||
.o_fp_landing_col_body {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ColumnHeader">
|
||||
<div t-att-class="props.column.is_mine ? 'o_fp_col_header mine' : 'o_fp_col_header'">
|
||||
<div class="col-meta">
|
||||
<div t-if="props.column.is_mine" class="mine-badge">📍 You're here</div>
|
||||
<div class="col-name" t-esc="props.column.label"/>
|
||||
</div>
|
||||
<span class="col-count" t-esc="cardCount"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.FilterChip">
|
||||
<button t-att-class="props.active ? 'o_fp_filter_chip active' : 'o_fp_filter_chip'"
|
||||
t-on-click="onClick"
|
||||
t-esc="props.label"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.KpiTile">
|
||||
<button t-att-class="tileClass" t-on-click="onClick">
|
||||
<div class="kpi-val" t-esc="props.value"/>
|
||||
<div class="kpi-lbl" t-esc="props.label"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.MiniTimeline">
|
||||
<div class="o_fp_mini_timeline">
|
||||
<div class="tl-row">
|
||||
<t t-foreach="props.timeline" t-as="entry" t-key="entry_index">
|
||||
<span t-att-class="classFor(entry)" t-att-title="entry.area"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="tl-labels">
|
||||
<t t-foreach="props.timeline" t-as="entry" t-key="entry_index">
|
||||
<span t-att-class="entry.state === 'current' ? 'current' : ''"
|
||||
t-esc="labelFor(entry.area)"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -15,9 +15,16 @@
|
||||
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
|
||||
|
||||
<div class="o_fp_pin_grid">
|
||||
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
|
||||
<!-- IMPORTANT: digits MUST be string literals here.
|
||||
OWL templates only expose `Math` as a JS global —
|
||||
`String`, `Number`, `Array`, etc. are NOT in template
|
||||
scope. Calling `String(d)` throws "v2 is not a
|
||||
function" because the compiled template references
|
||||
a global named String that doesn't exist. Keep this
|
||||
array as strings; do any type coercion in JS. -->
|
||||
<t t-foreach="['1','2','3','4','5','6','7','8','9']" t-as="d" t-key="d">
|
||||
<button class="o_fp_pin_key"
|
||||
t-on-click="() => this._press(String(d))"
|
||||
t-on-click="() => this._press(d)"
|
||||
t-att-disabled="state.submitting">
|
||||
<t t-esc="d"/>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PlantCard">
|
||||
<div t-att-class="cardClass" t-on-click="onCardClick">
|
||||
<!-- Header: WO + due -->
|
||||
<div class="card-top">
|
||||
<div class="card-wo">
|
||||
<t t-esc="props.card.wo_name"/>
|
||||
<span t-if="props.card.is_mine"> ⭐</span>
|
||||
</div>
|
||||
<div t-att-class="props.card.is_overdue ? 'card-due overdue' : 'card-due'">
|
||||
<span t-if="props.card.is_overdue">⚠ </span>
|
||||
<t t-esc="props.card.due_label"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer -->
|
||||
<div class="card-sub" t-esc="props.card.customer"/>
|
||||
|
||||
<!-- PN / Qty / PO -->
|
||||
<div class="card-sub">
|
||||
<t t-if="props.card.part_number">
|
||||
PN <span class="card-sub-em">
|
||||
<t t-esc="props.card.part_number"/>
|
||||
<t t-if="props.card.part_revision"> Rev <t t-esc="props.card.part_revision"/></t>
|
||||
</span> ·
|
||||
</t>
|
||||
Qty <span class="card-sub-em" t-esc="props.card.qty"/>
|
||||
<t t-if="props.card.po_number"> · PO <t t-esc="props.card.po_number"/></t>
|
||||
</div>
|
||||
|
||||
<!-- Recipe + spec (compact line) -->
|
||||
<div t-if="props.card.recipe_name or props.card.spec_code" class="card-meta">
|
||||
<t t-if="props.card.recipe_name" t-esc="props.card.recipe_name"/>
|
||||
<t t-if="props.card.recipe_name and props.card.spec_code"> · </t>
|
||||
<t t-if="props.card.spec_code" t-esc="props.card.spec_code"/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div t-if="props.card.tags.length" class="card-chips">
|
||||
<t t-foreach="props.card.tags" t-as="tag" t-key="tag">
|
||||
<span t-att-class="tagChipClass(tag)" t-esc="tagLabel(tag)"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Step name -->
|
||||
<div class="card-step" t-esc="props.card.step_name"/>
|
||||
|
||||
<!-- Tank + state chip -->
|
||||
<div class="card-chips">
|
||||
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
|
||||
<span t-att-class="stateChipClass(props.card.state_chip.kind)"
|
||||
t-esc="props.card.state_chip.label"/>
|
||||
</div>
|
||||
|
||||
<!-- Mini-timeline -->
|
||||
<FpMiniTimeline timeline="props.card.mini_timeline"/>
|
||||
|
||||
<!-- Footer: progress + operator + icons -->
|
||||
<div class="card-bottom">
|
||||
<div class="progress">
|
||||
<span><t t-esc="props.card.step_seq"/>/<t t-esc="props.card.step_total"/></span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" t-att-style="progressStyle"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="props.card.operator and props.card.operator.initials" class="operator-pill">
|
||||
<span class="operator-avatar" t-esc="props.card.operator.initials"/>
|
||||
</div>
|
||||
<div t-if="props.card.icons.length" class="icon-row">
|
||||
<t t-foreach="props.card.icons" t-as="icon" t-key="icon_index">
|
||||
<span t-esc="icon"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PlantKanban">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_plant_kanban">
|
||||
|
||||
<!-- ============== STICKY HEADER ============== -->
|
||||
<div class="floor-header">
|
||||
<div class="floor-header-top">
|
||||
<div class="floor-title">🏭 Shop Floor</div>
|
||||
<div class="floor-controls">
|
||||
<button t-if="state.data and state.data.paired_station"
|
||||
class="station-picker">
|
||||
📍 <t t-esc="state.data.paired_station.name"/>
|
||||
</button>
|
||||
<div class="mode-toggle">
|
||||
<button t-att-class="modeClass('station')"
|
||||
t-on-click="() => this.setMode('station')">Station</button>
|
||||
<button t-att-class="modeClass('all_plant')"
|
||||
t-on-click="() => this.setMode('all_plant')">All Plant</button>
|
||||
<button t-att-class="modeClass('manager')"
|
||||
t-on-click="() => this.setMode('manager')">Manager</button>
|
||||
</div>
|
||||
<button class="toolbar-btn" t-on-click="onScanQr">📷 Scan QR</button>
|
||||
<button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip -->
|
||||
<div t-if="state.data" class="kpi-strip">
|
||||
<FpKpiTile value="state.data.kpis.active_jobs"
|
||||
label="'Active Jobs'"
|
||||
kind="'good'"
|
||||
active="!!state.filters.all"
|
||||
onClick="() => this.toggleFilter('all')"/>
|
||||
<FpKpiTile value="state.data.kpis.at_my_station"
|
||||
label="'At My Station'"
|
||||
active="!!state.filters.mine"
|
||||
onClick="() => this.toggleFilter('mine')"/>
|
||||
<FpKpiTile value="state.data.kpis.bakes_due_soon"
|
||||
label="'Bakes Due ≤2h'"
|
||||
kind="'warn'"/>
|
||||
<FpKpiTile value="state.data.kpis.on_hold"
|
||||
label="'On Hold'"
|
||||
kind="'urgent'"
|
||||
active="!!state.filters.on_hold"
|
||||
onClick="() => this.toggleFilter('on_hold')"/>
|
||||
<FpKpiTile value="state.data.kpis.overdue"
|
||||
label="'Overdue'"
|
||||
kind="'urgent'"
|
||||
active="!!state.filters.overdue"
|
||||
onClick="() => this.toggleFilter('overdue')"/>
|
||||
</div>
|
||||
|
||||
<!-- Search + filter chips -->
|
||||
<div class="search-row">
|
||||
<input class="search-input"
|
||||
placeholder="🔎 Search WO #, customer, part #, PO..."
|
||||
t-on-input="onSearchInput"
|
||||
t-att-value="state.search"/>
|
||||
<FpFilterChip label="'All'"
|
||||
active="!!state.filters.all"
|
||||
onToggle="() => this.toggleFilter('all')"/>
|
||||
<FpFilterChip label="'My Station'"
|
||||
active="!!state.filters.mine"
|
||||
onToggle="() => this.toggleFilter('mine')"/>
|
||||
<FpFilterChip label="'Running'"
|
||||
active="!!state.filters.running"
|
||||
onToggle="() => this.toggleFilter('running')"/>
|
||||
<FpFilterChip label="'Blocked'"
|
||||
active="!!state.filters.blocked"
|
||||
onToggle="() => this.toggleFilter('blocked')"/>
|
||||
<FpFilterChip label="'Overdue'"
|
||||
active="!!state.filters.overdue"
|
||||
onToggle="() => this.toggleFilter('overdue')"/>
|
||||
<FpFilterChip label="'FAIR'"
|
||||
active="!!state.filters.fair"
|
||||
onToggle="() => this.toggleFilter('fair')"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== KANBAN BOARD ============== -->
|
||||
<div t-if="state.data" class="board">
|
||||
<t t-foreach="state.data.columns" t-as="col" t-key="col.area_kind">
|
||||
<div t-att-class="col.is_mine ? 'col mine' : 'col'">
|
||||
<FpColumnHeader column="col"/>
|
||||
<div class="col-scroll">
|
||||
<t t-foreach="filteredCardIds(col)" t-as="card_id" t-key="card_id">
|
||||
<FpPlantCard card="state.data.cards[card_id]"/>
|
||||
</t>
|
||||
<div t-if="filteredCardIds(col).length === 0" class="col-empty">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="state.loading and !state.data" class="loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -28,4 +28,18 @@
|
||||
<field name="tag">fp_process_tree</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Plant-view kanban (2026-05-23 redesign). -->
|
||||
<!-- One card per fp.job grouped into 9 fixed columns by area_kind. -->
|
||||
<!-- Replaces fp_shopfloor_landing when x_fc_shopfloor_layout='v2'. -->
|
||||
<!-- The landing-action resolver in fusion_plating/data/fp_landing_data -->
|
||||
<!-- .xml dispatches between this and the legacy action based on the -->
|
||||
<!-- ir.config_parameter set by the new feature-flag setting. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="action_fp_plant_kanban" model="ir.actions.client">
|
||||
<field name="name">Shop Floor</field>
|
||||
<field name="tag">fp_plant_kanban</field>
|
||||
<field name="target">main</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user