Compare commits

...

21 Commits

Author SHA1 Message Date
gsinghpal
edf3f95854 docs(brainstorm): post-shop cert + shipping job states spec
Trigger: WO-30058 (SO-30058) finished all recipe steps on entech and
disappeared from the Shop Floor kanban with a draft CoC and no QM
notification. Operators reported jobs feeling lost; risk that a job
could leave the building without paperwork.

Design (Approach A, approved):
- Two new fp.job.state values between in_progress and done:
  awaiting_cert + awaiting_ship
- Auto-advance on last step finish; auto-advance on cert issue
- Plant kanban widens domain, renders the two states in the existing
  Final inspection / Shipping columns
- 6th tab 'Certificates' on Quality Dashboard with kanban + filters
- ACL gate on fp.certificate.action_issue restricted to
  Manager / QM / Owner (transitive via all_group_ids)
- Email + mail.activity notification to QM authority group
- Migration script backfills mid-flight jobs

Shipping label printing, BoL, carrier dispatch are explicitly
out of scope; awaiting_ship is a parking column with a manual
Mark Shipped button.

Self-review pass found and fixed:
- round-robin field ambiguity (last_activity_at vs login_date)
- unstated behavior for button_mark_done gates (now in step.finish)
- placeholder version inlined (19.0.11.0.0)
- dead reference replaced with inline body

Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:03:31 -04:00
gsinghpal
80887d6098 changes 2026-05-25 08:17:29 -04:00
gsinghpal
5d5964a327 fix(plant-kanban): full-height bordered columns + viewport-pinned scrollbar
Two layout polish fixes after persona-walk feedback on the new Plant
Kanban surface (`fp_plant_kanban`).

1. Columns now run full board height with visible borders
   Was: `.col` had `background: $plant-bg` (= page bg, invisible) and
   no border, so only the header card (`.o_fp_col_header`) drew any
   outline. Empty columns (BAKING / DE-RACKING / SHIPPING) looked
   unbounded — operators couldn't tell where one column ended and
   another began.

   Now: `.col` is the bordered white card (Trello / Asana style),
   stretches full height via grid + flex. `.o_fp_col_header` drops
   its standalone border / radius / background and is just a bottom-
   divider band inside the column card.

2. Horizontal scrollbar pinned to viewport bottom
   Was: `.o_fp_plant_kanban` was `min-height: 100vh` (block flow) +
   `.board` had `min-height: 520px; overflow-x: auto`. Scrollbar
   showed at the bottom of the .board element (~520px from top of
   board), floating mid-page below the empty columns.

   Now: parent is `height: 100vh; display: flex; flex-direction:
   column`. Header is `flex: 0 0 auto`; `.board` is `flex: 1 1 auto;
   min-height: 0` so it fills all remaining vertical and its
   scrollbar sits at the viewport bottom.

`.col-scroll` switched from `max-height: calc(100vh - 260px)` to
`flex: 1 1 auto; min-height: 0` so it expands inside the now-full-
height column instead of being capped at a magic number.

Version: fusion_plating_shopfloor 19.0.33.1.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:41 -04:00
gsinghpal
80f80fb707 fix(tablet): ACL, loading hang, timer offset + FP-tz clock
Four fixes shipped together — all surfaced during tablet UX walkthrough
on entech.

1. sale.order ACL on step completion
   Technicians hit "Access Denied... sale.order" when starting/finishing
   any step. _fp_check_receiving_gate + the serial-promotion helpers +
   _fp_resolve_contract_review_part read step.job_id.sale_order_id (and
   sale_order_line_ids) without sudo. Per Rule 13m, denormalized cross-
   module reads in tablet controllers must sudo() the source recordset.

2. Workspace stuck on "Loading Job Workspace…" after Hand Off + relogin
   Action params aren't URL-encoded, so the workspace remounts with
   jobId=null. refresh() exited early, state.data stayed null, "Loading"
   shown indefinitely. onMounted now redirects to the plant kanban
   when jobId is null or the initial load returns no data.

3. 4-hour timer offset on active steps
   workspace_controller used fp_format() to serialize date_started —
   which converts naive UTC to user tz wall time first. JS then
   appended 'Z' and parsed as UTC, so timer was offset by the user's
   tz (4h on EDT). Switched to fp_isoformat_utc() (proper +00:00 ISO)
   and dropped the Z-append in formatActiveStepElapsed +
   isActiveStepOvertime.

4. Lock-screen clock follows FP regional setting
   tablet_lock.js used d.getHours() / d.toLocaleDateString() — browser
   tz. Now /fp/tablet/tiles returns tz_name (fp_user_tz resolution:
   user.tz → company.x_fc_default_tz → UTC) and the formatters use
   Intl.DateTimeFormat with the explicit timeZone option. plant_overview
   now consumes server_time (already fp_format'd) instead of toLocaleTime
   String. Same chain Odoo backend uses, so PDF / view / tablet all
   agree on what time it is.

Versions: fusion_plating_jobs 19.0.10.30.0,
fusion_plating_shopfloor 19.0.33.1.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:31:25 -04:00
gsinghpal
bfc138251a feat(fusion_plating): hide back-office menus from Plating Technicians
Per user request: technicians on the tablet should only see Discuss,
To-do, Plating, AI, Maintenance, Time Off. Every other top-level app
menu (Calendar, Contacts, CRM, Sales, Dashboards, RC, Faxes, Field
Service, Fusion Clock, Invoicing, Accounting, Project, Timesheets,
Planning, Shipping, Website, Purchase, Inventory, Sign, HR, Payroll,
Attendances, Recruitment, Expenses, IoT, Link Tracker, Apps) is now
restricted to a new group_fp_office_user.

Architecture:
- New group_fp_office_user (security/fp_menu_visibility.xml) — a
  marker group that controls back-office menu visibility.
- Owner / Manager / Quality Manager / Shop Manager / Sales Rep all
  imply office_user via implied_ids — they see everything they did
  before.
- Pure Technicians do NOT imply office_user — they see only the
  tablet-friendly menus.
- A "!technician" filter would have hit managers too (because Manager
  → ... → Technician via implication), so office_user is the inverse
  pattern that gets the right scoping.

Implementation:
- post_init_hook + migrations/19.0.21.4.0/post-migrate.py both call
  _fp_apply_office_user_menu_visibility(env) which iterates a curated
  list of menu xmlids and sets group_ids = [office_user] on each.
- Uses env.ref(..., raise_if_not_found=False) so menus from
  uninstalled modules silently skip — no hard depends added.
- ir.ui.menu uses `group_ids` in Odoo 19 (was groups_id pre-18 — same
  rename pattern as res.users; CLAUDE.md Rule 13c).
- Settings / Apps / Tests left untouched (already admin-restricted).
- Some menus (Field Service) end up with office_user OR their original
  group — that's correct behavior: Plating Techs have neither so still
  don't see them; explicit Field Technicians keep access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:00:15 -04:00
gsinghpal
7dab5fb9c6 feat(record-inputs): tap-to-adjust steppers + inputmode keypad hint
Adds [-] / [+] buttons around every numeric input in the Record Inputs
dialog (single-value, dual-entry, and pass_fail+range branches). Tap
to increment / decrement by the recipe-author-derived step size
(stepFor() already computes this from target_min/target_max precision,
falling back to input-type defaults).

- Decrement clamps at 0 (typical qty/time/temp on a plating floor
  doesn't go negative; if needed, operator can still tap the input
  and type a negative value)
- Increment uses _stepRound() to avoid floating-point fuzz on decimals
- Center-aligned monospace-ish input between the buttons for clarity
- inputmode='decimal' (or 'numeric' for time fields) hint so when the
  operator does tap the input, the iPad shows a number keypad instead
  of the full keyboard

Touches single-value, dual-entry (min/max), and pass_fail+range. Other
multi-field widgets (multi-point thickness, bath chemistry panel) still
use plain inputs — separate request if they need steppers too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:43:00 -04:00
gsinghpal
8d4c85cc52 fix(workspace): drop native confirm() on Close Receiving
Native browser confirm popups look out of place in the tablet UI.
Mark Counted is already a deliberate prior step, so requiring a
second confirmation on Close Receiving was just friction. If a
receiver hits Close prematurely, action_reset_to_counted on
fp.receiving from the back office is the recovery path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:36:48 -04:00
gsinghpal
fc17754996 fix(workspace): required-inputs gate fires + manager bypass dialog
Two bugs:

1. Gate silently passed when step.recipe_node_id was NULL — happened
   to every WO-30057 step after this morning's clone delete (the FK
   ON DELETE SET NULL wiped the link). _fp_missing_required_step_inputs
   returned an empty recordset when node was None, so the gate had
   nothing to fail on and button_finish succeeded with zero audit.
   Fix: _fp_check_step_inputs_complete now treats NULL recipe_node_id
   as an explicit "no recipe link" hard block. Operator can't finish;
   manager bypass posts chatter audit.

2. No tablet UI for the manager bypass. The gate's bypass was a
   Python context flag — invisible from the JS layer, so managers
   were stuck behind the same hard error as operators.
   Fix: new /fp/workspace/finish_step endpoint returns structured
   errors (gate type, missing_prompts list, bypass_available bool).
   Server-side enforces manager group when bypass=True (can't trust
   the client). New FpFinishBlockDialog OWL modal renders:
   - Non-manager: Cancel + Record Inputs
   - Manager:     Cancel + Record Inputs + ⚠ Bypass & Finish (audit)

JobWorkspace.onFinishStep routes plain finishes through the new
endpoint; signature-required steps still go through /fp/workspace/sign_off
(separate gate). Added is_manager to /fp/workspace/load payload so
the JS knows which dialog variant to render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:30:39 -04:00
gsinghpal
0371624afb feat(workspace): live HH:MM:SS timer on active step
Pure client-side tick — 1s setInterval bumps state.tickNow which the
template reads via formatActiveStepElapsed(step). No RPC per tick.
Reads step.date_started_iso (UTC) from the existing payload, parses
to ms, displays elapsed since.

- Green pill (#d1fae5 bg, monospace tabular-nums) on the ACTIVE badge
- Flips red (#fee2e2 + pulse animation) when elapsed > 1.5x
  duration_expected — visual cue for the operator that the step is
  running long against the recipe target

Cleanup interval on onWillUnmount alongside the existing 15s refresh
interval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:18:49 -04:00
gsinghpal
eed1c4619d feat(workspace): pre-recipe receiving card with box count + damage log
Adds the receiver workflow to the Job Workspace tablet view (was the
gap behind WO-30057 sitting in Receiving with no way to advance).
Receivers no longer need to go to the backend form.

Workspace card (renders above the step list when fp.receiving in
state draft/counted on the linked SO):
- Draft state: numeric box-count input + per-line received_qty /
  condition picker (good/damaged/mixed) + Damage Log panel + Mark
  Counted button. Autosaves on input blur.
- Counted state: read-only summary (boxes, parts, who/when) +
  Damage Log still editable + Close Receiving button.
- Closed: card disappears, recipe takes over.

New FpDamageDialog OWL modal:
- Severity pill picker (Cosmetic / Functional / Rejected) with
  color-coded active state
- Required description textarea
- Action Required pill picker (None / Notify / Return / As-Is)
- Photo capture: both "Take Photo" (input capture="environment"
  triggers tablet camera) AND "Upload" (file picker fallback).
  Multi-photo with preview grid + per-photo remove.

5 new endpoints on workspace_controller.py:
- receiving_save_lines (autosave box_count_in + per-line qty/cond)
- receiving_mark_counted (wraps action_mark_counted)
- receiving_close (wraps action_close)
- damage_create (creates fp.receiving.damage + attaches base64 photos)
- damage_delete (removes a damage row)

No model changes — wraps existing fp.receiving actions and damage
CRUD. C3 (outbound shipping carrier/label) is a separate spec.

Spec: in-conversation brainstorm (C1+C2) following the 2026-05-24
workspace step actions spec; no standalone doc since scope is small.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:08:30 -04:00
gsinghpal
170398ab6f feat(workspace): per-kind step action buttons in Job Workspace
Fix: in the Job Workspace tablet view, the Start button was buried
inside a parent t-if that required the step to already be in_progress
or blocked. So ready/paused steps showed no buttons at all -
operators couldn't advance the WO from this screen (the reason the
user couldn't complete anything on WO-30057).

Template restructure (job_workspace.xml):
- Always-visible line 1 (icon + step# + name + ACTIVE/PAUSED badge + meta)
- Non-terminal detail panel (chips + instructions + opt-out + GateViz)
  visible on every non-done step so operator reads ahead
- Action row dispatched per-kind via getStepActions() helper

Per-kind action dispatcher (job_workspace.js):
- in_progress -> Record Inputs, Pause, Finish (or Finish & Sign Off)
- paused      -> Resume, Record Inputs, Finish
- contract_review (ready) -> Open QA-005 Form
- gating (ready)          -> Mark Passed (1-click start+finish)
- requires_rack_assignment -> Start (Assign Rack) - opens FpRackPartsDialog
- else (ready)            -> Start

5 new handlers: onPauseStep / onResumeStep / onMarkPassed /
onOpenContractReview / onStartWithRack. Pause and Resume use ORM RPC
(button_pause/button_resume) since no HTTP endpoint exists.

New model method (fp.job.step.action_mark_gating_passed):
- 1-click pass for gating steps - does button_start + button_finish
  in one transaction, posts chatter "Gate X marked passed by Y"
- Raises UserError if called on a non-gating step (defensive)
- Bypasses S21 required-inputs gate (gating steps have no inputs)

Controller: workspace_controller.py adds requires_rack_assignment to
the step payload so the JS dispatcher can route correctly.

Spec: docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md
Sub-B (Record Inputs tablet polish: inputmode/prefill/date pickers/
signature pad/camera) is brainstormed but deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:38:22 -04:00
gsinghpal
d4e95dcd47 docs(plating): spec + plan for recipe cleanup + receiving enforcement
Root causes documented:
1. Recipe 3620 ENP-ALUM-BASIC had duplicate sequences (Contract
   Review + Masking both at seq 10; Incoming Inspection + Racking
   both at seq 20). Clones inherited the ambiguity and resolved by
   id ordering, putting Masking before Incoming Inspection — which
   meant new jobs went straight to Plating column after the
   contract-review auto-complete.
2. 24 per-part clone recipes accumulated, all carrying the broken
   ordering.
3. ~10 kind=other stragglers across the base recipes (Blasting,
   Adhesion Test Coupon, Strip Process, Chemical Conversion etc.)
4. Recipe duplication had no kind safety net.

Implementation shipped in commits referenced from the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:08:46 -04:00
gsinghpal
e1fedf7231 fix(fusion_plating): wet_process passthrough + per-clone unlink safety
Two follow-up fixes caught during the entech deploy of recipe cleanup:

1. RESOLVER_KIND_TO_ACTIVE_KIND was missing a self-pass entry for
   'wet_process'. The new aliases added in 19.0.21.3.0 (Chemical
   Conversion, Trivalent Chromate Conversion, Strip Process - AL,
   Plug The Threaded Holes via mask) directly return 'wet_process'
   from the resolver — without the passthrough they didn't translate
   to any active kind and stayed as 'other'. Added 'wet_process':
   'wet_process' so the migration's Phase 2 backfill catches them.

2. Migration 19.0.10.26.0 Phase 3 was using batch unlink
   (clone_recipes.unlink()) which tripped a PostgreSQL FK cascade
   ordering bug on entech ("insert or update on parent_id violates
   FK ..." during the CASCADE chain). Rewrote Phase 3 to delete one
   clone at a time with SAVEPOINT per clone — slower but immune to
   the batching bug, and one failed clone doesn't roll back the
   whole transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:08:35 -04:00
gsinghpal
9a2975b154 feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix
Migration 19.0.10.26.0/post-migrate.py runs in 5 phases:
1. Resequence recipe 3620 ENP-ALUM-BASIC ops to fix the duplicate-
   sequence bug (Contract Review=10, Incoming Inspection=20,
   Masking=30, Racking=40, then the rest). Also delete the empty
   duplicate ENP-Alum Line sub_process (id 4056).
2. Backfill kind on all kind=other nodes via the extended resolver
   from fusion_plating 19.0.21.3.0
3. Delete all per-part clone recipes (name contains em-dash)
4. Recompute fp.job.step.area_kind on all steps
5. Recompute fp.job.active_step_id + card_state on in-flight jobs

Plant kanban: no_parts cards now always land in the Receiving column
regardless of active_step area_kind. The receiver works Receiving;
that's where the card belongs when parts haven't arrived.

Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:59:33 -04:00
gsinghpal
271a995455 feat(fusion_plating): extend resolver + auto-classify hook on process node
Resolver (fp_resolve_step_kind) extensions:
- New aliases: blasting/bead blast/media blast variants, adhesion
  testing, corrosion testing, lab testing, strip process, chemical
  conversion, trivalent chromate, plug the threaded holes, air dry,
  desmut, soak clean, cleaner, nickel strike/strip
- Parenthetical suffix stripping - "Masking (If Required)" resolves
  through "masking", "Incoming Inspection (Standard)" through
  "incoming inspection", "Trivalent Chromate Conversion (A-14 / A)"
  through "trivalent chromate conversion"
- New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's
  vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test
  -> wet_process) so the resolver output lands on active kinds only

Auto-classify hook on fusion.plating.process.node:
- _fp_autoclassify_kind() upgrades kind_id when current is 'other'
  AND name resolves via the resolver. Idempotent - never overrides
  a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify
- Wired into create() and write() (only fires when name or kind_id
  changed on write)
- Side-effects: recipe duplication via copy() auto-corrects newly
  copied nodes; Simple/Tree editor authoring auto-classifies as soon
  as the name is saved

Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:57:35 -04:00
gsinghpal
056178b433 fix(jobs): store=True on fp.job.active_step_id
Required because fp.job.card_state (stored) has @api.depends including
active_step_id.area_kind. When step.area_kind changes, Odoo's trigger
chain searches fp.job by active_step_id — non-stored fields can't be
queried in WHERE clauses, raising ValueError("Cannot convert ... to
SQL because it is not stored").

Caught during entech deploy of 19.0.10.25.0/post-migrate.py Phase 3
(steps._compute_area_kind() failed on first run). store=True makes
the column searchable and the trigger chain works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:14:19 -04:00
gsinghpal
2285c9def1 docs(plating): spec + plan for Shop Floor live-step + library cleanup
Spec documents the 4 code defects + structural vocabulary mismatch
between fp.step.kind taxonomy and the legacy _STEP_KIND_TO_AREA dict,
plus the 30 library templates missing metadata. Plan breaks the work
into 15 bite-sized tasks across 2 phases.

Implementation shipped in:
- c75d2bde (Odoo 19 session.authenticate signature fix — separate)
- 7b90f210 (Phase 1: fusion_plating)
- b06d28e7 (Phase 2: jobs + shopfloor)
- 6afc9e3c (follow-up tracking + pattern anchor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:58 -04:00
gsinghpal
6afc9e3c0d fix(fusion_plating): tracking warning + De-Masking pattern anchor
- fp.step.kind.area_kind: drop tracking=True (model doesn't inherit
  mail.thread; tracking was a no-op emitting a startup warning).
- Migration 19.0.10.25.0: anchor the De-Masking ILIKE so it doesn't
  wildcard-match "Ready For De-Masking" (which the earlier "Ready %"
  rule already routes to gating). Also drop the cur_code='mask' filter
  so the 4 De-Masking nodes still classified as 'other' get picked up
  on fresh re-runs too.

Direct SQL applied the 4-row fix on entech (post-migrate doesn't
re-run for already-applied versions); this commit keeps fresh
installs and any future re-runs consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:42 -04:00
gsinghpal
b06d28e7f6 feat(jobs+shopfloor): live-step priority chain + done-job filter
Fix the Shop Floor plant kanban so cards land in the right column:
- fp.job._compute_active_step_id walks priority chain
  (in_progress > paused > ready > pending), not just in_progress
- fp.job._compute_card_state edge case respects job.state='done'
  (no more bogus 'contract_review' label on done jobs)
- fp.job.step._compute_area_kind reads kind.area_kind directly;
  legacy _STEP_KIND_TO_AREA dict removed (50+ lines deleted)
- /fp/landing/plant_kanban filters out done/cancelled jobs from
  the live board

Migration 19.0.10.25.0 backfills template metadata (codes,
descriptions, icons, kind_id) on 30 unfinished library templates
and repoints recipe nodes for 6 unambiguous name patterns
(Blasting -> blast, Ready For X -> gating, De-Masking -> demask,
Scheduling -> gating, Nickel Strip -> wet_process,
Pre-Meas/Check Sulfamate -> inspect).

Battle test bt_s24_between_steps.py covers between-step routing,
paused step lifecycle, and done-job board filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:06:53 -04:00
gsinghpal
7b90f210b9 feat(fusion_plating): kind.area_kind drives Shop Floor column routing
Add required area_kind Selection to fp.step.kind so each kind
self-declares which plant-view column its steps belong in. Replaces
the hardcoded _STEP_KIND_TO_AREA dict (removed in fp_job_step.py
in the follow-up commit).

- New `blast` kind for the Blasting column (sequence=35)
- 26 existing kind records seeded with area_kind in XML
- Pre-migrate 19.0.21.2.0 seeds existing rows BEFORE NOT NULL hits
  the schema; also activates derack/demask/gating that were
  deactivated in 19.0.20.6.0 but are needed for the full taxonomy
- Step Kind form + list views surface area_kind (badge + chip)
- Step Kind search adds Group By Shop Floor Column
- Simple Editor kind picker shows "Masking — Masking column"
  suffix so authors see the routing at pick time
- Add Hot Water Porosity Test (A-15) + Final Inspection / Packaging
  templates (used by 7+3 recipe nodes that previously had no
  library entry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:02:02 -04:00
gsinghpal
c75d2bde5a fix(shopfloor): session.authenticate signature update for Odoo 19
Odoo 19's Session.authenticate(env, credential) takes an Environment as
the first arg, not a db-name string. Passing request.db triggered
TypeError: 'str' object is not callable on the internal
env(user=None, su=False) reset.

Fixes the "Odoo Server Error" dialog operators saw when trying to PIN
unlock from the tablet. Same fix applies to lock_session (which was
silently masked by its broad except Exception).

Bumps fusion_plating_shopfloor to 19.0.33.1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:56:05 -04:00
59 changed files with 7179 additions and 764 deletions

View File

@@ -0,0 +1,784 @@
# Recipe Cleanup + Receiving Enforcement Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix recipe 3620 ENP-ALUM-BASIC's duplicate-sequence bug, delete all 24 per-part clone recipes, backfill `kind=other` nodes via an extended name resolver, add an auto-classify hook on every node create/write, and make `no_parts` cards always land in the Receiving column.
**Architecture:** One migration in `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` does all the data work in 5 phases (resequence 3620 → backfill kinds → delete clones → recompute step.area_kind → recompute job.active_step_id + card_state). Two code-side changes: extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping, add `_fp_autoclassify_kind()` to `fusion.plating.process.node.create/write` so future authoring + recipe duplication self-correct.
**Spec:** [docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md](../specs/2026-05-24-recipe-cleanup-design.md)
**Tech Stack:** Odoo 19, Python (ORM/migrations), PostgreSQL.
---
## File Inventory
| Path | Responsibility |
|---|---|
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME` aliases; add parenthetical-strip to `fp_resolve_step_kind()`; expose `RESOLVER_KIND_TO_ACTIVE_KIND` map |
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + create/write hooks |
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase data migration |
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving column override in `_resolve_card_area` |
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
---
## Task 1: Extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping
**Files:**
- Modify: `fusion_plating/__init__.py:208-304`
- [ ] **Step 1: Add `re` to imports**
At the top of `fusion_plating/__init__.py`, after the existing `import logging` line, add:
```python
import re
```
- [ ] **Step 2: Extend `_STARTER_KIND_BY_NAME`**
Find the dict at line 208. Inside the dict (before the closing `}`), add the following keys (preserve the existing entries):
```python
# 2026-05-24 — Recipe cleanup additions (live-step fix follow-up).
# Blasting variants
'blasting': 'blast',
'bead blast': 'blast',
'bead blasting': 'blast',
'media blast': 'blast',
'media blasting': 'blast',
# Inspection variants the resolver didn't know
'adhesion test coupon': 'inspect',
'adhesion testing': 'inspect',
'corrosion testing': 'inspect',
'lab testing': 'inspect',
'check sulfamate nickel area': 'inspect',
'pre-measurements': 'inspect',
'pre measurements': 'inspect',
'hot water porosity': 'inspect',
# Strip / chemical conversion / plugging (wet line)
'strip process': 'wet_process',
'strip process - al': 'wet_process',
'nickel strip - aluminum line': 'wet_process',
'chemical conversion': 'wet_process',
'trivalent chromate conversion': 'wet_process',
'plug the threaded holes': 'mask',
# Misc wet line variants seen on entech recipes
'air dry': 'dry',
'desmut': 'etch',
'soak clean': 'cleaning',
'cleaner': 'cleaning',
'nickel strike': 'plate',
'nickel strip': 'plate',
```
- [ ] **Step 3: Add parenthetical stripping inside `fp_resolve_step_kind()`**
Find the function around line 288. Replace its body:
```python
def fp_resolve_step_kind(name):
"""Resolve a step name to a default_kind, tolerant of whitespace and
case. Used by both the seeder and the migration backfill so we don't
have two slightly-different lookup paths.
Handles parenthetical suffixes like "(Standard)", "(If Required)",
"(A-14 / A)" by stripping them before the second lookup attempt.
Returns the kind str or None when no match.
"""
if not name:
return None
key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key]
# Parenthetical strip — "Masking (If Required)" → "Masking",
# "Incoming Inspection (Standard)" → "Incoming Inspection".
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[bare]
# Gating "Ready for / Ready For" prefix — anything starting with that
# is a gating node regardless of the destination step name.
if key.startswith('ready for ') or key.startswith('ready '):
return 'gating'
return None
```
- [ ] **Step 4: Add `RESOLVER_KIND_TO_ACTIVE_KIND` translation map**
Right after the `fp_resolve_step_kind` function (around line 305), add:
```python
# Translates the resolver's kind output to the active fp.step.kind.code
# values. The resolver still returns the OLD vocabulary (cleaning,
# electroclean, etch, rinse, strike, dry, wbf_test) which were
# deactivated in 19.0.20.6.0 — those roll up to the active wet_process
# kind. Other codes pass through 1:1.
RESOLVER_KIND_TO_ACTIVE_KIND = {
# Wet-line kinds → wet_process (active rollup)
'cleaning': 'wet_process',
'electroclean': 'wet_process',
'etch': 'wet_process',
'rinse': 'wet_process',
'strike': 'wet_process',
'dry': 'wet_process',
'wbf_test': 'wet_process',
# 1:1 mappings (kind exists and is active)
'contract_review': 'contract_review',
'mask': 'mask',
'racking': 'racking',
'plate': 'plate',
'bake': 'bake',
'derack': 'derack',
'demask': 'demask',
'inspect': 'inspect',
'final_inspect': 'final_inspect',
'ship': 'ship',
'gating': 'gating',
'blast': 'blast',
}
```
- [ ] **Step 5: Confirm import structure (no commit yet)**
Run:
```bash
grep -n "^import re\|^from\|^import" fusion_plating/fusion_plating/__init__.py | head -5
```
Expected: `import re` appears before `from . import controllers`.
Commit happens in Task 3.
---
## Task 2: Auto-classify hook on `fusion.plating.process.node`
**Files:**
- Modify: `fusion_plating/models/fp_process_node.py`
- [ ] **Step 1: Find an insertion point near the existing `create/write/copy` methods**
In [`fusion_plating/models/fp_process_node.py`](../../fusion_plating/models/fp_process_node.py), find the `copy()` method around line 789 (it's at the bottom of the FpProcessNode class). The autoclassify helper goes near it, and the create/write overrides slot in alongside copy.
- [ ] **Step 2: Add the helper + create/write overrides**
In `FpProcessNode`, add this block right before the `copy()` method at line ~787. Insert AFTER all the other fields/methods but BEFORE `copy()`:
```python
# ---- Auto-classify kind from name (2026-05-24) ----------------------
# Safety net: when a node's kind is the catch-all 'other' AND its
# name resolves via fp_resolve_step_kind(), upgrade kind_id to the
# resolved active kind. Runs on create() and on write() when name
# or kind_id changes. Prevents recipe authoring + recipe duplication
# from silently leaving nodes as 'other' (which then routes them to
# the wrong Shop Floor column).
#
# Skip with context flag fp_skip_kind_autoclassify=True for admin
# workflows that need to keep kind=other despite a known name.
def _fp_autoclassify_kind(self):
"""Upgrade kind_id when current is 'other' and name resolves."""
if self.env.context.get('fp_skip_kind_autoclassify'):
return
from odoo.addons.fusion_plating import (
fp_resolve_step_kind,
RESOLVER_KIND_TO_ACTIVE_KIND,
)
Kind = self.env['fp.step.kind']
other = Kind.search([('code', '=', 'other')], limit=1)
if not other:
return
for node in self:
if not node.name or node.kind_id != other:
continue
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code:
continue
target = Kind.search([('code', '=', target_code)], limit=1)
if target:
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'kind_id': target.id})
@api.model_create_multi
def create(self, vals_list):
nodes = super().create(vals_list)
nodes._fp_autoclassify_kind()
return nodes
def write(self, vals):
res = super().write(vals)
if 'name' in vals or 'kind_id' in vals:
self._fp_autoclassify_kind()
return res
```
- [ ] **Step 3: Verify the file parses (no commit yet)**
Run:
```bash
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating/models/fp_process_node.py').read()); print('OK')"
```
Expected: `OK`.
Commit happens in Task 3.
---
## Task 3: Version bump fusion_plating + commit Phase 1
**Files:**
- Modify: `fusion_plating/__manifest__.py`
- [ ] **Step 1: Bump the version**
In [`fusion_plating/__manifest__.py`](../../fusion_plating/__manifest__.py), change:
```python
'version': '19.0.21.2.0',
```
to:
```python
'version': '19.0.21.3.0',
```
- [ ] **Step 2: Commit Phase 1**
```bash
git add fusion_plating/fusion_plating/__init__.py \
fusion_plating/fusion_plating/models/fp_process_node.py \
fusion_plating/fusion_plating/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(fusion_plating): extend resolver + auto-classify hook on process node
Resolver (fp_resolve_step_kind) extensions:
- New aliases: blasting/bead blast/media blast variants, adhesion
testing, corrosion testing, lab testing, strip process, chemical
conversion, trivalent chromate, plug the threaded holes, air dry,
desmut, soak clean, cleaner, nickel strike/strip
- Parenthetical suffix stripping — "Masking (If Required)" resolves
through "masking", "Incoming Inspection (Standard)" through
"incoming inspection"
- New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's
vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test
→ wet_process) so the resolver output lands on active kinds only
Auto-classify hook on fusion.plating.process.node:
- _fp_autoclassify_kind() upgrades kind_id when current is 'other'
AND name resolves via the resolver. Idempotent — never overrides
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify
- Wired into create() and write() (only fires when name or kind_id
changed on write)
- Side-effects: recipe duplication via copy() auto-corrects newly
copied nodes; Simple/Tree editor authoring auto-classifies as soon
as the name is saved
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Write the 19.0.10.26.0 migration
**Files:**
- Create: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`
- [ ] **Step 1: Create the migration directory**
```bash
mkdir -p fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0
```
- [ ] **Step 2: Write the migration file**
Create `fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.10.26.0 — Recipe cleanup + per-part clone delete.
Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
Phases (in order):
1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the
duplicate empty ENP-Alum Line sub_process (id 4056).
2. Backfill kind on all kind=other nodes via the extended
fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND
translation.
3. Delete all 24 per-part clone recipes (name ILIKE '%%').
CASCADE handles child nodes; SET NULL handles fp.job /
fp.job.step / fp.coating.config / fp.pricing.rule /
fp.part.catalog references.
4. Recompute fp.job.step.area_kind on all rows.
5. Recompute fp.job.active_step_id + card_state on in-flight jobs.
All phases idempotent — re-running -u is safe.
"""
import logging
from odoo.api import Environment, SUPERUSER_ID
_logger = logging.getLogger(__name__)
# Recipe 3620's ops in the desired final order. Maps the existing node
# id (as documented in the spec) to its target sequence. The user
# decided mask-first-then-rack per spec Section "Mask vs Rack order".
RECIPE_3620_RESEQUENCE = [
# (node_id, new_sequence, expected_name)
(3853, 10, 'Contract Review'),
(3854, 20, 'Incoming Inspection (Standard)'),
(3877, 30, 'Masking'),
(3855, 40, 'Racking'),
(3858, 50, 'Ready for processing'),
(3859, 60, 'ENP-Alum Line'),
(3861, 70, 'De-Masking'),
(3864, 80, 'Oven baking'),
(3867, 90, 'De-racking'),
(4067, 100, 'Oven bake (Post de-rack)'),
(3873, 110, 'Post-plate Inspection'),
(3876, 120, 'Final Inspection'),
]
# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no
# children — the real one is id 3859 with E-Nickel Plating as child).
RECIPE_3620_DUPLICATE_TO_DELETE = 4056
def migrate(cr, version):
env = Environment(cr, SUPERUSER_ID, {})
# ============================================================
# Phase 1 — Resequence recipe 3620 + delete duplicate sub_process
# ============================================================
Node = env['fusion.plating.process.node']
recipe_3620 = Node.browse(3620).exists()
if not recipe_3620:
_logger.warning(
'[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; '
'skipping resequence phase'
)
else:
# Verify the expected nodes exist, then resequence them.
# We do this idempotently — only update if the sequence
# differs from the target.
renumbered = 0
for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE:
node = Node.browse(node_id).exists()
if not node:
_logger.warning(
'[recipe-cleanup] Recipe 3620: expected node %s '
'("%s") not found; skipping',
node_id, expected_name,
)
continue
if node.sequence != new_seq:
# Skip the autoclassify hook on this write (nothing
# changes about kind_id; we're only touching sequence).
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'sequence': new_seq})
renumbered += 1
_logger.info(
'[recipe-cleanup] Recipe 3620: %s nodes resequenced',
renumbered,
)
# Delete the empty duplicate ENP-Alum Line sub_process.
dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists()
if dup:
if dup.child_ids:
_logger.warning(
'[recipe-cleanup] Duplicate sub_process %s has '
'%s children — NOT deleting (safety check). '
'Expected an empty node.',
dup.id, len(dup.child_ids),
)
else:
dup.unlink()
_logger.info(
'[recipe-cleanup] Deleted empty duplicate '
'ENP-Alum Line sub_process (id %s)',
RECIPE_3620_DUPLICATE_TO_DELETE,
)
# ============================================================
# Phase 2 — Backfill kind on all kind=other nodes via resolver
# ============================================================
from odoo.addons.fusion_plating import (
fp_resolve_step_kind,
RESOLVER_KIND_TO_ACTIVE_KIND,
)
Kind = env['fp.step.kind']
other_kind = Kind.search([('code', '=', 'other')], limit=1)
if not other_kind:
_logger.error(
'[recipe-cleanup] No "other" kind found; skipping kind '
'backfill phase'
)
else:
# Build a cache of code → kind.id so we don't search per-row
kind_by_code = {k.code: k.id for k in Kind.search([])}
affected_nodes = Node.search([
('kind_id', '=', other_kind.id),
('name', '!=', False),
('node_type', 'in', ('operation', 'step', 'sub_process')),
])
fixed = 0
for node in affected_nodes:
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code or target_code not in kind_by_code:
continue
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'kind_id': kind_by_code[target_code]})
fixed += 1
_logger.info(
'[recipe-cleanup] Phase 2: backfilled kind on %s nodes '
'(of %s currently kind=other)',
fixed, len(affected_nodes),
)
# ============================================================
# Phase 3 — Delete all 24 per-part clone recipes
# ============================================================
# Identify by name pattern. The configurator names clones
# "BASE_NAME — PART_NUMBER Rev X" with an em-dash separator.
# No base recipe uses em-dash in its name.
clone_recipes = Node.search([
('node_type', '=', 'recipe'),
('name', 'ilike', '%%'),
])
if clone_recipes:
# Log what we're about to delete for forensic visibility.
clone_names = [c.name for c in clone_recipes]
_logger.info(
'[recipe-cleanup] Phase 3: deleting %s clone recipes: %s',
len(clone_recipes),
', '.join(clone_names[:10])
+ ('' if len(clone_names) > 10 else ''),
)
clone_recipes.unlink()
_logger.info(
'[recipe-cleanup] Phase 3: deleted %s clone recipes '
'(CASCADE removed their child nodes; FK SET NULL applied '
'to historical fp.job + fp.job.step references)',
len(clone_recipes),
)
else:
_logger.info(
'[recipe-cleanup] Phase 3: no clone recipes found '
'(already deleted on a prior run, or none exist)'
)
# ============================================================
# Phase 4 — Recompute area_kind on all fp.job.step rows
# ============================================================
# After Phase 2, many recipe nodes have new kinds. After Phase 3,
# some fp.job.step rows have NULL recipe_node_id (FK SET NULL'd
# when the clone got deleted). Recompute picks up the new kinds
# for active recipes and falls back to catch-all 'plating' for
# orphans (all historical / terminal jobs — won't show on board).
Step = env['fp.job.step']
steps = Step.search([])
if steps:
steps._compute_area_kind()
steps.flush_recordset(['area_kind'])
_logger.info(
'[recipe-cleanup] Phase 4: recomputed area_kind on %s steps',
len(steps),
)
# ============================================================
# Phase 5 — Recompute active_step_id + card_state on in-flight jobs
# ============================================================
Job = env['fp.job']
jobs = Job.search([
('state', 'in', ('confirmed', 'in_progress')),
])
if jobs:
jobs._compute_active_step_id()
jobs._compute_card_state()
jobs.flush_recordset(['active_step_id', 'card_state'])
_logger.info(
'[recipe-cleanup] Phase 5: recomputed active_step_id + '
'card_state on %s in-flight jobs',
len(jobs),
)
```
- [ ] **Step 3: Verify the file parses**
Run:
```bash
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py').read()); print('OK')"
```
Expected: `OK`.
---
## Task 5: Version bump fusion_plating_jobs
**Files:**
- Modify: `fusion_plating_jobs/__manifest__.py`
- [ ] **Step 1: Bump the version**
In [`fusion_plating_jobs/__manifest__.py`](../../fusion_plating_jobs/__manifest__.py), change:
```python
'version': '19.0.10.25.0',
```
to:
```python
'version': '19.0.10.26.0',
```
- [ ] **Step 2: No commit yet — grouped with Task 6's commit.**
---
## Task 6: `no_parts` cards always show in Receiving column
**Files:**
- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py:165-180`
- Modify: `fusion_plating_shopfloor/__manifest__.py`
- [ ] **Step 1: Update `_resolve_card_area`**
In [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../fusion_plating_shopfloor/controllers/plant_kanban.py), find `_resolve_card_area` (around line 165). Replace its body with:
```python
def _resolve_card_area(job):
"""Pick the column a card lives in.
Active-step area_kind wins, EXCEPT for no_parts cards which always
land in Receiving regardless of active step — the receiver is who
needs to act, and they work the Receiving column. With the live-step
priority chain (see fp.job._compute_active_step_id), active_step_id
is False only when the job has NO steps at all (recipe not assigned)
OR every step is `done`. Done jobs are filtered off the board
upstream, so the orphan fallback fires only for truly orphaned cards.
See spec 2026-05-24-recipe-cleanup-design.md Change 6.
"""
# no_parts cards belong in Receiving regardless of where the active
# step is — the receiver is who acts.
if job.card_state == 'no_parts':
return 'receiving'
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
# Orphan fallback — represents a data integrity issue, not a
# normal state. Cards here have NO steps assigned at all.
return 'receiving'
```
- [ ] **Step 2: Bump fusion_plating_shopfloor manifest**
In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py):
```python
'version': '19.0.33.1.3',
```
to:
```python
'version': '19.0.33.1.4',
```
- [ ] **Step 3: Commit Phase 2 (Tasks 4-6)**
```bash
git add fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
fusion_plating/fusion_plating_jobs/__manifest__.py \
fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py \
fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "$(cat <<'EOF'
feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix
Migration 19.0.10.26.0/post-migrate.py runs in 5 phases:
1. Resequence recipe 3620 ENP-ALUM-BASIC ops (fixes the duplicate-
sequence bug that caused WO-30057 to skip Receiving)
2. Backfill kind on all kind=other nodes via the extended resolver
from fusion_plating 19.0.21.3.0
3. Delete all 24 per-part clone recipes
4. Recompute fp.job.step.area_kind on all steps
5. Recompute fp.job.active_step_id + card_state on in-flight jobs
Plant kanban: no_parts cards now always land in the Receiving column
regardless of active_step area_kind. The receiver works Receiving;
that's where the card belongs when parts haven't arrived.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Deploy to entech + verify
- [ ] **Step 1: Fetch + check concurrent commits**
```bash
git fetch origin
git log HEAD..origin/main --oneline
```
Expected: empty (we're ahead, not behind). If anything shows, rebase first.
- [ ] **Step 2: Copy modified files to entech**
```bash
for f in \
fusion_plating/__init__.py \
fusion_plating/models/fp_process_node.py \
fusion_plating/__manifest__.py \
fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
fusion_plating_jobs/__manifest__.py \
fusion_plating_shopfloor/controllers/plant_kanban.py \
fusion_plating_shopfloor/__manifest__.py; do
echo "Copying $f"
cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \"mkdir -p \\\$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f\""
done
echo "=== ALL COPIED ==="
```
(Run from `/Users/gurpreet/Github/Odoo-Modules/fusion_plating/` so the file paths line up.)
- [ ] **Step 3: Upgrade modules + restart**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -60 && systemctl start odoo && sleep 3 && systemctl is-active odoo'"
```
Expected log lines (in order):
- `[recipe-cleanup] Recipe 3620: N nodes resequenced`
- `[recipe-cleanup] Deleted empty duplicate ENP-Alum Line sub_process (id 4056)`
- `[recipe-cleanup] Phase 2: backfilled kind on N nodes …`
- `[recipe-cleanup] Phase 3: deleting 24 clone recipes: …`
- `[recipe-cleanup] Phase 3: deleted 24 clone recipes …`
- `[recipe-cleanup] Phase 4: recomputed area_kind on N steps`
- `[recipe-cleanup] Phase 5: recomputed active_step_id + card_state on N in-flight jobs`
- Service prints `active` at the end.
No tracebacks. If you see one, STOP and report it.
- [ ] **Step 4: SQL spot-check — clones deleted**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT COUNT(*) AS clones_remaining FROM fusion_plating_process_node WHERE node_type='\\''recipe'\\'' AND name ILIKE '\\''% — %'\\'';\" | sudo -u postgres psql -d admin'"
```
Expected: `clones_remaining = 0`.
- [ ] **Step 5: SQL spot-check — recipe 3620 resequenced**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT sequence, name FROM fusion_plating_process_node WHERE parent_id = 3620 AND node_type IN ('\\''operation'\\'', '\\''sub_process'\\'') ORDER BY sequence;\" | sudo -u postgres psql -d admin'"
```
Expected output (12 unique-sequence rows):
```
10 | Contract Review
20 | Incoming Inspection (Standard)
30 | Masking
40 | Racking
50 | Ready for processing
60 | ENP-Alum Line
70 | De-Masking
80 | Oven baking
90 | De-racking
100 | Oven bake (Post de-rack)
110 | Post-plate Inspection
120 | Final Inspection
```
NO duplicate sequences. NO second ENP-Alum Line row.
- [ ] **Step 6: SQL spot-check — kind=other nodes backfilled**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT n.name, COUNT(*) AS still_other FROM fusion_plating_process_node n JOIN fp_step_kind k ON k.id = n.kind_id WHERE k.code = '\\''other'\\'' AND n.node_type IN ('\\''operation'\\'', '\\''step'\\'', '\\''sub_process'\\'') GROUP BY n.name ORDER BY still_other DESC;\" | sudo -u postgres psql -d admin'"
```
Expected: very few rows, only names like `ENP-Alum Line - HP` (sub_process with no clear category) or genuinely-niche operation names. Should NOT include `Contract Review`, `Masking`, `Racking`, `Incoming Inspection`, `E-Nickel Plating`, `Final Inspection`, `Shipping`, `Bake`, `Blasting`, `De-Masking`, `De-racking`, `Hot Water Porosity`, etc.
- [ ] **Step 7: End-to-end smoke**
On the entech UI:
1. Open Plating → Sales & Quoting → Sale Orders → New
2. Add a customer + a part whose default recipe is `ENP-ALUM-BASIC` (id 3620)
3. Confirm the SO
4. Check the new WO on Plating → Operations → Plating Jobs:
a. Recipe should be a fresh clone named `ENP-ALUM-BASIC — <PART#> Rev <X>`
b. The clone's first 4 operations should be: Contract Review (10), Incoming Inspection (20), Masking (30), Racking (40)
5. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' AND/OR because Incoming Inspection is now the next live step after Contract Review auto-completes)
6. Open Plating → Configuration → Recipes & Steps → Recipes — confirm no recipe has " — " in its name (the clones are gone)
- [ ] **Step 8: Autoclassify hook smoke**
In the Simple Editor on any recipe:
1. Drop a new step, type name "Masking" without picking a kind
2. Save
3. Refresh the page
4. Confirm the step's kind reads "Masking" (not "Other")
---
## Task 8: Commit spec + plan, push to origin
- [ ] **Step 1: Stage and commit the spec + plan docs**
```bash
git add fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md \
fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md
git commit -m "$(cat <<'EOF'
docs(plating): spec + plan for recipe cleanup + receiving enforcement
Spec documents:
- Root cause 1: duplicate sequences on recipe 3620 ENP-ALUM-BASIC
- Root cause 2: 24 per-part clone recipes carrying the broken order
- Root cause 3: ~10 kind=other stragglers across base recipes
- Root cause 4: recipe duplication has no kind safety net
Implementation shipped in commits referenced from the plan's task list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 2: Final fetch + push**
```bash
git fetch origin
git log HEAD..origin/main --oneline # expect empty
git push origin main
```
---
## Rollback
If anything fails on entech:
1. `git reset --hard <prior-commit>` locally, force-copy the prior files back to entech.
2. Force-rerun the prior version's post-migrate by setting `ir_module_module.latest_version` back to `19.0.10.25.0` for fusion_plating_jobs and `19.0.21.2.0` for fusion_plating, then `-u`.
(Migration is idempotent so re-running the broken version is safe; you may need to manually re-create the deleted clones from a DB backup if rollback needed clones back — out of scope per "we don't need to worry about current data".)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,384 @@
# Recipe Cleanup + Receiving Enforcement
**Date:** 2026-05-24
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
**Status:** Approved, awaiting implementation plan.
---
## Problem
User created SO-30057, confirmed it, and the resulting WO-30057 went **straight to the Plating column** on the Shop Floor board — skipping Receiving entirely. The card-state was `no_parts` (correctly: parts hadn't arrived yet) but the column resolved to `plating`, so:
- The receiver, who watches the Receiving column, never sees the job
- The Masking operator sees a card they can't start
- The parts physically can't move forward because nobody knows they need to be received
The auto-complete contract-review logic (`_fp_autocomplete_repeat_order_contract_review`) is **NOT the bug** — it correctly marks Contract Review as done when the part has a complete QA-005 history. The real problems are deeper.
## Root causes
### Root cause 1 — `ENP-ALUM-BASIC` (id 3620) has DUPLICATE SEQUENCES
```
seq 10: Contract Review (id 3853, kind=contract_review)
seq 10: Masking (id 3877, kind=mask) ← TIE
seq 20: Incoming Insp. (id 3854, kind=receiving)
seq 20: Racking (id 3855, kind=racking) ← TIE
seq 40: ENP-Alum Line (id 3859, sub_process, has E-Nickel Plating child)
seq 40: ENP-Alum Line (id 4056, sub_process, empty) ← DUPLICATE
seq 50: De-Masking
seq 60: Oven baking
...
```
When this base recipe is cloned per-part by the configurator (`fp.process.node.copy()`), tied sequences resolve by id. So in the clone:
- Position 10: Contract Review (id 3853 < id 3877 → wins)
- Position 20: Masking (the second one at 10 → promoted to 20)
- Position 30: Incoming Inspection (one of the seq-20 ties → promoted to 30)
- Position 40: Racking (the other seq-20 → promoted to 40)
After Contract Review auto-completes, the live step is **Masking** (kind=mask, area=masking) — which our prior live-step fix routes to the Masking column, not Receiving. The clone for WO-30057 (recipe 4649) followed exactly this pattern.
### Root cause 2 — 24 per-part clone recipes accumulated, all carrying the broken ordering
Each clone is its own `fusion.plating.process.node` row with `node_type='recipe'` and a name like `BASE_NAME — PART_NUMBER Rev X`. There are 24 such clones on entech. Several are referenced by historical jobs (24 cancelled + 7 done jobs use them), but all those jobs are terminal — none are in-flight.
### Root cause 3 — ~10 nodes across base recipes still have `kind=other`
Mostly niche names the existing `fp_resolve_step_kind()` resolver doesn't know:
| Recipe | Node | Currently | Should be |
|---|---|---|---|
| 3645 ENP-STEEL-MP-BASIC | Blasting (If Required) | other | blast |
| 3645 ENP-STEEL-MP-BASIC | Adhesion Test Coupon | other | inspect |
| 3689 ENP-SP | Adhesion Test Coupon | other | inspect |
| 3689 ENP-SP | Adhesion Testing | other | inspect |
| 3689 ENP-SP | Corrosion Testing | other | inspect |
| 3689 ENP-SP | Lab Testing | other | inspect |
| 3945 ENP ALUM BASIC HP SC2 | ENP-Alum Line - HP | other | other (intentional — sub_process) |
| 3782 Chemical Conversion Process | Strip Process - AL | other | wet_process |
| 3782 Chemical Conversion Process | Plug The Threaded Holes | other | mask |
| 3782 Chemical Conversion Process | Chemical Conversion (sub_process) | other | wet_process |
| 3782 Chemical Conversion Process | Trivalent Chromate Conversion (A-14 / A) | other | wet_process |
### Root cause 4 — Recipe duplication has no kind safety net
`fp.process.node.copy()` uses the standard Odoo deep-copy which inherits all fields including `kind_id`. So if the source has bad kinds, the clone inherits bad kinds. Even after we fix the base recipes, future authoring mistakes will propagate.
---
## Approved fix
### Change 1 — Delete all 24 per-part clone recipes
Identify clones by name pattern (em-dash with spaces — the configurator's separator): `name ILIKE '% — %' AND node_type='recipe'`.
FK constraints verified:
- `fp.job.recipe_id` → SET NULL (historical job loses recipe ref, step data persists)
- `fp.job.start_at_node_id` → SET NULL
- `fp.job.step.recipe_node_id` → SET NULL
- `fusion.plating.process.node.parent_id` → CASCADE (child nodes auto-deleted)
- `fp.coating.config.recipe_id` → SET NULL
- `fp.pricing.rule.recipe_id` → SET NULL
- `fp.part.catalog.default_process_id` → SET NULL
- Zero rows in the 2 RESTRICT FKs (`fp.quote.configurator.recipe_id`, `fp.job.node.override.node_id`) point at clones → no blockers
One DELETE statement:
```sql
DELETE FROM fusion_plating_process_node
WHERE node_type = 'recipe'
AND name ILIKE '% — %';
```
CASCADE handles all child operations + steps + sub_processes via the `parent_id` chain. SET NULL handles all the historical job references.
### Change 2 — Fix recipe 3620 ENP-ALUM-BASIC
**a. Resequence operations** so each has a unique sequence and Receiving precedes physical work:
| New sequence | Operation | id | Was at |
|---|---|---|---|
| 10 | Contract Review | 3853 | 10 |
| 20 | Incoming Inspection (Standard) | 3854 | 20 (tied) |
| 30 | Masking | 3877 | 10 (tied) |
| 40 | Racking | 3855 | 20 (tied) |
| 50 | Ready for processing | 3858 | 30 |
| 60 | ENP-Alum Line | 3859 | 40 (tied) |
| 70 | De-Masking | 3861 | 50 |
| 80 | Oven baking | 3864 | 60 |
| 90 | De-racking | 3867 | 70 |
| 100 | Oven bake (Post de-rack) | 4067 | 80 |
| 110 | Post-plate Inspection | 3873 | 90 |
| 120 | Final Inspection | 3876 | 120 |
Per the user decision (mask first, then rack — matches the existing De-Masking step's position between Plating and Bake; de-mask before de-rack would be illogical).
**b. Delete duplicate empty ENP-Alum Line sub_process** (id 4056, no children). The real one (id 3859, contains E-Nickel Plating) survives.
### Change 3 — Extend `fp_resolve_step_kind()`
In [`fusion_plating/__init__.py`](../../../fusion_plating/__init__.py):
**a. Add aliases to `_STARTER_KIND_BY_NAME`:**
```python
# Blasting variants
'blasting': 'blast',
'bead blast': 'blast',
'bead blasting': 'blast',
'media blast': 'blast',
'media blasting': 'blast',
# Inspection variants the resolver didn't know
'adhesion test coupon': 'inspect',
'adhesion testing': 'inspect',
'corrosion testing': 'inspect',
'lab testing': 'inspect',
# Strip + chemical conversion + plugging (mostly wet line)
'strip process': 'wet_process',
'strip process - al': 'wet_process',
'nickel strip - aluminum line': 'wet_process',
'chemical conversion': 'wet_process',
'trivalent chromate conversion': 'wet_process',
'plug the threaded holes': 'mask',
```
**b. Add parenthetical stripping** to `fp_resolve_step_kind()` so `"Incoming Inspection (Standard)"`, `"Blasting (If Required)"`, `"Trivalent Chromate Conversion (A-14 / A)"` etc. resolve through their base name. Strip first, look up second, fall through to the resolver's other rules:
```python
def fp_resolve_step_kind(name):
if not name:
return None
key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key]
# NEW: strip parenthetical suffixes — "Masking (If Required)" →
# "Masking", "Incoming Inspection (Standard)" → "Incoming
# Inspection".
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[bare]
if key.startswith('ready for ') or key.startswith('ready '):
return 'gating'
return None
```
**c. Translate resolver kinds to active `fp.step.kind.code` values.** Several resolver outputs (`cleaning`, `electroclean`, `etch`, `rinse`, `strike`, `dry`, `wbf_test`) map to kinds that are inactive in the dropdown — those should roll up to the active `wet_process` kind. Add a translation in the migration:
```python
RESOLVER_KIND_TO_ACTIVE_KIND = {
# Wet-line kinds → wet_process (active rollup)
'cleaning': 'wet_process',
'electroclean': 'wet_process',
'etch': 'wet_process',
'rinse': 'wet_process',
'strike': 'wet_process',
'dry': 'wet_process',
'wbf_test': 'wet_process',
# 1:1 mappings (kind exists and is active)
'contract_review': 'contract_review',
'mask': 'mask',
'racking': 'racking',
'plate': 'plate',
'bake': 'bake',
'derack': 'derack',
'demask': 'demask',
'inspect': 'inspect',
'final_inspect': 'final_inspect',
'ship': 'ship',
'gating': 'gating',
'blast': 'blast',
}
```
### Change 4 — Backfill `kind=other` nodes via the extended resolver
For every `fusion.plating.process.node` where `kind.code='other'` and `name` is set:
- Call `fp_resolve_step_kind(name)`
- Translate via `RESOLVER_KIND_TO_ACTIVE_KIND`
- If a match: look up `fp.step.kind` by code, write `kind_id`
- If no match: leave as-is (admin can pick later)
Idempotent — only affects nodes currently at `kind=other`.
### Change 5 — Auto-classify hook on `fusion.plating.process.node`
In [`fusion_plating/models/fp_process_node.py`](../../../fusion_plating/models/fp_process_node.py), add a post-write helper that runs after `create()` and `write()`:
```python
def _fp_autoclassify_kind(self):
"""If kind_id is 'other' AND name resolves via fp_resolve_step_kind,
upgrade to the resolved active kind. Idempotent — never overrides
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify=True.
"""
if self.env.context.get('fp_skip_kind_autoclassify'):
return
from odoo.addons.fusion_plating import fp_resolve_step_kind
Kind = self.env['fp.step.kind']
other = Kind.search([('code', '=', 'other')], limit=1)
if not other:
return
for node in self:
if not node.name or node.kind_id != other:
continue
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code:
continue
target = Kind.search([('code', '=', target_code)], limit=1)
if target:
node.with_context(fp_skip_kind_autoclassify=True).write(
{'kind_id': target.id},
)
@api.model_create_multi
def create(self, vals_list):
nodes = super().create(vals_list)
nodes._fp_autoclassify_kind()
return nodes
def write(self, vals):
res = super().write(vals)
# Only re-run autoclassify when name OR kind_id changed
if 'name' in vals or 'kind_id' in vals:
self._fp_autoclassify_kind()
return res
```
Two side-effects this guarantees:
- Recipe duplication via `copy()` → after super().copy() runs, the hook fires on the new node and upgrades the kind if applicable. So future per-part clones get correct kinds even if the source was sloppy.
- Authors typing a step name in the Simple/Tree editor → kind auto-upgrades as soon as the name is saved (provided they hadn't already picked a specific kind).
### Change 6 — `no_parts` cards always land in Receiving column
In [`fusion_plating_shopfloor/controllers/plant_kanban.py:165`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py):
```python
def _resolve_card_area(job):
"""..."""
# NEW — Defect: no_parts cards belong in Receiving regardless of
# active step. The receiver is who acts; the receiver works the
# Receiving column.
if job.card_state == 'no_parts':
return 'receiving'
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
return 'receiving'
```
Belt-and-suspenders so even if a job slips through with a bad area_kind or before kinds are recomputed, "no parts" cards still show where they belong.
### Change 7 — Unified migration
New file: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`. Runs AFTER fusion_plating's data files load (so the resolver extensions are available).
Phases, in order:
1. **Resequence recipe 3620** ops + delete duplicate empty `ENP-Alum Line` sub_process (id 4056).
2. **Backfill `kind=other` nodes** using the extended resolver + active-kind translation. Affects ~10 nodes across recipes 3645/3689/3945/3782.
3. **Delete the 24 clone recipes** — single DELETE on `fusion_plating_process_node` where `name ILIKE '% — %' AND node_type='recipe'`. CASCADE cleans up children; SET NULL handles job refs.
4. **Recompute `fp.job.step.area_kind`** on all rows. After the kind-backfill + clone delete, some steps lose their `recipe_node_id` (NULL); those fall to the catch-all `'plating'`. Acceptable — those are all done/cancelled jobs.
5. **Recompute `fp.job.active_step_id` + `card_state`** on in-flight jobs (currently 0 on entech, but defensive).
All phases idempotent — re-running `-u` is safe.
### Change 8 — Version bumps
| Module | From | To |
|---|---|---|
| `fusion_plating` | `19.0.21.2.0` | `19.0.21.3.0` (resolver + autoclassify hook + new aliases) |
| `fusion_plating_jobs` | `19.0.10.25.0` | `19.0.10.26.0` (migration only) |
| `fusion_plating_shopfloor` | `19.0.33.1.3` | `19.0.33.1.4` (no_parts override) |
---
## Out of scope (explicit)
- **Reordering the other 6 base recipes.** Only recipe 3620 has the documented duplicate-sequence problem. The others have sane sequences and acceptable ordering.
- **Backfilling historical jobs' `area_kind`.** All 31 historical jobs are terminal (cancelled/done). They drop off the live board so their stored area_kind is decorative.
- **Manual kind picks for the ~5 nodes left as `other`** (e.g. `ENP-Alum Line - HP` sub_process). The resolver can't classify them reliably; admin can pick manually if needed.
- **Removing the per-part clone path itself.** The configurator still clones recipes per-part — that's the intended flow. We're just removing existing clones; future SOs will create fresh clones from the fixed base recipes.
- **Battle test for this fix.** The flow (SO confirm → job create → recipe clone → step gen → auto-complete → card-area resolve) is covered by manual smoke. A scripted battle test for this would duplicate significant configurator + auto-complete logic — disproportionate to the fix size.
---
## Test plan
### Manual smoke (after deploy)
1. **Confirm clones gone:**
```sql
SELECT COUNT(*) FROM fusion_plating_process_node
WHERE node_type='recipe' AND name ILIKE '% — %';
-- expected: 0
```
2. **Confirm 3620 reordered:**
```sql
SELECT sequence, name FROM fusion_plating_process_node
WHERE parent_id=3620 ORDER BY sequence;
-- expected: 10=Contract Review, 20=Incoming Inspection, 30=Masking,
-- 40=Racking, 50=Ready for processing, 60=ENP-Alum Line,
-- 70=De-Masking, 80=Oven baking, 90=De-racking,
-- 100=Oven bake (Post de-rack), 110=Post-plate Inspection,
-- 120=Final Inspection
-- NO duplicate sequences. ENP-Alum Line appears ONCE (not twice).
```
3. **Confirm kinds backfilled:**
```sql
SELECT n.name, k.code FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE k.code = 'other'
AND n.node_type IN ('operation','step')
ORDER BY n.name;
-- expected: only ENP-Alum Line - HP (or similar genuinely-other
-- nodes that resolver can't classify) — NOT Adhesion Test
-- Coupon, Corrosion Testing, Lab Testing, Plug The Threaded
-- Holes, etc.
```
4. **End-to-end flow:**
a. Create a new SO with a part whose default recipe is `ENP-ALUM-BASIC`.
b. Confirm the SO.
c. Check: the cloned recipe has Contract Review at sequence 10, Incoming Inspection at sequence 20, Masking at 30, Racking at 40.
d. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' from the no_parts override OR because Incoming Inspection is the active step after Contract Review auto-completes).
e. Mark Incoming Inspection done → card moves to Masking column.
5. **Auto-classify hook:**
a. Open the Simple Editor on any recipe.
b. Drop a new step, type name "Masking" (don't pick a kind).
c. Save the recipe.
d. Refresh the page.
e. Confirm the kind dropdown shows "Masking" (not "Other").
---
## Roll-out
1. Implement Changes 1-8 in one branch.
2. Local dev test — no local container available, so skip; verify directly on entech.
3. Deploy to entech via the standard `pct exec 111` flow.
4. SQL spot-checks per the test plan.
5. Manual smoke (steps 4 + 5).
6. Commit + push.
---
## Files touched
| File | Change |
|---|---|
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME`, add parenthetical-strip in `fp_resolve_step_kind()` |
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + hooks in `create()` and `write()` |
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase migration (Changes 2, 4, 1, recompute, recompute) |
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving override in `_resolve_card_area` |
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
Estimated diff: ~250 lines added, ~20 modified.

View File

@@ -0,0 +1,635 @@
# Shop Floor — Live Step + Kind/Library Cleanup
**Date:** 2026-05-24
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
**Status:** Revised after step-library audit. Awaiting implementation plan.
---
## Problem
All 7 jobs on entech are stuck in the **Receiving** column of the Shop Floor
plant kanban, each tagged with a purple "📋 QA-005 review" chip, even though
every step on every one of them is `done`. The board doesn't reflect shop
state.
Investigation surfaced **four code defects**, a **structural vocabulary
mismatch** between the user-extensible step kind taxonomy and the hardcoded
`area_kind` mapping, **gaps in the kind taxonomy** (no `blast` kind, three
relevant kinds inactive), and **30 step-library templates missing codes,
descriptions, and meaningful icons**.
### Defect 1 — `_compute_card_state` edge case mislabels done jobs
[`fusion_plating_jobs/models/fp_job.py:261-267`](../../../fusion_plating_jobs/models/fp_job.py)
A job whose `active_step_id` is False (all steps done OR no steps at all)
defaults to `'contract_review'` regardless of `job.state`. Done jobs get a
QA-005 chip they don't deserve.
### Defect 2 — `_compute_active_step_id` is too narrow
[`fusion_plating_jobs/models/fp_job.py:386-391`](../../../fusion_plating_jobs/models/fp_job.py)
Only matches `state == 'in_progress'`. Between-step / paused / ready jobs
have `active_step_id = False`. Combined with Defect 3, these teleport to
Receiving.
### Defect 3 — column-resolve fallback is `'receiving'`
[`fusion_plating_shopfloor/controllers/plant_kanban.py:161-170`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
When `active_step_id` is False this fallback fires for every non-running
job. Receiving becomes a parking lot.
### Defect 4 — done jobs aren't filtered off the board
Done + cancelled jobs stay visible forever. The 7 stuck cards on entech are
all `state='done'` jobs that shipped weeks ago.
### Defect 5 (structural) — kind→area_kind vocabulary mismatch
`fp.step.kind` is a user-extensible taxonomy (28 records, 12 active in the
dropdown post 2026-05-24 dedup). `kind_id` is `required=True` on both
`fp.step.template` and `fusion.plating.process.node`, defaulting to
`code='other'`.
`fp.job.step._compute_area_kind` reads `recipe_node.default_kind` (the kind
code) through the hardcoded `_STEP_KIND_TO_AREA` dict in
[`fp_job_step.py:25-73`](../../../fusion_plating_jobs/models/fp_job_step.py).
The two vocabularies overlap on **7 of 28 codes**. Adoption on entech:
| `kind.code` | Nodes | Mapping exists? | Falls to |
|---|---|---|---|
| `other` | 240 | ❌ | `'plating'` |
| `racking` | 122 | ✅ | `'racking'` ✓ |
| `wet_process` | 105 | ❌ | `'plating'` (lucky — wet line IS plating) |
| `bake` | 103 | ✅ | `'baking'` ✓ |
| `mask` | 92 | ❌ (dict has `'masking'`) | `'plating'` (wrong) |
| `inspect` | 52 | ❌ (dict has `'inspection'`) | `'plating'` (wrong) |
| `plate` | 35 | ❌ (dict has `'e_nickel_plate'`) | `'plating'` (lucky) |
| `final_inspect` | 31 | ❌ (dict has `'final_inspection'`) | `'plating'` (wrong) |
| `contract_review` | 17 | ✅ | `'receiving'` ✓ |
| `receiving` | 16 | ✅ | `'receiving'` ✓ |
| `ship` | 3 | ❌ (dict has `'shipping'`) | `'plating'` (wrong) |
The structural fix: make `area_kind` a required field on `fp.step.kind`
itself so each kind self-declares its column.
### Defect 6 (taxonomy) — kinds that should exist but don't / are inactive
| Kind | Currently | Needed because |
|---|---|---|
| `blast` | Does not exist | 11 recipe nodes named "Blasting" can't be classified correctly. There's no kind that maps to the Blasting column. |
| `derack` | Exists but `active=False` | 23+ recipe nodes named "De-racking" / "DeRacking" need their own kind for tablet routing clarity (`area_kind='de_racking'`). |
| `demask` | Exists but `active=False` | 33 recipe nodes named "De-Masking" are misclassified as `mask` → land in Masking column. Per spec §D4 De-Masking folds into De-Racking. |
| `gating` | Exists but `active=False` | 50+ "Ready For X" recipe nodes are unclassified gates. Without `gating` they fall back to `other` → catch-all. |
### Defect 7 (library) — 30 step-library templates missing metadata
Step Library audit (38 active templates):
| Field | Has it | Missing |
|---|---|---|
| `code` | 8 | 30 |
| `description` | 8 | 30 |
| Meaningful icon (not `fa-cog`) | 13 | 25 |
| `material_callout` | 0 | 38 |
| `process_type_id` | 0 | 38 |
The 8 well-formed templates (`RECV_STD`, `ELEC_CLEAN_STD`, `STRIKE_STD`, etc.)
came from the XML data file. The remaining 30 came from
`_seed_step_library_if_empty()` (programmatic seed from ENP-ALUM-BASIC recipe)
without their library-management metadata.
Several library templates are also classified to the wrong kind. Examples:
| Template | Currently `kind` | Should be `kind` |
|---|---|---|
| Blasting | `other` | `blast` (kind we're creating) |
| De-Masking | `mask` | `demask` (per spec §D4) |
| Ready for Plating / Ready for processing | `plate` / `other` | `gating` |
| Pre-Measurements / Check Sulfamate Nickel Area | `other` | `inspect` |
| Nickel Strip / Nickel Strip - Steel Line | `plate` | `wet_process` (it's a strip, not plating) |
### Defect 8 (recipe nodes) — in-the-wild misclassifications
Once kinds are fixed and library is corrected, the EXISTING ~880 recipe
nodes still point at the wrong kind in well-defined patterns:
| Pattern | Affected nodes | Re-point to |
|---|---|---|
| `name = 'Blasting'` AND `kind = other` | 11 | `kind = blast` |
| `name ILIKE 'Ready %'` AND `kind != gating` | ~50+ | `kind = gating` |
| `name ILIKE '%De-Masking%' OR '%DeMasking%'` AND `kind = mask` | 33 | `kind = demask` |
| `name = 'Scheduling'` AND `kind = other` | 5 | `kind = gating` |
| `name ILIKE '%Nickel Strip%'` AND `kind = plate` | ~10 | `kind = wet_process` |
| `name ILIKE '%Pre-Measurement%' OR '%Check Sulfamate%'` AND `kind = other` | ~10 | `kind = inspect` |
These are auto-migratable because the patterns are unambiguous. The harder
calls (e.g. "Post Plate Inspection" — `inspect` or `final_inspect`?) stay
manual.
---
## Approved fix
### Change 1 — `_compute_active_step_id` priority chain
Replace the single-state filter with a priority lookup over `step_ids`
sorted by sequence. First match wins:
```
in_progress > paused > ready > first pending
```
If every step is `done` (or no steps exist), returns False — handled by
Change 2.
**Why this order:**
- `in_progress` is the most informative.
- `paused` means someone was working and stopped; the card belongs at that station so the next operator can pick it up.
- `ready` is the next-up step waiting on an operator.
- The first `pending` after a `done` is the "next gate" — where the card visually waits.
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
### Change 2 — `_compute_card_state` edge case
Replace the buggy "no active step → contract_review" fallback with:
```python
if not job.active_step_id:
if job.state == 'done':
job.card_state = 'done'
elif job._fp_inbound_not_received():
job.card_state = 'no_parts'
else:
job.card_state = 'ready' # no steps yet — recipe not assigned
continue
```
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
### Change 3 — Board state filter
Add `('state', 'in', ('confirmed', 'in_progress'))` to the `fp.job` search
domain in `/fp/landing/plant_kanban`. Done + cancelled jobs disappear from
the board; they remain reachable elsewhere.
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
### Change 4 — Column-resolve fallback (comment only)
`_resolve_card_area`'s `'receiving'` fallback stays but updates inline
comment to explain the new semantics (truly orphaned cards only).
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
### Change 5 — `fp.step.kind.area_kind` field (structural)
Add a required Selection field to `fp.step.kind`. Each kind self-declares
which plant-view column its steps belong in.
```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='Shop Floor Column',
required=True,
index=True,
tracking=True,
help='Determines which column on the Shop Floor plant kanban shows '
'cards whose active step uses this kind.',
)
```
**File:** [`fusion_plating/models/fp_step_kind.py`](../../../fusion_plating/models/fp_step_kind.py)
### Change 6 — `_compute_area_kind` priority chain
Simplify `fp.job.step._compute_area_kind`:
```python
@api.depends(
'work_centre_id.area_kind',
'recipe_node_id.kind_id.area_kind',
)
def _compute_area_kind(self):
for step in self:
# 1. work_centre.area_kind (explicit operator setup)
if step.work_centre_id and step.work_centre_id.area_kind:
step.area_kind = step.work_centre_id.area_kind
continue
# 2. recipe_node.kind_id.area_kind (kind taxonomy is authoritative)
node = step.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
step.area_kind = node.kind_id.area_kind
continue
# 3. Catch-all — data integrity issue if we land here
step.area_kind = 'plating'
```
The legacy `_STEP_KIND_TO_AREA` dict is deleted.
**File:** [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py)
### Change 7 — Step Kind UI surfaces `area_kind`
- **Form view** ([`fp_step_kind_views.xml`](../../../fusion_plating/views/fp_step_kind_views.xml)) — add `area_kind` as a prominent picker next to `code` + `name`, with a help-text inline ("Cards whose active step uses this kind appear in this column on the Shop Floor board").
- **List view** — add `area_kind` as a chip column.
- **Simple Editor kind picker** ([`simple_recipe_editor.xml:506-522`](../../../fusion_plating/static/src/xml/simple_recipe_editor.xml)) — option label becomes "Masking — Masking column" so authors see the routing at pick time. Requires updating `kindOptions` payload in [`simple_recipe_controller.py`](../../../fusion_plating/controllers/simple_recipe_controller.py) to include `area_kind` + a human-readable column label per kind.
### Change 8 — Step Kind taxonomy expansion (Cat A)
XML data file additions / updates in
[`fusion_plating/data/fp_step_kind_data.xml`](../../../fusion_plating/data/fp_step_kind_data.xml):
```xml
<!-- NEW: Blasting kind -->
<record id="step_kind_blast" model="fp.step.kind">
<field name="code">blast</field>
<field name="name">Blasting / Media Blast</field>
<field name="sequence">35</field>
<field name="icon">fa-bullseye</field>
<field name="area_kind">blasting</field>
</record>
<!-- Activate existing kinds + set area_kind. The records already exist
from 19.0.20.6.0 with active=False; here we flip + classify.
noupdate=1 protects user edits, so use a one-shot migration to
do the flip on existing installs (Change 10). -->
```
Migration (Change 10) handles the flip on existing installs since the data
file has `noupdate="1"`:
```python
# Activate kinds that were dropped in 19.0.20.6.0 but are needed
# for the area_kind taxonomy to be complete.
for code, area in (
('derack', 'de_racking'),
('demask', 'de_racking'),
('gating', 'receiving'),
):
cr.execute("""
UPDATE fp_step_kind
SET active = TRUE, area_kind = %s
WHERE code = %s AND active = FALSE
""", (area, code))
```
### Change 9 — Step Template metadata backfill + additions (Cat B)
Migration backfills metadata on the 30 templates seeded without it.
Idempotent — only fills NULL/empty fields, doesn't overwrite human edits.
```python
TEMPLATE_BACKFILL = {
# name : (code, icon, kind_code, description_snippet)
'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'),
'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'),
'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'),
'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'),
'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'),
'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'),
'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'),
'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'),
'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'),
'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'),
'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'),
'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'),
'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'),
'Final Inspection': ('FINAL_INSP_STD','fa-check-circle','final_inspect','Final visual + dimensional QA before packing.'),
'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'),
'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'),
'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'),
'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'),
'Nickel Strip - Steel Line': ('NI_STRIP_SL','fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'),
'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle','inspect', 'Post-plate inspection — thickness sample + visual.'),
'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'),
'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'),
'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step — parts staged ready for the plating line.'),
'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step — parts staged ready for the next operation.'),
'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'),
'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'),
'Soak Clean': ('SOAK_CLEAN_STD','fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'),
'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'),
'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'),
'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'),
}
```
New templates (XML data file additions, `noupdate="1"`):
| Name | Code | Kind | Why add |
|---|---|---|---|
| `Hot Water Porosity Test (A-15)` | `HWP_A15` | `inspect` | 7 recipe nodes use it — should be in the library |
| `Final Inspection / Packaging` | `FINAL_PKG_STD` | `final_inspect` | 3 recipe nodes use it; library has separate inspection + packaging but not the combined one |
**Files:**
- [`fusion_plating/data/fp_step_template_data.xml`](../../../fusion_plating/data/fp_step_template_data.xml) — 2 new template records
- Migration (Change 10) — TEMPLATE_BACKFILL loop, idempotent
### Change 10 — Unified migration
New file: [`fusion_plating/migrations/19.0.21.2.0/pre-migrate.py`](../../../fusion_plating/migrations/19.0.21.2.0/pre-migrate.py)
Pre-migrate runs BEFORE the `area_kind NOT NULL` constraint hits the
schema, so it fills values first.
```python
import logging
_logger = logging.getLogger(__name__)
KIND_TO_AREA = {
'other': 'plating', # catch-all default
'wet_process': 'plating',
'receiving': 'receiving',
'contract_review':'receiving',
'gating': 'receiving',
'racking': 'racking',
'derack': 'de_racking',
'mask': 'masking',
'demask': 'de_racking', # spec §D4
'cleaning': 'plating',
'electroclean': 'plating',
'etch': 'plating',
'rinse': 'plating',
'strike': 'plating',
'plate': 'plating',
'replenishment': 'plating',
'wbf_test': 'plating',
'dry': 'plating',
'bake': 'baking',
'inspect': 'inspection',
'final_inspect': 'inspection',
'hardness_test': 'inspection',
'adhesion_test': 'inspection',
'salt_spray': 'inspection',
'packaging': 'shipping',
'ship': 'shipping',
'blast': 'blasting',
'bead_blast': 'blasting',
'media_blast': 'blasting',
}
def migrate(cr, version):
# Phase 1 — seed area_kind on existing kinds BEFORE NOT NULL hits.
for code, area in KIND_TO_AREA.items():
cr.execute("""
UPDATE fp_step_kind SET area_kind = %s
WHERE code = %s AND (area_kind IS NULL OR area_kind = '')
""", (area, code))
# Anything still NULL: default to 'plating' to clear the constraint.
cr.execute("""
UPDATE fp_step_kind SET area_kind = 'plating'
WHERE area_kind IS NULL OR area_kind = ''
""")
_logger.info('[live-step-fix] kind.area_kind seeded')
# Phase 2 — activate the three inactive kinds we need (Cat A).
for code in ('derack', 'demask', 'gating'):
cr.execute("""
UPDATE fp_step_kind SET active = TRUE
WHERE code = %s AND active = FALSE
""", (code,))
_logger.info('[live-step-fix] derack/demask/gating activated')
```
New file: [`fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py`](../../../fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py)
Post-migrate runs AFTER schema sync, so all fields exist with values.
```python
import logging
_logger = logging.getLogger(__name__)
# Library template metadata backfill — copied from spec Change 9.
TEMPLATE_BACKFILL = { ... } # full dict per Change 9
# Recipe node patterns to repoint (Cat C).
NODE_REPOINTING = [
# (name_filter_sql, current_kind_code, new_kind_code, description)
("name = 'Blasting'", 'other', 'blast', 'Blasting → blast'),
("name ILIKE 'Ready %%'", None, 'gating', 'Ready For X → gating'),
("name ILIKE '%%De-Masking%%' OR name ILIKE '%%DeMasking%%'", 'mask', 'demask', 'De-Masking → demask'),
("name = 'Scheduling'", 'other', 'gating', 'Scheduling → gating'),
("name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip → wet_process'),
("name ILIKE '%%Pre-Measurement%%' OR name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate → inspect'),
]
def migrate(cr, version):
from odoo.api import Environment, SUPERUSER_ID
env = Environment(cr, SUPERUSER_ID, {})
# Phase 1 — template metadata backfill (Cat B). Idempotent.
Tpl = env['fp.step.template']
Kind = env['fp.step.kind']
fixed = 0
for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items():
tpl = Tpl.search([('name', '=', name)], limit=1)
if not tpl:
continue
vals = {}
if not tpl.code:
vals['code'] = code
if not tpl.description or tpl.description in ('', '<p><br></p>'):
vals['description'] = f'<p>{desc}</p>'
if tpl.icon == 'fa-cog':
vals['icon'] = icon
kind = Kind.search([('code', '=', kind_code)], limit=1)
if kind and tpl.kind_id.code != kind_code:
vals['kind_id'] = kind.id
if vals:
tpl.write(vals)
fixed += 1
_logger.info('[live-step-fix] template backfill: %s templates updated', fixed)
# Phase 2 — recipe node repointing (Cat C). Pattern-driven SQL.
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
params = []
sql = f"""
UPDATE fusion_plating_process_node n
SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1)
FROM fp_step_kind k
WHERE n.kind_id = k.id
AND ({filter_sql})
"""
params.append(new_code)
if cur_code is not None:
sql += " AND k.code = %s"
params.append(cur_code)
sql += " AND k.code != %s"
params.append(new_code)
cr.execute(sql, params)
_logger.info('[live-step-fix] repointed %s nodes: %s',
cr.rowcount, desc)
# Phase 3 — recompute area_kind on all fp.job.step rows.
steps = env['fp.job.step'].search([])
steps._compute_area_kind()
steps.flush_recordset(['area_kind'])
_logger.info('[live-step-fix] recomputed area_kind on %s steps', len(steps))
# Phase 4 — recompute active_step_id + card_state on in-flight jobs.
jobs = env['fp.job'].search([('state', 'in', ('confirmed', 'in_progress'))])
jobs._compute_active_step_id()
jobs._compute_card_state()
jobs.flush_recordset(['active_step_id', 'card_state'])
_logger.info('[live-step-fix] recomputed jobs: %s', len(jobs))
```
Idempotent across the board: phase 1 only fills NULLs / fa-cog defaults;
phase 2 includes `AND k.code != %s` so re-running won't re-do already
correct rows; phases 3-4 are pure recomputes.
### Change 11 — Version bumps
| Module | From | To |
|---|---|---|
| `fusion_plating` | `19.0.21.1.3` | `19.0.21.2.0` (schema change on fp.step.kind + data file additions) |
| `fusion_plating_jobs` | `19.0.10.23.0` | `19.0.10.24.0` (compute change + migration) |
| `fusion_plating_shopfloor` | `19.0.33.1.2` | `19.0.33.1.3` (controller filter + comment) |
---
## What this approach replaces
| Dropped from the original (pre-restructure) spec | Why |
|---|---|
| `_RESOLVER_KIND_TO_AREA` translation dict | Kind self-declares its column — no translation needed |
| `_resolve_area_kind_from_name` helper | Kind taxonomy is authoritative; name resolution is unnecessary |
| `_STARTER_KIND_BY_NAME` extensions for column routing | The starter resolver is for `default_kind` seeding (Sub 12a library), not column routing — stays as-is for that purpose |
| Parenthetical stripping regex | Not needed when we read the kind directly |
| Backfill of `default_kind` on existing recipe nodes via name resolver | Recipe nodes already have `kind_id` populated by 19.0.20.6.0 pre-migrate |
---
## Test plan
### Manual smoke (on entech after deploy)
1. Open Shop Floor tablet/desktop — confirm the 7 done jobs are GONE from the board.
2. Plating → Configuration → Recipes & Steps → **Step Kind catalog** — confirm:
- `blast` exists, active, area_kind=`blasting`
- `derack`, `demask`, `gating` are now `active=True`, area_kinds correct
- Every kind has area_kind set
3. Plating → Configuration → Recipes & Steps → **Step Library** — confirm:
- All 38 templates now have a code, description, meaningful icon
- `Hot Water Porosity Test (A-15)` and `Final Inspection / Packaging` are listed
- "Blasting" is `kind=blast`, "De-Masking" is `kind=demask`, "Ready for ..." are `kind=gating`
4. Open the Simple Recipe Editor; click "+ Add new kind" — confirm area_kind picker is visible/required in the inline-create flow.
5. Create a fresh test job from any recipe (e.g. ENP-ALUM-BASIC):
a. Confirm it lands in Receiving column with `card_state='ready'`.
b. Walk through all steps — confirm column transitions follow area_kind sequence.
c. Mark job done → confirm card drops off the board.
6. Verify a step with `state='paused'` keeps the card at its column.
### Spot-check existing data
```sql
-- Every node should have a kind with area_kind set.
SELECT n.id, n.name, k.code, k.area_kind
FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE k.area_kind IS NULL OR k.area_kind = '';
-- expected: 0 rows
-- Blasting nodes should now use blast kind.
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE n.name = 'Blasting' GROUP BY k.code;
-- expected: all rows have k.code = 'blast'
-- Ready For X gating nodes.
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE n.name ILIKE 'Ready %' GROUP BY k.code;
-- expected: all rows have k.code = 'gating'
-- De-Masking nodes use demask.
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
JOIN fp_step_kind k ON k.id = n.kind_id
WHERE n.name ILIKE '%De-Masking%' OR n.name ILIKE '%DeMasking%'
GROUP BY k.code;
-- expected: all rows have k.code = 'demask'
-- Template code coverage.
SELECT COUNT(*) FROM fp_step_template
WHERE active = TRUE AND (code IS NULL OR code = '');
-- expected: 0
```
### Automated battle test
New script: `fusion_plating_quality/scripts/bt_s24_between_steps.py` covering
the live-step priority chain end-to-end (see prior version of the spec for
full pseudocode — unchanged).
### Existing tests
Existing tests in `fusion_plating_shopfloor/tests/` and
`fusion_plating_jobs/tests/` may need updates for:
- The new `state` filter in `/fp/landing/plant_kanban`.
- The new `active_step_id` priority chain.
Re-run all `bt_s*.py` scripts to confirm no regressions in S1-S23.
---
## Roll-out
1. Implement Changes 1-11 in a single branch.
2. Local dev test (`docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init`).
3. Deploy to entech using the standard `pct exec 111` flow. Pre-migrate seeds run automatically.
4. Verify on entech with manual smoke + SQL spot-checks.
5. Commit + push to GitHub.
---
## Non-goals (explicit)
- **Re-assigning historical steps to `work_centre_id`.** The 85+ steps with NULL `work_centre_id` stay that way. The kind→area_kind lookup gives them correct `area_kind` without needing a work_centre.
- **Recipe authoring UX changes** beyond the kind picker hint. Required-field enforcement on `kind_id` already exists.
- **Removing the "Other" kind.** Stays as a catch-all default mapped to `'plating'`.
- **Card_state precedence rework.** Rules 1-13 stay; only the edge-case fallback changes.
- **Mini-timeline rendering.** Separate compute (`mini_timeline_json`), out of scope.
- **Hidden-but-recent done jobs.** No "recent shipments" filter.
- **Subjective node re-classification.** "Post Plate Inspection" stays whatever the recipe author picked (`inspect` vs `final_inspect`). Only the unambiguous patterns in Change 10 phase 2 are auto-migrated.
- **process_type_id / material_callout backfill on templates.** Out of scope for this spec — those need recipe-author input per template.
---
## Files touched (summary)
| File | Change |
|---|---|
| `fusion_plating/models/fp_step_kind.py` | New `area_kind` Selection field (Change 5) |
| `fusion_plating/views/fp_step_kind_views.xml` | Add area_kind to form + list (Change 7) |
| `fusion_plating/controllers/simple_recipe_controller.py` | Include area_kind + label in kindOptions (Change 7) |
| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | Kind picker shows "→ Column" suffix (Change 7) |
| `fusion_plating/data/fp_step_kind_data.xml` | New `step_kind_blast` record (Change 8) |
| `fusion_plating/data/fp_step_template_data.xml` | New `Hot Water Porosity Test` + `Final Inspection / Packaging` templates (Change 9) |
| `fusion_plating/migrations/19.0.21.2.0/pre-migrate.py` | NEW — seed area_kind, activate kinds (Change 10 phase 1-2) |
| `fusion_plating/__manifest__.py` | Version bump (Change 11) |
| `fusion_plating_jobs/models/fp_job.py` | Rewrite `_compute_active_step_id` (Change 1) + `_compute_card_state` edge case (Change 2) |
| `fusion_plating_jobs/models/fp_job_step.py` | Simplify `_compute_area_kind` (Change 6); drop `_STEP_KIND_TO_AREA` dict |
| `fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py` | NEW — template backfill + node repointing + recomputes (Change 10 phase 1-4) |
| `fusion_plating_jobs/__manifest__.py` | Version bump (Change 11) |
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | Add state filter (Change 3) + comment (Change 4) |
| `fusion_plating_shopfloor/__manifest__.py` | Version bump (Change 11) |
| `fusion_plating_quality/scripts/bt_s24_between_steps.py` | NEW — battle test |
Estimated diff: ~400 lines added (most in the migration data tables), ~30 modified, ~50 deleted (the `_STEP_KIND_TO_AREA` dict goes away).

View File

@@ -0,0 +1,425 @@
# Job Workspace — Per-Kind Step Actions
**Date:** 2026-05-24
**Modules:** `fusion_plating_jobs`, `fusion_plating_shopfloor`
**Status:** Approved, awaiting implementation plan.
**Sub-project:** A of 2. Sub-B (Record Inputs tablet polish — `inputmode`, prefill,
date/time pickers, signature pad, camera) is brainstormed but DEFERRED.
---
## Problem
Operator opens WO-30057 in the Job Workspace tablet view. Step 1 (Contract Review)
shows ✓ (auto-completed from prior QA-005). Steps 2-12 each show only a bare
`○ Step N <name>` row — **no Start button, no action of any kind**. The operator
has no way to advance the job from this screen, even though every step is
`state='ready'` and `can_start=True` on the backend.
### Root cause
In [`job_workspace.xml:105`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml),
the expanded step-detail block is gated:
```xml
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
<div class="o_fp_ws_step_detail">
...
<!-- Start button is INSIDE this parent gate -->
<div t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
<button t-on-click="() => this.onStartStep(step.id)">Start</button>
</div>
</div>
</t>
```
`isStepActive` returns true only when `step.state === 'in_progress'`. For a
`state='ready'` step with no blocker, the parent `<t t-if>` is false — the whole
detail block (incl. the inner Start button) never renders. **Dead code.**
### Secondary gaps
Even if Start were reachable, certain step kinds need different actions, not a
generic Start/Finish chain:
| Kind | Today (broken) | What it actually needs |
|---|---|---|
| `contract_review` | Hidden Start button | **Open QA-005 Form** button (uses existing `_fp_open_contract_review`) |
| `gating` | Hidden Start, then operator clicks Finish too | **1-click "Mark Passed"** (no work to do — it's an admin gate) |
| `requires_rack_assignment=True` | Hidden Start | Start should open the **Rack Parts** dialog first |
| `state='paused'` | Hidden Start | Should show **Resume** + Finish + Record Inputs |
| all kinds, `state='in_progress'` | Shows Finish, Record Inputs | Missing a **Pause** button |
### Operator can't see what's coming
The recipe-author info (thickness target, dwell time, bake temp, sign-off
required) currently only renders on the active step. Operators can't read ahead
to know what they're about to start. CLAUDE.md S20 "Tablet usability pass" called
this out for the per-step kanban; the same gap exists in the Job Workspace.
---
## Approved fix
### Change 1 — Template restructure
Replace the parent-gated detail block in [`job_workspace.xml:88-170`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml)
with three independent rendering layers per step:
```xml
<div t-att-class="...">
<!-- [ALWAYS] Line 1: icon + step# + name + meta -->
<div class="o_fp_ws_step_l1">
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
<span class="o_fp_ws_step_name" t-esc="step.name"/>
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
<span class="o_fp_ws_step_meta">...assignee, duration...</span>
</div>
<!-- [NON-TERMINAL] Read-ahead detail: chips + instructions + GateViz -->
<div class="o_fp_ws_step_detail"
t-if="step.state not in ('done', 'skipped', 'cancelled')">
<!-- chips (thickness/dwell/bake/signoff) -->
<div class="o_fp_ws_step_chips">
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">🎯 Thickness ...</span>
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">⏱ Dwell ...</span>
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">🔥 Bake ...°</span>
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">✎ Sign-off</span>
</div>
<!-- recipe instructions -->
<div t-if="step.instructions" class="o_fp_ws_step_instr"><t t-esc="step.instructions"/></div>
<!-- opt-out -->
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
<i class="fa fa-ban"/> Skipped per recipe override
</div>
<!-- blocker viz -->
<GateViz t-if="step.blocker_kind !== 'none'"
canStart="false"
blockerKind="step.blocker_kind"
blockerReason="step.blocker_reason"
jumpTargetModel="step.blocker_jump_target_model"
jumpTargetId="step.blocker_jump_target_id"
onJump.bind="onJumpToBlocker"/>
</div>
<!-- [ACTIONABLE] Action row — per-kind buttons per the dispatcher -->
<div class="o_fp_ws_step_actions"
t-if="!step.override_excluded
and step.blocker_kind === 'none'
and step.state not in ('done', 'skipped', 'cancelled')">
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
<button t-att-class="action.cssClass"
t-on-click="() => this.dispatchStepAction(step, action.key)">
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
</button>
</t>
</div>
</div>
```
Keep the existing `isStepActive(step)` helper for the ACTIVE badge but **don't**
let it gate the detail block.
### Change 2 — `getStepActions(step)` per-kind dispatcher
New JS helper in [`job_workspace.js`](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js).
Returns an array of action descriptors based on `step.state` + `step.kind` +
`step.requires_rack_assignment` + `step.requires_signoff`:
```js
getStepActions(step) {
// Done/skipped/cancelled → no actions (caller already hides)
if (['done', 'skipped', 'cancelled'].includes(step.state)) return [];
// Blocked → no actions (caller already shows GateViz)
if (step.blocker_kind && step.blocker_kind !== 'none') return [];
if (step.override_excluded) return [];
const actions = [];
if (step.state === 'in_progress') {
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
actions.push({ key: 'pause', label: 'Pause', icon: 'fa fa-pause', cssClass: 'btn btn-light' });
actions.push({
key: 'finish',
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
icon: 'fa fa-check', cssClass: 'btn btn-success'
});
return actions;
}
if (step.state === 'paused') {
actions.push({ key: 'resume', label: 'Resume', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
actions.push({
key: 'finish',
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
icon: 'fa fa-check', cssClass: 'btn btn-success'
});
return actions;
}
// state in ('pending', 'ready') — entry-point per kind
if (step.kind === 'contract_review') {
actions.push({ key: 'open_contract_review', label: 'Open QA-005 Form',
icon: 'fa fa-file-text-o', cssClass: 'btn btn-primary' });
return actions;
}
if (step.kind === 'gating') {
actions.push({ key: 'mark_passed', label: 'Mark Passed',
icon: 'fa fa-check-circle', cssClass: 'btn btn-success' });
return actions;
}
if (step.requires_rack_assignment) {
actions.push({ key: 'start_with_rack', label: 'Start (Assign Rack)',
icon: 'fa fa-server', cssClass: 'btn btn-primary' });
return actions;
}
// Default
actions.push({ key: 'start', label: 'Start', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
return actions;
}
```
### Change 3 — `dispatchStepAction(step, key)`
Single router method that delegates to handler methods:
```js
async dispatchStepAction(step, key) {
switch (key) {
case 'start': return this.onStartStep(step.id);
case 'resume': return this.onResumeStep(step); // button_resume — distinct from button_start
case 'pause': return this.onPauseStep(step);
case 'record_inputs': return this.onRecordInputs(step);
case 'finish': return this.onFinishStep(step);
case 'mark_passed': return this.onMarkPassed(step);
case 'open_contract_review': return this.onOpenContractReview(step);
case 'start_with_rack': return this.onStartWithRack(step);
}
}
```
### Change 4 — New JS handlers
**`onPauseStep(step)`** — calls `fp.job.step.button_pause` via ORM RPC.
(No `/fp/shopfloor/pause_wo` HTTP endpoint exists; the legacy stop_wo
endpoint's docstring claims pause isn't implemented but `button_pause`
does exist in `fusion_plating/models/fp_job_step.py:320`. Using ORM
RPC sidesteps the need to add a new HTTP route.)
```js
async onPauseStep(step) {
const reason = window.prompt(`Pause reason for "${step.name}"?`, '');
if (reason === null) return; // operator cancelled
try {
await rpc('/web/dataset/call_kw', {
model: 'fp.job.step', method: 'button_pause',
args: [[step.id]],
kwargs: { reason: reason || 'no reason given' },
});
this.notification.add('Step paused.', { type: 'success' });
await this.refresh();
} catch (err) {
this.notification.add(err.message, { type: 'danger' });
}
}
```
**`onResumeStep(step)`** — calls `fp.job.step.button_resume` via ORM RPC.
Distinct from `onStartStep` because the model has separate methods:
`button_start` is for state=ready → in_progress; `button_resume` is for
state=paused → in_progress (preserves accrued time + reason audit).
```js
async onResumeStep(step) {
try {
await rpc('/web/dataset/call_kw', {
model: 'fp.job.step', method: 'button_resume',
args: [[step.id]], kwargs: {},
});
this.notification.add('Step resumed.', { type: 'success' });
await this.refresh();
} catch (err) {
this.notification.add(err.message, { type: 'danger' });
}
}
```
**`onMarkPassed(step)`** — calls a new ORM method `action_mark_gating_passed`
which does `button_start` + `button_finish` in one server call:
```js
async onMarkPassed(step) {
try {
await rpc('/web/dataset/call_kw', {
model: 'fp.job.step', method: 'action_mark_gating_passed',
args: [[step.id]], kwargs: {},
});
this.notification.add('Gate marked passed.', { type: 'success' });
await this.refresh();
} catch (err) {
this.notification.add(err.message, { type: 'danger' });
}
}
```
**`onOpenContractReview(step)`** — calls the existing `_fp_open_contract_review`
helper on `fp.job.step` (per CLAUDE.md Policy B section). Returns an act_window
that the action service opens. After dialog close, refresh:
```js
async onOpenContractReview(step) {
try {
const result = await rpc('/web/dataset/call_kw', {
model: 'fp.job.step', method: '_fp_open_contract_review',
args: [[step.id]], kwargs: {},
});
if (result) {
await this.action.doAction(result, { onClose: () => this.refresh() });
}
} catch (err) {
this.notification.add(err.message || "Couldn't open QA-005", { type: 'danger' });
}
}
```
**`onStartWithRack(step)`** — opens the existing Rack Parts dialog from
`move_controller.py`. On commit (rack assigned + parts loaded), calls
`onStartStep(step.id)`. Implementation reuses `FpRackPartsDialog` from
`fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js`:
```js
async onStartWithRack(step) {
this.dialog.add(FpRackPartsDialog, {
jobId: this.state.jobId,
stepId: step.id,
partRef: this.state.data.job.part_number || '',
defaultQty: this.state.data.job.qty || 1,
onCommitted: async () => {
// Rack assigned → now start the step
await this.onStartStep(step.id);
},
});
}
```
### Change 5 — New backend method `action_mark_gating_passed`
In [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py),
add:
```python
def action_mark_gating_passed(self):
"""1-click pass for gating steps (kind=='gating'). Performs
button_start() then button_finish() in the same transaction.
Posts chatter ("Gate marked passed by <user>") on the parent job.
Only valid for state in (ready, pending, paused) — defensive
NOOP otherwise (idempotent on repeat clicks).
"""
for step in self:
if step.state in ('done', 'skipped', 'cancelled'):
continue
kind_code = step.recipe_node_id.kind_id.code if (
step.recipe_node_id and step.recipe_node_id.kind_id
) else None
if kind_code != 'gating':
raise UserError(_(
"action_mark_gating_passed is only valid for gating "
"steps (this step has kind=%s).") % (kind_code or 'unknown'))
if step.state not in ('ready', 'pending', 'paused'):
continue
# Resume if paused, then start, then finish — bypass the input
# gate (gating steps have no required inputs by design).
if step.state == 'paused':
step.button_resume()
if step.state != 'in_progress':
step.button_start()
step.with_context(
fp_skip_required_inputs_gate=True,
).button_finish()
step.job_id.message_post(body=_(
'Gate "%(name)s" marked passed by %(user)s.'
) % {'name': step.name, 'user': self.env.user.name})
return True
```
### Change 6 — Verify controller payload has `requires_rack_assignment`
The workspace controller payload at
[`workspace_controller.py:75-95`](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)
already includes `kind`, `state`, `can_start`, `requires_signoff`,
`blocker_kind`. Verify `requires_rack_assignment` is included; if not, add it:
```python
'requires_rack_assignment': bool(getattr(step, 'requires_rack_assignment', False)),
```
### Change 7 — Version bumps
| Module | From | To |
|---|---|---|
| `fusion_plating_jobs` | `19.0.10.26.0` | `19.0.10.27.0` (new `action_mark_gating_passed` method) |
| `fusion_plating_shopfloor` | `19.0.33.1.4` | `19.0.33.1.5` (JS + XML restructure + controller payload) |
No data migration needed — purely behavioural / UX.
---
## Test plan
### Manual smoke (after deploy)
1. Open WO-30057 in Job Workspace.
2. Confirm Step 1 (Contract Review, done) shows ✓ + name, NO buttons.
3. Confirm Step 2 (Masking, ready) shows **Start** button.
4. Click Start → confirm step transitions to `in_progress` → buttons swap to Record Inputs, Pause, Finish.
5. Click Pause → confirm prompt → confirm step transitions to `paused` → buttons swap to Resume, Record Inputs, Finish.
6. Click Resume → confirm back to `in_progress` + correct buttons.
7. Click Finish → confirm step completes → next step (Incoming Inspection, ready) now shows Start.
8. Locate a job with a Contract Review step that hasn't been auto-completed (rare — most parts have prior QA-005). Confirm **Open QA-005 Form** button. Click → form opens. Submit → refresh → step completes.
9. Locate or create a job with a Gating step (kind='gating'). Confirm **✓ Mark Passed** button. Click → step jumps from ready to done in one click.
10. Find a step where `requires_signoff=True`. Click Finish → signature pad opens (existing behaviour). Sign → step completes.
11. Find a blocked step (predecessor not done). Confirm GateViz renders, NO action buttons.
12. Find an opt-out step (`override_excluded=True`). Confirm "Skipped per recipe override" notice, NO action buttons.
### Smoke for chip / instructions visibility
13. On any in-flight job, confirm chips (🎯 thickness, ⏱ dwell, 🔥 bake, ✎ sign-off) + recipe instructions render on **every non-done step** (not just the active one). Operator can read ahead.
### Battle test followup
Defer to Sub B (no new automated test for this UX-only change — covered by manual smoke).
---
## Out of scope (explicit)
- **`inputmode` attributes / number keyboards / prefill / date/time pickers /
signature pad in Record Inputs / camera capture** — all deferred to Sub B
(record-inputs tablet polish).
- **Auditing every kind's default input prompts** — deferred to Sub B. The
existing dialog renders all 15 input_types; Sub B verifies each is good UX.
- **Skip step button** — supervisor-only, accessible via backend form. Not
adding to operator workspace.
- **Reassign step** — supervisor-only.
- **Per-recipe ordering or kind fixes** — already covered by recent recipe
cleanup spec.
---
## Files touched
| File | Change |
|---|---|
| `fusion_plating_shopfloor/static/src/xml/job_workspace.xml` | Template restructure — always-visible action row + non-terminal detail block (Change 1) |
| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `getStepActions`, `dispatchStepAction`, `onPauseStep`, `onMarkPassed`, `onOpenContractReview`, `onStartWithRack` (Changes 2-4) |
| `fusion_plating_shopfloor/static/src/scss/job_workspace.scss` | Minor styling for the action row (consistent spacing across button counts) |
| `fusion_plating_shopfloor/controllers/workspace_controller.py` | Add `requires_rack_assignment` to step payload if missing (Change 6) |
| `fusion_plating_shopfloor/__manifest__.py` | Bump to `19.0.33.1.5` (Change 7) |
| `fusion_plating_jobs/models/fp_job_step.py` | Add `action_mark_gating_passed()` method (Change 5) |
| `fusion_plating_jobs/__manifest__.py` | Bump to `19.0.10.27.0` (Change 7) |
Estimated diff: ~200 lines added, ~50 modified, ~10 deleted.

View File

@@ -0,0 +1,610 @@
# Post-Shop Cert + Shipping Job States
**Date:** 2026-05-25
**Status:** Approved for implementation (brainstorming gate)
**Author:** Brainstorming session (gsinghpal)
**Triggering incident:** Job WO-30058 (SO-30058) finished all recipe steps on entech and **disappeared from the Shop Floor kanban**. CoC was auto-spawned in `draft` but nobody was notified, no surface listed pending certs for the Quality Manager, and there was no kanban column for "completed-but-not-shipped" jobs. Operators reported jobs they had finished feeling "lost" — same risk that a job could leave the building without a CoC.
## Goal
Three things, decided as one unit of work:
1. **Stop completed-but-uncertified jobs from disappearing** from the Plant Kanban — they must stay visible to shop staff so jobs aren't forgotten or shipped without paperwork.
2. **Give the Quality Manager a dedicated surface** (Quality Dashboard tab + email + in-app activity) for CoC issuance, with hard ACL gating so Technicians cannot self-issue.
3. **Model the actual lifecycle** in `fp.job.state` so reporting, queries, and future workflow gates derive from a single source of truth instead of cross-module joins.
## Out of scope (deferred to follow-on work)
- **Shipping label printing, carrier dispatch, tracking-number capture, BoL generation.** These remain manual for now; the new `awaiting_ship` state is a parking column so jobs are visible to the shipping crew. `awaiting_ship → done` is a manual button click. (User: *"lets first finish this certification step then we will look into shipping"*.)
- **Auto-transition from `awaiting_ship → done` on `delivery.action_mark_delivered`.** Scaffolded as a future hook in the design; not wired in this scope.
- **RMA-aware regression** (job re-opening on RMA receive). Already handled by existing RMA flow — not touched here.
- **Per-cert-type ACL** (e.g. only QM can issue Nadcap, but Manager can issue CoC). Out of scope; single QM-or-higher gate for all cert types in v1.
## Decisions reached during brainstorming
| # | Decision | Rationale |
|---|---|---|
| D1 | Use **Approach A** — add two new `fp.job.state` values (`awaiting_cert`, `awaiting_ship`) | Cleanest semantics; `state='done'` will once again mean "fully complete and shipped". Single source of truth for kanban, dashboard, reporting, and future delivery automation. |
| D2 | **Auto-advance** `in_progress → awaiting_cert` when every recipe step is terminal AND a cert is required | No new operator button; removes risk of forgetting to advance. Hooks into existing `all_steps_terminal` computed field. |
| D3 | **Auto-advance** `awaiting_cert → awaiting_ship` from `fp.certificate.action_issue` when every required cert is `issued` | The QM clicking Issue is the natural trigger; no separate "mark inspection complete" button needed. The recipe's final-inspection step already captures inspection data via custom prompts. |
| D4 | **Cert void regresses state** (`awaiting_ship → awaiting_cert` if a previously-issued cert is voided) | Defensive against late-discovered issues. Re-fires `cert_awaiting_issuance` notification under a `cert_voided_re_notify` event so dedupe doesn't suppress it. |
| D5 | **Manual `awaiting_ship → done` button** in this scope; auto-hook on delivery deferred | User explicitly scoped shipping work out. The button is repurposed from the existing milestone-advance "Mark Done" button (renamed "Mark Shipped"), restricted to Manager/Owner. |
| D6 | **ACL gate on `action_issue`** — Manager / Quality Manager / Owner only | User: *"certifications can only be issued by Manager, Quality Manager and Owner, i don't want technicians to issue certifications"*. Two-layer enforcement: Python `AccessError` + view-level `groups=` on the Issue button. |
| D7 | **Group-membership resolved via `all_group_ids`** (transitive) — Owners reach QM authority via implication chain | Rule 23 (CLAUDE.md). Owners don't carry `group_fp_quality_manager` directly; the `all_group_ids` lookup catches them via implication. |
| D8 | **Notification fires on the auto-transition**, not on the operator's last step-finish click | Decouples notification from operator UX; transition is the authoritative trigger so all paths (UI, RPC, scripted) notify consistently. |
| D9 | **Belt-and-suspenders: in-app `mail.activity`** in addition to email | If email bounces / spam-folders, the red activity badge on the QM's home is the floor-of-truth signal. Activity auto-resolves on `awaiting_ship` transition. |
| D10 | **Jobs that don't require certs skip `awaiting_cert`** and land directly in `awaiting_ship` | Visibility consistency — every completed-but-unshipped job is in the Shipping column, regardless of cert requirements. No silent path to `done`. |
| D11 | **Plant kanban shows the two new states in `Final inspection` and `Shipping` columns**; old per-step `area_kind` logic untouched for `in_progress` | Repurposes the two right-most columns that are currently almost always empty. State drives column for the new values; nothing else changes. |
| D12 | **Gates from `button_mark_done` (bake/qty/QC) move UP into `fp.job.step.button_finish` on the LAST step** instead of running at auto-transition time | The auto-transition itself must not raise — if it raised when the operator finishes their last step, the step would finish but the auto-advance would silently fail with no error path. Better: when finishing the last step, surface the pending gates as UserError on the finish click. Operator fixes (qty, bake, QC), retries finish, transition fires cleanly. Step-completion gate is implied (the step IS finishing). |
## Architecture
```
┌─ STATE MACHINE ─────────────────────────────────────────────────┐
│ │
│ confirmed → in_progress → awaiting_cert → awaiting_ship → done│
│ │ ▲ ▲ ▲ │
│ │ │ │ │ │
│ all steps terminal QM Issue (last cert) Mark Shipped│ │
│ + cert required button │ │
│ │ │ │
│ └──── (no cert required) ─────────────────┘ │
│ │
│ awaiting_ship ──(cert voided after issue)──► awaiting_cert
│ │
└─────────────────────────────────────────────────────────────────┘
┌─ KANBAN VISIBILITY (PLANT VIEW) ────────────────────────────────┐
│ │
│ Receiving · Masking · Blasting · Racking · Plating │
│ · Baking · De-Racking · ★Final inspection · ★Shipping │
│ ▲ ▲ │
│ │ │ │
│ awaiting_cert awaiting_ship │
│ (column = state, not active_step) │
│ │
│ Domain widens: state IN (confirmed, in_progress, │
│ awaiting_cert, awaiting_ship) │
└─────────────────────────────────────────────────────────────────┘
┌─ QUALITY DASHBOARD ─────────────────────────────────────────────┐
│ │
│ Holds | Checks | NCRs | CAPAs | RMAs | ★Certificates │
│ (new 6th tab) │
│ │
│ Tab content: kanban grouped by state, Draft folded open, │
│ filters (My Customer / Today / Overdue >24h / │
│ Missing Fischerscope), buttons Open Cert / Open Job. │
│ │
│ Header KPI: "Certificates Awaiting Issuance: N (M overdue)" │
└─────────────────────────────────────────────────────────────────┘
┌─ NOTIFICATION (when state hits awaiting_cert) ──────────────────┐
│ │
│ Email via fp.notification.template, event: │
│ cert_awaiting_issuance (first notification) │
│ cert_voided_re_notify (re-fires after cert void) │
│ │
│ Recipients: every active non-share user with all_group_ids │
│ containing QM | Manager | Owner. Resolved by helper │
│ _fp_resolve_cert_authority_users() (see Rule 13l pattern). │
│ │
│ + mail.activity ("To Do") assigned to one QM, round-robin │
│ by last_activity_at. Auto-marks done on awaiting_ship. │
└─────────────────────────────────────────────────────────────────┘
```
## Schema changes (additive)
### `fp.job` model
| Change | Type | Notes |
|---|---|---|
| `state` selection — add `('awaiting_cert', 'Awaiting Cert')` and `('awaiting_ship', 'Awaiting Ship')` | Selection extension | Selection extension via `_inherit` keeps tracking + chatter audit working. Sequence: confirmed → in_progress → awaiting_cert → awaiting_ship → done → cancelled. |
| New method `_fp_check_advance_post_shop()` | Method | Called from step-state-change hooks (specifically from `fp.job.step.button_finish` after `super()`). If all `step_ids` are in `('done','skipped','cancelled')` AND state is `in_progress`: transitions to `awaiting_cert` (if `_resolve_required_cert_types()` non-empty) or `awaiting_ship` (if empty). Idempotent. Does NOT raise — gates moved into step.button_finish per D12. |
| Hardened `fp.job.step.button_finish` for the LAST open step | Method | When finishing a step that would leave all steps terminal, run the bake-window / qty-reconciliation / QC gates from the old `button_mark_done` BEFORE allowing the finish. On failure: raise UserError, step stays open, operator fixes + retries. Same manager-bypass context flags as today (`fp_skip_bake_gate`, `fp_skip_qty_reconcile`, `fp_skip_qc_gate`). |
| New method `_fp_check_advance_after_cert_issue()` | Method | Called from `fp.certificate.action_issue`. If every required cert for the job is `issued`: transition `awaiting_cert → awaiting_ship`. Idempotent. |
| New method `_fp_check_regress_after_cert_void()` | Method | Called from `fp.certificate.write({'state':'voided'})`. If any required cert is no longer `issued`: transition `awaiting_ship → awaiting_cert` and re-fire notification under `cert_voided_re_notify`. |
| New method `button_mark_shipped()` | Method | Manual transition `awaiting_ship → done`. Restricted via `groups=` on the form button (Manager/Owner). Reuses the existing `button_mark_done` body for side effects (delivery wiring, notifications, chatter), but **does not include the step/QC/bake/qty gates** (those already passed when we transitioned to `awaiting_cert`). |
| Repurpose existing `button_mark_done()` | Method | Becomes an internal-only method called from the auto-transitions. Visible operator action is now `button_mark_shipped`. Existing callers remain valid but are now triggered by the state machine rather than direct user clicks. |
### `fp.certificate` model
| Change | Type | Notes |
|---|---|---|
| Hardened `action_issue()` | Method | Adds Python-side `AccessError` if user lacks QM/Manager/Owner. Calls `job._fp_check_advance_after_cert_issue()` after successful issue. Manager bypass via context flag `fp_skip_cert_authority_gate=True` (posts chatter audit). |
| `write({'state':'voided'})` override | Method | Calls `job._fp_check_regress_after_cert_void()` after the void completes. |
| `x_fc_age_hours` | Float, non-stored, computed | Drives the Quality Dashboard age chip + overdue filter. `(now - create_date).total_seconds() / 3600`. |
### `fp.notification.template` data
| Change | Notes |
|---|---|
| New selection values on `trigger_event`: `('cert_awaiting_issuance', 'Cert Awaiting Issuance')`, `('cert_voided_re_notify', 'Cert Voided — Please Re-Issue')` | Loaded via `data/fp_notification_events_data.xml`. |
| Two seeded `fp.notification.template` records (one per event) | `data/fp_cert_authority_templates.xml`. Default body shown under "Notification changes" further below. Editable via UI (Plating → Configuration → Quality & Documents → Notification Templates). |
| New recipient resolver helper `_fp_resolve_cert_authority_users(job)` on `fp.notification.template` | Wraps the group-membership search (Rule 13l pattern). Dispatched when `trigger_event` is `cert_awaiting_issuance` or `cert_voided_re_notify`. |
### `mail.activity` integration
| Change | Notes |
|---|---|
| New `mail.activity.type` xmlid `fusion_plating_jobs.activity_type_issue_coc` | Title: "Issue CoC". Default summary template: `Issue CoC for {{ job.display_wo_name }}`. |
| `_fp_schedule_cert_activity(job)` helper on `fp.job` | Picks one QM from `_fp_resolve_cert_authority_users`, sorted by `login_date asc nulls first` — the QM who logged in least recently (likely least busy / hasn't been on the system in a while). Creates the activity. Auto-resolves on `awaiting_ship` transition. `login_date` chosen over a custom `last_activity_at` field because it's standard on `res.users` and always populated. |
## Plant Kanban changes
### Controller — `fusion_plating_shopfloor/controllers/plant_kanban.py`
```python
# Line 73-75 — widen the domain
domain = [
('state', 'in', ('confirmed', 'in_progress',
'awaiting_cert', 'awaiting_ship')),
]
# Line ~165 — extend _resolve_card_area
def _resolve_card_area(job):
if job.card_state == 'no_parts':
return 'receiving'
if job.state == 'awaiting_cert':
return 'inspection'
if job.state == 'awaiting_ship':
return 'shipping'
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
return 'receiving' # orphan fallback (unchanged)
```
### Card-state catalog — `fusion_plating_jobs/models/fp_job.py`
Two new values added to the `_compute_card_state` precedence chain. Inserted BEFORE the existing `done` rule (which is now unreachable from `state='done'` jobs anyway because they're filtered off the board):
```python
# Before existing Rule 8 (done):
if job.state == 'awaiting_cert':
job.card_state = 'awaiting_cert'
continue
if job.state == 'awaiting_ship':
job.card_state = 'awaiting_ship'
continue
```
### Chip rendering — `_state_chip()` in plant_kanban.py
```python
if card_state == 'awaiting_cert':
return {'label': _('🏷️ Awaiting CoC'), 'kind': 'awaiting_cert'}
if card_state == 'awaiting_ship':
return {'label': _('📦 Ready to ship'), 'kind': 'awaiting_ship'}
```
### Sort priority — `_SORT_PRIORITY` in plant_kanban.py
```python
'awaiting_cert': 3.5, # right after awaiting_signoff
'awaiting_ship': 8.5, # right after running
```
(Floats are fine — `_sort_key` returns a tuple sorted ascending, no integer assumption.)
### SCSS — `fusion_plating_shopfloor/static/src/scss/_plant_card.scss`
Add two new state modifier classes following the existing pattern:
```scss
.o_fp_plant_card.state-awaiting_cert {
background-color: var(--fp-state-awaiting-cert-bg, #fff3cd);
border-left: 4px solid var(--fp-state-awaiting-cert-border, #ff9800);
}
.o_fp_plant_card.state-awaiting_ship {
background-color: var(--fp-state-awaiting-ship-bg, #d1f1d4);
border-left: 4px solid var(--fp-state-awaiting-ship-border, #2e7d32);
}
```
Tokens go in `_plant_tokens.scss` first (per Rule 8), with `@if $o-webclient-color-scheme == dark` darker variants (per Rule 9).
### KPI strip + filter chips — `fusion_plating_shopfloor/static/src/js/plant_kanban.js`
```javascript
// Two new KPI tiles
{ key: 'awaiting_cert', label: _t('Awaiting CoC'),
count: kpis.awaiting_cert, kind: 'awaiting_cert' },
{ key: 'awaiting_ship', label: _t('Ready to Ship'),
count: kpis.awaiting_ship, kind: 'awaiting_ship' },
// Two new filter chip
{ key: 'awaiting_cert', label: _t('Awaiting CoC') },
{ key: 'awaiting_ship', label: _t('Ready to Ship') },
```
Server-side KPI compute (in `plant_kanban` endpoint):
```python
'awaiting_cert': sum(1 for j in jobs if j.state == 'awaiting_cert'),
'awaiting_ship': sum(1 for j in jobs if j.state == 'awaiting_ship'),
```
### Mini-timeline strip — `_compute_mini_timeline_json` in fp_job.py
When `state='awaiting_cert'`: the `inspection` dot renders as `current` with `variant='awaiting_cert'`; all 7 earlier dots render `done`; shipping dot renders `upcoming`. Same shape when `state='awaiting_ship'` — shipping is `current` with `variant='awaiting_ship'`, inspection is `done`. Lets the QM see at a glance "this card has cleared the whole line, just waiting on paperwork/shipping."
## Quality Dashboard changes
### Counts endpoint — `fusion_plating_quality/controllers/fp_quality_dashboard.py`
Extend the existing `/fp/quality/dashboard/counts` response with one block:
```python
Cert = env['fp.certificate']
return {
# existing blocks (holds, checks, ncrs, capas, rmas) unchanged
'certificates': {
'open': Cert.search_count([('state', '=', 'draft')]),
'overdue': Cert.search_count([
('state', '=', 'draft'),
('create_date', '<', d1), # >24h = overdue
]),
},
}
```
### Tab UI — `fusion_plating_quality/static/src/{js,xml,scss}/fp_quality_dashboard.*`
Sixth tab "Certificates" with:
- **Kanban view** grouped by `state` (Draft / Issued / Voided), Draft folded open by default.
- **Card content** (per cert): WO# + part# (large), customer name, cert-type chip (CoC / CoC+Thickness / Nadcap), age chip (`Created 4h ago`, red past 24h), status badges (🟢 Thickness PDF ready · 📋 Inspection prompts captured · ⚠ Missing spec ref).
- **Two card actions**: `Open Cert` (cert form) · `Open Job` (source job, lets QM audit inspection prompts before issuing).
- **Filters above kanban**: My Customer / Today / Overdue (>24h) / Missing Fischerscope (status='pending' from S19) / High-severity Customer.
- **Group-by option**: Customer.
**Header KPI** card on the dashboard gains: `Certificates Awaiting Issuance: <count>` with overdue sub-count in red.
**Header KPI strip** across all 6 tabs stays glanceable — six small tiles in a row.
### Menu entry
No new menu — the Quality Dashboard already lives at Plating → Quality → Dashboard. New tab is a sibling render inside the existing client action.
### Deep-link from notification email
The email body includes `{{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}`. New URL param `?tab=certificates` is parsed by the OWL action's `setup()` to focus the right tab on load.
## ACL changes
### `fp.certificate.action_issue` Python guard
```python
def action_issue(self):
if not self.env.context.get('fp_skip_cert_authority_gate'):
cert_authority_gids = [
self.env.ref('fusion_plating.group_fp_quality_manager').id,
self.env.ref('fusion_plating.group_fp_manager').id,
self.env.ref('fusion_plating.group_fp_owner').id,
]
if not (set(self.env.user.all_group_ids.ids)
& set(cert_authority_gids)):
raise AccessError(_(
"Only Quality Managers, Managers, and Owners can issue "
"certificates. Ask your QM to review and issue this CoC."
))
else:
self.message_post(body=Markup(_(
'Cert authority gate <b>bypassed</b> by '
'<b>%(u)s</b> (context flag fp_skip_cert_authority_gate).'
)) % {'u': self.env.user.name})
# existing issue logic...
```
### View-level button gating
`fp_certificate_views.xml` — Issue button on form:
```xml
<button name="action_issue" string="Issue" type="object"
invisible="state != 'draft'"
groups="fusion_plating.group_fp_quality_manager,
fusion_plating.group_fp_manager,
fusion_plating.group_fp_owner"/>
```
### Tablet impact
S18-era cert flow ran any operator session. Post-change: remove the Issue affordance from operator-facing tablet surfaces. The cert form is still openable (read-only for Technicians); the action button is hidden. Surface a "Send to QM" hint on the job workspace footer when state is `awaiting_cert`, but the actual notification fires automatically — no operator click required.
## Notification changes
### New trigger events
Add to `fp.notification.template.trigger_event` selection:
```python
('cert_awaiting_issuance', _('Cert Awaiting Issuance')),
('cert_voided_re_notify', _('Cert Voided — Please Re-Issue')),
```
### Seeded template — `data/fp_cert_authority_templates.xml`
Single template per event (subject, body, recipient_resolver = `cert_authority_group_members`). Default body (per the Architecture diagram above and the section that follows on the recipient resolver):
```
Subject: 🏷️ Job {{ object.display_wo_name }} ready for CoC issuance
Hi {{ recipient.name|first_name }},
Job {{ object.display_wo_name }} ({{ object.partner_id.name }})
has finished the shop floor and is awaiting CoC issuance.
Part: {{ object.part_catalog_id.part_number }}
Quantity: {{ object.qty_done }}
Recipe: {{ object.recipe_id.name }}
Review the inspection prompts captured by the operator on the Final
Inspection step, then issue the CoC from the Quality Dashboard:
→ {{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}
Or open the job directly:
→ {{ object.x_fc_record_url }}
```
Marked `noupdate="1"` so admin edits in the UI survive `-u` (Rule 22).
### Recipient resolver — `_fp_resolve_cert_authority_users()`
```python
@api.model
def _fp_resolve_cert_authority_users(self, job=None):
"""Return active, non-share users with QM | Manager | Owner role
(transitive via all_group_ids). See Rule 13l for the rationale —
direct user_ids on group records does NOT include implied
memberships."""
gids = [
self.env.ref('fusion_plating.group_fp_quality_manager').id,
self.env.ref('fusion_plating.group_fp_manager').id,
self.env.ref('fusion_plating.group_fp_owner').id,
]
return self.env['res.users'].sudo().search([
('all_group_ids', 'in', gids),
('share', '=', False),
('active', '=', True),
])
```
### Throttling / dedupe
`fp.notification.log` dedupe key is `(template_id, source_record_id, event)`. The two events have distinct keys, so a `cert_voided_re_notify` after `cert_awaiting_issuance` re-fires (correct — different signal). Repeated `cert_awaiting_issuance` for the same job is suppressed (correct — already on the QM's radar).
### `mail.activity` belt + suspenders
After firing the notification, schedule one activity per job (not per QM — round-robin assignment to a single QM, who can re-assign if needed). Auto-resolves when state transitions to `awaiting_ship`.
```python
def _fp_schedule_cert_activity(self):
self.ensure_one()
activity_type = self.env.ref(
'fusion_plating_jobs.activity_type_issue_coc',
raise_if_not_found=False,
)
if not activity_type:
return
qm = self.env['fp.notification.template']._fp_resolve_cert_authority_users(self)
if not qm:
return
# Round-robin: pick the QM who logged in least recently (likely
# least busy). NULL login_date sorts first.
qm = qm.sorted(lambda u: u.login_date or fields.Datetime.from_string('1970-01-01'))[:1]
self.activity_schedule(
activity_type_id=activity_type.id,
user_id=qm.id,
summary=_('Issue CoC for %s') % (self.display_wo_name or self.name),
note=_('Job has finished the shop floor. Review the inspection '
'prompts captured on the final step, then issue the CoC.'),
)
```
Auto-resolve on `awaiting_ship` transition:
```python
def _fp_resolve_cert_activities(self):
self.ensure_one()
activity_type = self.env.ref(
'fusion_plating_jobs.activity_type_issue_coc',
raise_if_not_found=False,
)
if not activity_type:
return
self.activity_ids.filtered(
lambda a: a.activity_type_id == activity_type
).action_feedback(feedback=_('Cert issued — auto-resolved.'))
```
## Migration plan
### Pre-migration state assessment
```sql
-- Count jobs currently affected
SELECT state, count(*)
FROM fp_job
WHERE state IN ('confirmed', 'in_progress', 'done')
GROUP BY state;
-- Jobs in-progress with all steps terminal (will migrate to awaiting_cert or awaiting_ship)
SELECT j.id, j.name, j.state,
count(s.id) AS total_steps,
count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled')) AS terminal_steps
FROM fp_job j
LEFT JOIN fp_job_step s ON s.job_id = j.id
WHERE j.state = 'in_progress'
GROUP BY j.id, j.name, j.state
HAVING count(s.id) > 0
AND count(s.id) = count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled'));
-- Done jobs with draft certs (would need backfill in the new world, but we leave them alone)
SELECT j.id, j.name, count(c.id) AS draft_certs
FROM fp_job j
JOIN fp_certificate c ON c.x_fc_job_id = j.id AND c.state = 'draft'
WHERE j.state = 'done'
GROUP BY j.id, j.name;
```
### Migration script — `fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py`
```python
def migrate(cr, version):
"""Backfill new states for jobs caught mid-transition by the upgrade.
Rules:
- in_progress + all steps terminal + draft cert exists → awaiting_cert
- in_progress + all steps terminal + no cert required → awaiting_ship
- done jobs LEFT ALONE — they're historical (already shipped)
"""
cr.execute("""
UPDATE fp_job
SET state = 'awaiting_cert'
WHERE id IN (
SELECT j.id
FROM fp_job j
JOIN fp_job_step s ON s.job_id = j.id
WHERE j.state = 'in_progress'
GROUP BY j.id
HAVING count(*) FILTER (
WHERE s.state NOT IN ('done','skipped','cancelled')
) = 0
)
AND EXISTS (
SELECT 1 FROM fp_certificate c
WHERE c.x_fc_job_id = fp_job.id AND c.state = 'draft'
);
""")
cr.execute("""
UPDATE fp_job
SET state = 'awaiting_ship'
WHERE id IN (
SELECT j.id
FROM fp_job j
JOIN fp_job_step s ON s.job_id = j.id
WHERE j.state = 'in_progress'
GROUP BY j.id
HAVING count(*) FILTER (
WHERE s.state NOT IN ('done','skipped','cancelled')
) = 0
)
AND NOT EXISTS (
SELECT 1 FROM fp_certificate c
WHERE c.x_fc_job_id = fp_job.id
AND c.state IN ('draft', 'issued')
);
""")
```
Idempotent: re-running on a fresh upgrade is a no-op because no `in_progress` job will match the all-terminal predicate after the first run.
### Card_state recompute
After the migration script runs, force a recompute of `fp.job.card_state` (stored compute) so the kanban renders correctly:
```python
# In post-migrate.py, after the state UPDATEs:
env = api.Environment(cr, SUPERUSER_ID, {})
affected = env['fp.job'].search([
('state', 'in', ('awaiting_cert', 'awaiting_ship')),
])
affected.invalidate_recordset(['card_state'])
affected._compute_card_state()
```
## Edge cases + defensive design
| Case | Behavior |
|---|---|
| Job with no recipe steps at all | `_fp_check_advance_post_shop` returns early (nothing to check). State stays at whatever it was — operator can still manually move it via the existing milestone-advance button. |
| Recipe doesn't have a Final Inspection step | Card still lands in Final Inspection column (state drives column, not recipe shape). The recipe author probably forgot to add the step — Quality Dashboard surface catches it. |
| Customer not flagged for any cert | `_resolve_required_cert_types()` returns empty set → state transitions `in_progress → awaiting_ship` directly. Card visible in Shipping column. No notification fires (no QM action needed). |
| QM voids a cert AFTER `awaiting_ship` | `_fp_check_regress_after_cert_void()` flips state back to `awaiting_cert`, re-fires `cert_voided_re_notify` (different dedupe key from initial notification, so not suppressed). Card visibly moves left from Shipping back to Final Inspection. |
| Operator marks a step as `cancelled` mid-flow that bricks the all-terminal check | `cancelled` IS terminal per the existing logic — so cancelling the last open step DOES trigger the transition. Operator-error path: if a manager opens a previously-terminal step (e.g. `_fp_reopen_step`) the state should regress to `in_progress`. Add inverse trigger on step reopen. |
| Cron-triggered cert issuance (e.g. some future auto-issue) with no `env.user` | The Python guard hits `self.env.user` which would be the cron user. Need cron-safe bypass: use `fp_skip_cert_authority_gate=True` in the cron's `with_context(...)` call. Documented but no cron exists today. |
| Migration: existing `state='done'` jobs that haven't shipped | Left alone — historically completed jobs are out of scope. Backfill action (`action_backfill_missing_certs` in fp_job.py) already exists for cert gaps; that path is unchanged. |
| Multiple QMs assigned a single activity | Round-robin picks ONE QM (oldest `login_date`). They can reassign via the activity widget if needed. Activity auto-resolves on `awaiting_ship` regardless of which QM acted. |
## Testing strategy
### Smoke test (entech-style, scriptable)
```python
# scripts/bt_post_shop_states.py
# 1. Create SO + job with cert-requiring customer
# 2. Walk every step to terminal → assert state='awaiting_cert'
# 3. Assert card appears in plant_kanban under 'inspection' column
# 4. Assert email + activity scheduled on a QM
# 5. As a Technician, call cert.action_issue() → assert AccessError
# 6. As a QM, call cert.action_issue() → state='issued', job state→'awaiting_ship'
# 7. Assert card moves to 'shipping' column, activity auto-resolves
# 8. Void the cert → assert state back to 'awaiting_cert', activity re-scheduled
# 9. Re-issue → 'awaiting_ship' again
# 10. Click button_mark_shipped (as Manager) → state='done', card off board
```
### Unit-level
- `fp.job._fp_check_advance_post_shop` — exhaustive matrix of (state, all_terminal, certs_required) → expected new state
- `fp.certificate.action_issue` ACL — separate tests per role (Tech/Mgr/QM/Owner/Sales Rep)
- `_fp_resolve_cert_authority_users` — entech-realistic group setup, confirm Owners are returned via implication
### Manual QA on entech
1. Pick a currently-done WO-30058 (the triggering job) → run migration → confirm it stays at `state='done'` (untouched).
2. Find an `in_progress` job with all steps terminal — confirm migration script moves it to the right state.
3. Walk a fresh SO end-to-end: confirm card visibility at each column transition.
4. Try issuing a cert as a Technician via the JS-rpc console → confirm AccessError.
## Files touched
### `fusion_plating_jobs/`
- `models/fp_job.py` — state extension, new methods, repurpose button_mark_done, new button_mark_shipped, hooks for cert state changes, mini-timeline update, card-state extension
- `views/fp_job_views.xml` — Mark Shipped button (replaces Mark Done), groups gating
- `data/fp_activity_types_data.xml` — new mail.activity.type `activity_type_issue_coc`
- `migrations/19.0.<next>/post-migrate.py` — state backfill
- `__manifest__.py` — version bump, data file additions
### `fusion_plating_certificates/`
- `models/fp_certificate.py` — Python ACL guard in action_issue, write override for state=voided, x_fc_age_hours computed
- `views/fp_certificate_views.xml``groups=` on Issue button
- `__manifest__.py` — version bump
### `fusion_plating_shopfloor/`
- `controllers/plant_kanban.py` — domain widen, column resolution, chip rendering, KPI compute, sort priority
- `static/src/js/plant_kanban.js` — KPI tile + filter chip additions
- `static/src/scss/_plant_card.scss` + `_plant_tokens.scss` — new state modifier classes (light + dark via `$o-webclient-color-scheme`)
- `__manifest__.py` — version bump
### `fusion_plating_quality/`
- `controllers/fp_quality_dashboard.py` — certificates block in counts response
- `static/src/{js,xml,scss}/fp_quality_dashboard.*` — sixth tab, header KPI, deep-link `?tab=certificates` parsing
- `__manifest__.py` — version bump (and add `fusion_plating_certificates` to depends if not already)
### `fusion_plating_notifications/`
- `models/fp_notification_template.py` — new trigger_event selection values, recipient resolver helper
- `data/fp_cert_authority_templates.xml` — seeded templates for both events
- `__manifest__.py` — version bump
## Open questions for implementation phase
1. **Where exactly does the auto-transition fire?** Most likely `fp.job.step.write` post-hook when `state` changes — needs centralization so all step-completion paths (button_finish, action_skip, action_cancel, etc.) trigger consistently. Implementation plan should validate by grepping for every site that sets `step.state`.
2. **Should `button_mark_shipped` add ANY gates** (e.g. delivery exists / draft cert isn't lingering)? Default answer: no — the state machine has already validated correctness; the button is just "yes, shipped". But worth re-confirming.
3. **Tablet "Send to QM" footer hint** — exact wording and link target. Minor UX; can be settled during implementation.
4. **Mini-timeline dot for `done` (state at end-of-lifecycle)** — currently the timeline is always 9 dots regardless of state. After shipping the card is off the board, so this only matters for historical viewers. Probably no change needed; flag for implementation review.
## Status & deployment notes
Target version bumps (suggestion, finalize at implementation time):
- `fusion_plating_jobs` 19.0.10.24.0 → 19.0.11.0.0 (state extension is a minor-version bump per existing convention)
- `fusion_plating_certificates` 19.0.5.4.0 → 19.0.6.0.0
- `fusion_plating_shopfloor` 19.0.34.0.0 → 19.0.35.0.0
- `fusion_plating_quality` 19.0.5.0.0 → 19.0.6.0.0
- `fusion_plating_notifications` minor bump
Deploy order: notifications → jobs (post-migrate runs here) → certificates → shopfloor → quality. Each gets its own `-u` step to keep blast radius small per [CLAUDE.md → Sub 12 build order rule 11](../../CLAUDE.md).

View File

@@ -4,6 +4,7 @@
# Part of the Fusion Plating product family.
import logging
import re
from . import controllers
from . import models
@@ -34,6 +35,86 @@ def post_init_hook(env):
_migrate_legacy_uom_columns(env)
_seed_starter_recipes_once(env)
_fp_post_init_role_migration(env)
_fp_apply_office_user_menu_visibility(env)
# Top-level app menus that technicians should NOT see. Each entry is an
# xmlid; env.ref(..., raise_if_not_found=False) silently skips menus
# from uninstalled modules so this is safe across configurations.
# Kept visible to technicians (NOT in this list): Discuss, To-do,
# Plating, AI, Maintenance, Time Off. Settings/Apps/Tests are admin-
# restricted upstream — also not in this list.
# See security/fp_menu_visibility.xml for the design rationale.
MENU_HIDE_FROM_TECHNICIANS = [
'calendar.mail_menu_calendar',
'contacts.menu_contacts',
'crm.crm_menu_root',
'sale.sale_menu_root',
'spreadsheet_dashboard.spreadsheet_dashboard_menu_root',
'fusion_ringcentral.menu_rc_root',
'fusion_faxes.menu_fusion_faxes_root',
'fusion_tasks.menu_field_service_root',
'fusion_clock.menu_fusion_clock_root',
'account.menu_finance',
'accountant.menu_accounting',
'project.menu_main_pm',
'hr_timesheet.timesheet_menu_root',
'planning.planning_menu_root',
'fusion_shipping.menu_fusion_shipping_root',
'website.menu_website_configuration',
'purchase.menu_purchase_root',
'stock.menu_stock_root',
'sign.menu_document',
'hr.menu_hr_root',
'hr_work_entry_enterprise.menu_hr_payroll_root',
'hr_attendance.menu_hr_attendance_root',
'hr_recruitment.menu_hr_recruitment_root',
'hr_expense.menu_hr_expense_root',
'iot.iot_menu_root',
'utm.menu_link_tracker_root',
'base.menu_management',
]
def _fp_apply_office_user_menu_visibility(env):
"""Set group_ids = [group_fp_office_user] on every menu in
MENU_HIDE_FROM_TECHNICIANS that exists in this DB.
Field is `group_ids` on ir.ui.menu in Odoo 19 (was `groups_id` in
earlier versions — Odoo 18 renamed it). Same naming-rename pattern
as res.users (CLAUDE.md Critical Rule 13c).
Idempotent: if a menu already has only the office_user group, no
change is made. If it has additional groups (e.g. a previous custom
restriction), they're REPLACED — the design accepts this trade-off
because office_user is implied by every fp role above Technician,
so non-fp users keep their access on entech.
Cross-module xmlids: env.ref(..., raise_if_not_found=False) returns
None for menus from uninstalled modules, which we silently skip.
"""
office = env.ref(
'fusion_plating.group_fp_office_user', raise_if_not_found=False,
)
if not office:
_logger.warning(
'[menu-visibility] group_fp_office_user not found; skipping'
)
return
touched = 0
for xmlid in MENU_HIDE_FROM_TECHNICIANS:
menu = env.ref(xmlid, raise_if_not_found=False)
if not menu:
continue
current_ids = set(menu.group_ids.ids)
if current_ids == {office.id}:
continue # already locked-down, nothing to do
menu.sudo().group_ids = [(6, 0, [office.id])]
touched += 1
_logger.info(
'[menu-visibility] restricted %s menu(s) to group_fp_office_user',
touched,
)
def _fp_post_init_role_migration(env):
@@ -282,6 +363,38 @@ _STARTER_KIND_BY_NAME = {
'ready for post-plate inspection': 'gating',
'ready for final inspection': 'gating',
'ready for shipping': 'gating',
# 2026-05-24 — Recipe cleanup additions (spec
# 2026-05-24-recipe-cleanup-design.md). Covers names the existing
# resolver didn't know that turned up in the entech recipes audit.
# Blasting variants
'blasting': 'blast',
'bead blast': 'blast',
'bead blasting': 'blast',
'media blast': 'blast',
'media blasting': 'blast',
# Inspection variants
'adhesion test coupon': 'inspect',
'adhesion testing': 'inspect',
'corrosion testing': 'inspect',
'lab testing': 'inspect',
'check sulfamate nickel area': 'inspect',
'pre-measurements': 'inspect',
'pre measurements': 'inspect',
'hot water porosity': 'inspect',
# Strip / chemical conversion / plugging (wet line)
'strip process': 'wet_process',
'strip process - al': 'wet_process',
'nickel strip - aluminum line': 'wet_process',
'chemical conversion': 'wet_process',
'trivalent chromate conversion': 'wet_process',
'plug the threaded holes': 'mask',
# Misc wet-line variants seen on entech recipes
'air dry': 'dry',
'desmut': 'etch',
'soak clean': 'cleaning',
'cleaner': 'cleaning',
'nickel strike': 'plate',
'nickel strip': 'plate',
}
@@ -290,6 +403,9 @@ def fp_resolve_step_kind(name):
case. Used by both the seeder and the migration backfill so we don't
have two slightly-different lookup paths.
Handles parenthetical suffixes like "(Standard)", "(If Required)",
"(A-14 / A)" by stripping them and re-trying the lookup.
Returns the kind str or None when no match.
"""
if not name:
@@ -297,6 +413,12 @@ def fp_resolve_step_kind(name):
key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key]
# Parenthetical strip — "Masking (If Required)" → "masking",
# "Incoming Inspection (Standard)" → "incoming inspection",
# "Trivalent Chromate Conversion (A-14 / A)" → "trivalent chromate conversion".
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[bare]
# Gating "Ready for / Ready For" prefix — anything starting with that
# is a gating node regardless of the destination step name.
if key.startswith('ready for ') or key.startswith('ready '):
@@ -304,6 +426,44 @@ def fp_resolve_step_kind(name):
return None
# Translates resolver kind output to the active fp.step.kind.code values.
# The resolver still returns the OLD vocabulary (cleaning, electroclean,
# etch, rinse, strike, dry, wbf_test) which were deactivated in
# 19.0.20.6.0 — those roll up to the active wet_process kind. Other
# codes pass through 1:1. Used by the auto-classify hook on
# fusion.plating.process.node + the recipe-cleanup migration
# (fusion_plating_jobs 19.0.10.26.0).
RESOLVER_KIND_TO_ACTIVE_KIND = {
# Wet-line kinds → wet_process (active rollup)
'cleaning': 'wet_process',
'electroclean': 'wet_process',
'etch': 'wet_process',
'rinse': 'wet_process',
'strike': 'wet_process',
'dry': 'wet_process',
'wbf_test': 'wet_process',
'wet_process': 'wet_process', # the alias added in 19.0.21.3.0
# for "Strip Process - AL", "Chemical
# Conversion", "Trivalent Chromate
# Conversion" maps DIRECTLY to
# 'wet_process' — this passthrough
# entry lets those land correctly.
# 1:1 mappings (kind exists and is active)
'contract_review': 'contract_review',
'mask': 'mask',
'racking': 'racking',
'plate': 'plate',
'bake': 'bake',
'derack': 'derack',
'demask': 'demask',
'inspect': 'inspect',
'final_inspect': 'final_inspect',
'ship': 'ship',
'gating': 'gating',
'blast': 'blast',
}
def _seed_step_library_if_empty(env):
"""Sub 12a — seed fp.step.template starter library.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.21.1.3',
'version': '19.0.21.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -82,6 +82,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/fp_security.xml',
'security/fp_security_v2.xml',
'security/ir.model.access.csv',
# Menu visibility — loads after fp_security_v2.xml so the role
# group xmlids exist when we add office_user to their
# implied_ids. Loads after fp_menu.xml in spirit BUT references
# cross-module menus (calendar, sale, hr, etc.) which exist by
# the time fusion_plating loads, so safe to load here at
# security-config time.
'security/fp_menu_visibility.xml',
'data/fp_landing_data.xml',
'data/fp_sequence_data.xml',
'data/fp_job_sequences.xml',

View File

@@ -484,8 +484,15 @@ class SimpleRecipeController(http.Controller):
type='jsonrpc', auth='user')
def kinds_list(self):
"""Sub 14b — Step Kind dropdown options for the inline library
form. User-extensible via /fp/simple_recipe/kinds/create."""
form. User-extensible via /fp/simple_recipe/kinds/create.
2026-05-24 — payload now includes `area_kind` + a humanized
`area_kind_label` so the Simple Editor picker can render
"Masking — Masking column" and authors see which Shop Floor
column they're routing the step to.
"""
Kind = request.env['fp.step.kind']
area_labels = dict(Kind._fields['area_kind'].selection)
return {
'kinds': [
{
@@ -494,6 +501,8 @@ class SimpleRecipeController(http.Controller):
'name': k.name or '',
'icon': k.icon or '',
'sequence': k.sequence,
'area_kind': k.area_kind or '',
'area_kind_label': area_labels.get(k.area_kind, ''),
}
for k in Kind.search(
[('active', '=', True)], order='sequence, name',

View File

@@ -18,7 +18,15 @@
(covers all bath-based steps).
- `mask` covers Masking + De-Masking, `racking` covers
Racking + De-Racking — operators differentiate by the
step name. -->
step name.
2026-05-24 update (19.0.21.2.0 — Shop Floor live-step fix):
- New `area_kind` field on fp.step.kind drives plant-view
column routing. Every record below carries an
area_kind. New `blast` kind for the Blasting column.
- `derack`, `demask`, `gating` get re-activated via the
pre-migrate (they're listed under "ACTIVE KINDS" here
now since they're meant to be active going forward). -->
<!-- ============================================================ -->
<!-- ACTIVE KINDS — visible in dropdown -->
@@ -29,13 +37,7 @@
<field name="name">Other</field>
<field name="sequence">5</field>
<field name="icon">fa-circle-o</field>
</record>
<record id="step_kind_wet_process" model="fp.step.kind">
<field name="code">wet_process</field>
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
<field name="sequence">55</field>
<field name="icon">fa-tint</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_receiving" model="fp.step.kind">
@@ -43,144 +45,182 @@
<field name="name">Receiving / Incoming Inspection</field>
<field name="sequence">10</field>
<field name="icon">fa-truck</field>
<field name="area_kind">receiving</field>
</record>
<record id="step_kind_contract_review" model="fp.step.kind">
<field name="code">contract_review</field>
<field name="name">Contract Review (QA-005)</field>
<field name="sequence">20</field>
<field name="icon">fa-file-text-o</field>
<field name="area_kind">receiving</field>
</record>
<record id="step_kind_racking" model="fp.step.kind">
<field name="code">racking</field>
<field name="name">Racking</field>
<field name="sequence">30</field>
<field name="icon">fa-server</field>
<field name="area_kind">racking</field>
</record>
<record id="step_kind_blast" model="fp.step.kind">
<field name="code">blast</field>
<field name="name">Blasting / Media Blast</field>
<field name="sequence">35</field>
<field name="icon">fa-bullseye</field>
<field name="area_kind">blasting</field>
</record>
<record id="step_kind_mask" model="fp.step.kind">
<field name="code">mask</field>
<field name="name">Masking</field>
<field name="sequence">40</field>
<field name="icon">fa-eye-slash</field>
<field name="area_kind">masking</field>
</record>
<record id="step_kind_cleaning" model="fp.step.kind">
<field name="code">cleaning</field>
<field name="name">Cleaning</field>
<field name="sequence">50</field>
<field name="icon">fa-tint</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_wet_process" model="fp.step.kind">
<field name="code">wet_process</field>
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
<field name="sequence">55</field>
<field name="icon">fa-tint</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_electroclean" model="fp.step.kind">
<field name="code">electroclean</field>
<field name="name">Electroclean</field>
<field name="sequence">60</field>
<field name="icon">fa-bolt</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_etch" model="fp.step.kind">
<field name="code">etch</field>
<field name="name">Etch / Activation</field>
<field name="sequence">70</field>
<field name="icon">fa-flask</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_rinse" model="fp.step.kind">
<field name="code">rinse</field>
<field name="name">Rinse</field>
<field name="sequence">80</field>
<field name="icon">fa-tint</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_strike" model="fp.step.kind">
<field name="code">strike</field>
<field name="name">Strike (Wood's Nickel / Activation)</field>
<field name="sequence">90</field>
<field name="icon">fa-bolt</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_plate" model="fp.step.kind">
<field name="code">plate</field>
<field name="name">Plating</field>
<field name="sequence">100</field>
<field name="icon">fa-shield</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_replenishment" model="fp.step.kind">
<field name="code">replenishment</field>
<field name="name">Tank Replenishment</field>
<field name="sequence">110</field>
<field name="icon">fa-plus-circle</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_wbf_test" model="fp.step.kind">
<field name="code">wbf_test</field>
<field name="name">Water Break Free Test</field>
<field name="sequence">120</field>
<field name="icon">fa-check-square-o</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_dry" model="fp.step.kind">
<field name="code">dry</field>
<field name="name">Drying</field>
<field name="sequence">130</field>
<field name="icon">fa-sun-o</field>
<field name="area_kind">plating</field>
</record>
<record id="step_kind_bake" model="fp.step.kind">
<field name="code">bake</field>
<field name="name">Bake (HE Relief / Stress Relief)</field>
<field name="sequence">140</field>
<field name="icon">fa-fire</field>
<field name="area_kind">baking</field>
</record>
<record id="step_kind_demask" model="fp.step.kind">
<field name="code">demask</field>
<field name="name">De-Masking</field>
<field name="sequence">150</field>
<field name="icon">fa-eye</field>
<field name="area_kind">de_racking</field>
</record>
<record id="step_kind_derack" model="fp.step.kind">
<field name="code">derack</field>
<field name="name">De-Racking</field>
<field name="sequence">160</field>
<field name="icon">fa-server</field>
<field name="area_kind">de_racking</field>
</record>
<record id="step_kind_inspect" model="fp.step.kind">
<field name="code">inspect</field>
<field name="name">Inspection</field>
<field name="sequence">170</field>
<field name="icon">fa-search</field>
<field name="area_kind">inspection</field>
</record>
<record id="step_kind_hardness_test" model="fp.step.kind">
<field name="code">hardness_test</field>
<field name="name">Hardness Test (HV / HK / HRC)</field>
<field name="sequence">180</field>
<field name="icon">fa-tachometer</field>
<field name="area_kind">inspection</field>
</record>
<record id="step_kind_adhesion_test" model="fp.step.kind">
<field name="code">adhesion_test</field>
<field name="name">Adhesion Test</field>
<field name="sequence">190</field>
<field name="icon">fa-link</field>
<field name="area_kind">inspection</field>
</record>
<record id="step_kind_salt_spray" model="fp.step.kind">
<field name="code">salt_spray</field>
<field name="name">Salt Spray / Corrosion Test</field>
<field name="sequence">200</field>
<field name="icon">fa-cloud</field>
<field name="area_kind">inspection</field>
</record>
<record id="step_kind_final_inspect" model="fp.step.kind">
<field name="code">final_inspect</field>
<field name="name">Final Inspection</field>
<field name="sequence">210</field>
<field name="icon">fa-check-circle</field>
<field name="area_kind">inspection</field>
</record>
<record id="step_kind_packaging" model="fp.step.kind">
<field name="code">packaging</field>
<field name="name">Packaging / Pre-Ship</field>
<field name="sequence">220</field>
<field name="icon">fa-archive</field>
<field name="area_kind">shipping</field>
</record>
<record id="step_kind_ship" model="fp.step.kind">
<field name="code">ship</field>
<field name="name">Shipping</field>
<field name="sequence">230</field>
<field name="icon">fa-paper-plane</field>
<field name="area_kind">shipping</field>
</record>
<record id="step_kind_gating" model="fp.step.kind">
<field name="code">gating</field>
<field name="name">Gating</field>
<field name="sequence">240</field>
<field name="icon">fa-pause-circle</field>
<field name="area_kind">receiving</field>
</record>
<!-- ============================================================
@@ -955,5 +995,8 @@
<!-- gating: intentionally no default inputs -->
<!-- blast: intentionally no default inputs (operator picks
approach by step name + recipe instructions) -->
</data>
</odoo>

View File

@@ -103,4 +103,30 @@
]]></field>
</record>
<!-- 2026-05-24 additions (19.0.21.2.0 — Shop Floor live-step fix) -->
<record id="fp_step_template_hwp_a15" model="fp.step.template">
<field name="name">Hot Water Porosity Test (A-15)</field>
<field name="code">HWP_A15</field>
<field name="kind_id" ref="step_kind_inspect"/>
<field name="icon">fa-tint</field>
<field name="description"><![CDATA[
<p>Hot-water porosity test for plated samples. Verify continuity
of the deposit across the test panel; record any porosity sites
and attach a photo when a defect is found.</p>
]]></field>
</record>
<record id="fp_step_template_final_pkg_std" model="fp.step.template">
<field name="name">Final Inspection / Packaging</field>
<field name="code">FINAL_PKG_STD</field>
<field name="kind_id" ref="step_kind_final_inspect"/>
<field name="icon">fa-check-circle</field>
<field name="description"><![CDATA[
<p>Combined final visual + dimensional inspection followed by
packaging into the customer's original boxes for shipment.
Verify part count, attach certs, photo the sealed load.</p>
]]></field>
</record>
</odoo>

View File

@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.21.2.0 — Shop Floor live-step + kind taxonomy.
Seeds fp.step.kind.area_kind on existing kinds BEFORE the required
NOT NULL constraint on the new field hits the schema. Also activates
the three kinds (derack/demask/gating) that were deactivated in
19.0.20.6.0 but are needed for the full area_kind taxonomy.
Idempotent: only fills NULL / inactive rows.
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
"""
import logging
_logger = logging.getLogger(__name__)
KIND_TO_AREA = {
'other': 'plating',
'wet_process': 'plating',
'receiving': 'receiving',
'contract_review': 'receiving',
'gating': 'receiving',
'racking': 'racking',
'derack': 'de_racking',
'mask': 'masking',
'demask': 'de_racking',
'cleaning': 'plating',
'electroclean': 'plating',
'etch': 'plating',
'rinse': 'plating',
'strike': 'plating',
'plate': 'plating',
'replenishment': 'plating',
'wbf_test': 'plating',
'dry': 'plating',
'bake': 'baking',
'inspect': 'inspection',
'final_inspect': 'inspection',
'hardness_test': 'inspection',
'adhesion_test': 'inspection',
'salt_spray': 'inspection',
'packaging': 'shipping',
'ship': 'shipping',
'blast': 'blasting',
'bead_blast': 'blasting',
'media_blast': 'blasting',
}
def migrate(cr, version):
# Phase 1 — Pre-create the column NULL-permitting so we can seed it
# BEFORE Odoo's schema sync tries to enforce NOT NULL.
cr.execute(
"ALTER TABLE fp_step_kind "
"ADD COLUMN IF NOT EXISTS area_kind VARCHAR"
)
# Phase 2 — Seed area_kind on existing kinds. Idempotent: only fills
# NULLs, so re-running -u is safe.
seeded = 0
for code, area in KIND_TO_AREA.items():
cr.execute(
"UPDATE fp_step_kind SET area_kind = %s "
"WHERE code = %s "
"AND (area_kind IS NULL OR area_kind = '')",
(area, code),
)
seeded += cr.rowcount
_logger.info(
'[live-step-fix] kind.area_kind seeded on %s known-code rows',
seeded,
)
# Phase 3 — Fallback: any user-created custom kinds not in our seed
# map → 'plating'. Clears the NOT NULL constraint for any leftover.
cr.execute(
"UPDATE fp_step_kind SET area_kind = 'plating' "
"WHERE area_kind IS NULL OR area_kind = ''"
)
if cr.rowcount:
_logger.info(
'[live-step-fix] %s unknown kinds defaulted to plating',
cr.rowcount,
)
# Phase 4 — Activate kinds we need for full coverage.
activated = 0
for code in ('derack', 'demask', 'gating'):
cr.execute(
"UPDATE fp_step_kind SET active = TRUE "
"WHERE code = %s AND active = FALSE",
(code,),
)
activated += cr.rowcount
_logger.info(
'[live-step-fix] %s kinds activated (derack/demask/gating)',
activated,
)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.21.4.0 — Apply office-user menu visibility on -u.
post_init_hook only fires on FIRST install (CLAUDE.md Rule 13d).
This script runs the same helper on every -u so existing installs
get the menu restrictions applied without needing to uninstall +
reinstall. Idempotent — the helper checks current state and skips
already-restricted menus.
"""
import logging
from odoo.api import Environment, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
from odoo.addons.fusion_plating import _fp_apply_office_user_menu_visibility
env = Environment(cr, SUPERUSER_ID, {})
_fp_apply_office_user_menu_visibility(env)

View File

@@ -784,6 +784,62 @@ class FpProcessNode(models.Model):
return self.action_open_simple_editor()
return self.action_open_tree_editor()
# ---- Auto-classify kind from name (2026-05-24, spec
# docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md) -------
# Safety net: when a node's kind is the catch-all 'other' AND its
# name resolves via fp_resolve_step_kind(), upgrade kind_id to the
# resolved active kind. Runs on create() and on write() when name
# or kind_id changes. Prevents recipe authoring + recipe
# duplication from silently leaving nodes as 'other' (which then
# routes them to the wrong Shop Floor column).
#
# Skip with context flag fp_skip_kind_autoclassify=True for admin
# workflows that need to keep kind=other despite a known name.
def _fp_autoclassify_kind(self):
"""Upgrade kind_id when current is 'other' and name resolves."""
if self.env.context.get('fp_skip_kind_autoclassify'):
return
from odoo.addons.fusion_plating import (
fp_resolve_step_kind,
RESOLVER_KIND_TO_ACTIVE_KIND,
)
Kind = self.env['fp.step.kind']
other = Kind.search([('code', '=', 'other')], limit=1)
if not other:
return
# Cache active-kind ids by code so we don't re-search per row.
kind_by_code = {}
for node in self:
if not node.name or node.kind_id != other:
continue
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code:
continue
if target_code not in kind_by_code:
tgt = Kind.search([('code', '=', target_code)], limit=1)
kind_by_code[target_code] = tgt.id if tgt else False
target_id = kind_by_code[target_code]
if target_id:
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'kind_id': target_id})
@api.model_create_multi
def create(self, vals_list):
nodes = super().create(vals_list)
nodes._fp_autoclassify_kind()
return nodes
def write(self, vals):
res = super().write(vals)
if 'name' in vals or 'kind_id' in vals:
self._fp_autoclassify_kind()
return res
# ---- Copy (deep-duplicate) -----------------------------------------------
def copy(self, default=None):

View File

@@ -34,6 +34,31 @@ class FpStepKind(models.Model):
string='Icon',
default='fa-cog',
)
# 2026-05-24 — Shop Floor live-step fix.
# Each kind self-declares which plant-view column its steps land in.
# Replaces the hardcoded _STEP_KIND_TO_AREA dict (removed from
# fusion_plating_jobs/models/fp_job_step.py). Pre-migrate
# 19.0.21.2.0 seeds existing rows before NOT NULL hits the schema.
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='Shop Floor Column',
required=True,
index=True,
help='Determines which column on the Shop Floor plant kanban shows '
'cards whose active step uses this kind. Step kinds drive '
'routing automatically — picking a kind tells the system both '
'what gates fire AND where the card lives.',
)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
2026-05-24 — Hide non-essential app menus from Technicians.
Per user request: technicians should see ONLY the apps they actually
need on the tablet — Discuss, To-do, Plating, AI, Maintenance, Time
Off. Every other top-level app menu is restricted to a new "office
user" group implied by every fp role ABOVE technician.
THIS FILE only declares the office_user group + the implied_ids
chain. The actual menu group-restriction is applied via a
post_init_hook / post-migrate script (see fusion_plating/__init__.py
and migrations/19.0.21.4.0/post-migrate.py), because cross-module
<menuitem id="other_module.X" groups="..."/> overrides require the
other module in `depends`, which would lock us into hard
dependencies on calendar/sale/hr/etc. The hook uses
env.ref(..., raise_if_not_found=False) — modules that aren't
installed are silently skipped.
Why a separate office_user group instead of !technician?
Manager → ... → Technician via implied_ids, so a Manager IS a
technician for group-matching purposes. A "!technician" filter would
hide menus from managers too. The office_user pattern flips that:
we add a new group that's implied by manager+ (and explicitly NOT
by technician), then require it on the menus we want to hide.
-->
<odoo>
<data>
<!-- ============================================================ -->
<!-- New marker group: "Office User" — implied by every non- -->
<!-- technician fp role. -->
<!-- ============================================================ -->
<record id="group_fp_office_user" model="res.groups">
<field name="name">Plating: Office User (sees back-office menus)</field>
<field name="privilege_id"
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
<field name="sequence">90</field>
<field name="comment">Marker group that controls visibility of
non-tablet app menus (Calendar, Sales, Inventory, etc.).
Implied by every fp role above Technician (Owner, Manager,
Quality Manager, Shop Manager, Sales Rep, Estimator).
Pure Technicians don't have it, so they only see the
tablet apps (Plating, Discuss, To-do, AI, Maintenance,
Time Off).</field>
</record>
<!-- ============================================================ -->
<!-- Add office_user to implied_ids of each above-technician role -->
<!-- These records UPDATE existing groups (additive Command.link) -->
<!-- ============================================================ -->
<record id="group_fp_sales_rep" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
</record>
<record id="group_fp_shop_manager_v2" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
</record>
<record id="group_fp_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
</record>
<record id="group_fp_quality_manager" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
</record>
<record id="group_fp_owner" model="res.groups">
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
</record>
</data>
</odoo>

View File

@@ -509,6 +509,7 @@
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
<t t-esc="k.name"/>
<t t-if="k.area_kind_label"><t t-esc="k.area_kind_label"/> column</t>
</option>
</t>
<!-- Manager-only inline create. The

View File

@@ -17,6 +17,7 @@
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="area_kind" decoration-info="True"/>
<field name="icon"/>
<field name="template_count"/>
<field name="active" widget="boolean_toggle"/>
@@ -47,6 +48,8 @@
</div>
<group>
<group>
<field name="area_kind" widget="badge"
help="Cards whose active step uses this kind appear in this column on the Shop Floor plant kanban."/>
<field name="sequence"/>
<field name="company_id"
groups="base.group_multi_company"/>
@@ -94,8 +97,13 @@
<search>
<field name="name"/>
<field name="code"/>
<field name="area_kind"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Shop Floor Column" name="group_area_kind"
context="{'group_by': 'area_kind'}"/>
</group>
</search>
</field>
</record>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.24.2',
'version': '19.0.10.31.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.10.25.0 — Template metadata backfill + recipe-node repointing.
Runs AFTER fusion_plating's pre-migrate 19.0.21.2.0 (which seeds
kind.area_kind and activates derack/demask/gating). At this point:
- All kinds have area_kind set.
- blast / derack / demask / gating exist and are active.
- XML data files have loaded (new templates exist).
This migration:
1. Backfills code / description / icon / kind_id on the ~30 library
templates seeded without metadata.
2. Repoints existing recipe nodes from wrong kinds to correct ones
using unambiguous name patterns.
3. Recomputes area_kind on all fp.job.step rows.
4. Recomputes active_step_id + card_state on in-flight jobs.
All phases idempotent — re-running -u is safe.
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
"""
import logging
from odoo.api import Environment, SUPERUSER_ID
_logger = logging.getLogger(__name__)
# (name : (code, icon, kind_code, description_snippet))
TEMPLATE_BACKFILL = {
'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'),
'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'),
'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'),
'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'),
'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'),
'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'),
'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'),
'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'),
'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'),
'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'),
'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'),
'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'),
'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'),
'Final Inspection': ('FINAL_INSP_STD', 'fa-check-circle', 'final_inspect', 'Final visual + dimensional QA before packing.'),
'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'),
'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'),
'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'),
'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'),
'Nickel Strip - Steel Line': ('NI_STRIP_SL', 'fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'),
'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle', 'inspect', 'Post-plate inspection - thickness sample + visual.'),
'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'),
'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'),
'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step - parts staged ready for the plating line.'),
'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step - parts staged ready for the next operation.'),
'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'),
'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'),
'Soak Clean': ('SOAK_CLEAN_STD', 'fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'),
'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'),
'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'),
'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'),
}
# (filter_sql, current_kind_code, new_kind_code, description)
# current_kind_code=None means "any kind that isn't the target"
NODE_REPOINTING = [
("n.name = 'Blasting'", 'other', 'blast', 'Blasting -> blast'),
("n.name ILIKE 'Ready %%'", None, 'gating', 'Ready For X -> gating'),
# De-Masking: anchored ILIKE (must start with "De-Masking" or
# "DeMasking") so we don't match "Ready For De-Masking" which the
# earlier 'Ready %' rule already moved to gating. cur_code=None
# catches both 'mask' (the 34 lazy/legacy ones) and 'other' (4
# older nodes never reclassified after the demask kind was added).
# The trailing AND k.code != %s safeguard skips already-correct rows.
("(n.name ILIKE 'De-Masking%%' OR n.name ILIKE 'DeMasking%%')", None, 'demask', 'De-Masking -> demask'),
("n.name = 'Scheduling'", 'other', 'gating', 'Scheduling -> gating'),
("n.name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip -> wet_process'),
("n.name ILIKE '%%Pre-Measurement%%' OR n.name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate -> inspect'),
]
def migrate(cr, version):
env = Environment(cr, SUPERUSER_ID, {})
# Phase 1 — Template metadata backfill. Idempotent: only fills
# NULL/empty fields, doesn't overwrite admin edits.
Tpl = env['fp.step.template']
Kind = env['fp.step.kind']
fixed_tpl = 0
for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items():
tpl = Tpl.search([('name', '=', name)], limit=1)
if not tpl:
continue
vals = {}
if not tpl.code:
vals['code'] = code
cur_desc = (tpl.description or '').strip()
if cur_desc in ('', '<p><br></p>', '<p></p>'):
vals['description'] = '<p>%s</p>' % desc
if tpl.icon == 'fa-cog':
vals['icon'] = icon
kind = Kind.search([('code', '=', kind_code)], limit=1)
if kind and tpl.kind_id.code != kind_code:
vals['kind_id'] = kind.id
if vals:
tpl.write(vals)
fixed_tpl += 1
_logger.info(
'[live-step-fix] template metadata backfilled: %s templates updated',
fixed_tpl,
)
# Phase 2 — Recipe node repointing. Idempotent: AND k.code != %s
# ensures already-correct rows are skipped on re-run.
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
params = [new_code]
sql = (
"UPDATE fusion_plating_process_node n "
"SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1) "
"FROM fp_step_kind k "
"WHERE n.kind_id = k.id "
"AND (" + filter_sql + ")"
)
if cur_code is not None:
sql += " AND k.code = %s"
params.append(cur_code)
sql += " AND k.code != %s"
params.append(new_code)
cr.execute(sql, params)
_logger.info(
'[live-step-fix] repointed %s nodes: %s',
cr.rowcount, desc,
)
# Phase 3 — Recompute area_kind on every fp.job.step row.
steps = env['fp.job.step'].search([])
if steps:
steps._compute_area_kind()
steps.flush_recordset(['area_kind'])
_logger.info(
'[live-step-fix] recomputed area_kind on %s steps', len(steps),
)
# Phase 4 — Recompute active_step_id + card_state on in-flight jobs.
jobs = env['fp.job'].search([
('state', 'in', ('confirmed', 'in_progress')),
])
if jobs:
jobs._compute_active_step_id()
jobs._compute_card_state()
jobs.flush_recordset(['active_step_id', 'card_state'])
_logger.info(
'[live-step-fix] recomputed active_step_id + card_state on %s jobs',
len(jobs),
)

View File

@@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.10.26.0 - Recipe cleanup + per-part clone delete.
Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
Phases (in order):
1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the
duplicate empty ENP-Alum Line sub_process (id 4056).
2. Backfill kind on all kind=other nodes via the extended
fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND
translation.
3. Delete all per-part clone recipes (name ILIKE '% - %').
CASCADE handles child nodes; SET NULL handles fp.job /
fp.job.step / fp.coating.config / fp.pricing.rule /
fp.part.catalog references.
4. Recompute fp.job.step.area_kind on all rows.
5. Recompute fp.job.active_step_id + card_state on in-flight jobs.
All phases idempotent - re-running -u is safe.
"""
import logging
from odoo.api import Environment, SUPERUSER_ID
_logger = logging.getLogger(__name__)
# Recipe 3620's ops in the desired final order. Maps the existing node
# id (as documented in the spec) to its target sequence. The user
# decided mask-first-then-rack (matches the existing De-Masking step's
# position between Plating and Bake; de-mask before de-rack would be
# illogical).
RECIPE_3620_RESEQUENCE = [
# (node_id, new_sequence, expected_name)
(3853, 10, 'Contract Review'),
(3854, 20, 'Incoming Inspection (Standard)'),
(3877, 30, 'Masking'),
(3855, 40, 'Racking'),
(3858, 50, 'Ready for processing'),
(3859, 60, 'ENP-Alum Line'),
(3861, 70, 'De-Masking'),
(3864, 80, 'Oven baking'),
(3867, 90, 'De-racking'),
(4067, 100, 'Oven bake (Post de-rack)'),
(3873, 110, 'Post-plate Inspection'),
(3876, 120, 'Final Inspection'),
]
# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no
# children - the real one is id 3859 with E-Nickel Plating as child).
RECIPE_3620_DUPLICATE_TO_DELETE = 4056
def migrate(cr, version):
env = Environment(cr, SUPERUSER_ID, {})
# ============================================================
# Phase 1 - Resequence recipe 3620 + delete duplicate sub_process
# ============================================================
Node = env['fusion.plating.process.node']
recipe_3620 = Node.browse(3620).exists()
if not recipe_3620:
_logger.warning(
'[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; '
'skipping resequence phase'
)
else:
# Resequence idempotently - only update if sequence differs.
renumbered = 0
for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE:
node = Node.browse(node_id).exists()
if not node:
_logger.warning(
'[recipe-cleanup] Recipe 3620: expected node %s '
'("%s") not found; skipping',
node_id, expected_name,
)
continue
if node.sequence != new_seq:
# Skip autoclassify - we only touch sequence here.
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'sequence': new_seq})
renumbered += 1
_logger.info(
'[recipe-cleanup] Recipe 3620: %s nodes resequenced',
renumbered,
)
# Delete the empty duplicate ENP-Alum Line sub_process.
dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists()
if dup:
if dup.child_ids:
_logger.warning(
'[recipe-cleanup] Duplicate sub_process %s has '
'%s children - NOT deleting (safety check). '
'Expected an empty node.',
dup.id, len(dup.child_ids),
)
else:
dup.unlink()
_logger.info(
'[recipe-cleanup] Deleted empty duplicate '
'ENP-Alum Line sub_process (id %s)',
RECIPE_3620_DUPLICATE_TO_DELETE,
)
# ============================================================
# Phase 2 - Backfill kind on all kind=other nodes via resolver
# ============================================================
from odoo.addons.fusion_plating import (
fp_resolve_step_kind,
RESOLVER_KIND_TO_ACTIVE_KIND,
)
Kind = env['fp.step.kind']
other_kind = Kind.search([('code', '=', 'other')], limit=1)
if not other_kind:
_logger.error(
'[recipe-cleanup] No "other" kind found; skipping kind '
'backfill phase'
)
else:
# Cache code -> kind.id so we don't search per-row.
kind_by_code = {k.code: k.id for k in Kind.search([])}
affected_nodes = Node.search([
('kind_id', '=', other_kind.id),
('name', '!=', False),
('node_type', 'in', ('operation', 'step', 'sub_process')),
])
fixed = 0
for node in affected_nodes:
resolver_code = fp_resolve_step_kind(node.name)
if not resolver_code:
continue
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
if not target_code or target_code not in kind_by_code:
continue
node.with_context(
fp_skip_kind_autoclassify=True,
).write({'kind_id': kind_by_code[target_code]})
fixed += 1
_logger.info(
'[recipe-cleanup] Phase 2: backfilled kind on %s nodes '
'(of %s currently kind=other)',
fixed, len(affected_nodes),
)
# ============================================================
# Phase 3 - Delete all per-part clone recipes
# ============================================================
# Identify by name pattern. The configurator names clones
# "BASE_NAME - PART_NUMBER Rev X" with an em-dash separator
# (U+2014). No base recipe uses em-dash in its name.
#
# IMPORTANT: delete one-clone-at-a-time with savepoint per clone.
# Batch unlink (clone_recipes.unlink()) tripped a PostgreSQL FK
# cascade ordering bug on entech (insert-or-update on parent_id
# during the cascade chain). Per-clone unlink with intermediate
# cleanup avoids that path entirely and lets one bad clone fail
# without rolling back the others.
clone_recipes = Node.search([
('node_type', '=', 'recipe'),
('name', 'ilike', '%%'),
])
if not clone_recipes:
_logger.info(
'[recipe-cleanup] Phase 3: no clone recipes found '
'(already deleted on a prior run, or none exist)'
)
else:
_logger.info(
'[recipe-cleanup] Phase 3: deleting %s clone recipes one '
'at a time (per-clone savepoint)', len(clone_recipes),
)
deleted = 0
failed = []
for clone in clone_recipes:
cid, cname = clone.id, clone.name
cr.execute('SAVEPOINT delete_clone')
try:
clone.unlink()
cr.execute('RELEASE SAVEPOINT delete_clone')
deleted += 1
except Exception as e:
cr.execute('ROLLBACK TO SAVEPOINT delete_clone')
failed.append((cid, cname, type(e).__name__, str(e)[:120]))
_logger.warning(
'[recipe-cleanup] Phase 3: failed to delete '
'clone %s ("%s"): %s — continuing',
cid, cname, type(e).__name__,
)
_logger.info(
'[recipe-cleanup] Phase 3: deleted %s/%s clones '
'(%s failures retained for manual review)',
deleted, len(clone_recipes), len(failed),
)
if failed:
for cid, cname, errtype, errmsg in failed:
_logger.warning(
'[recipe-cleanup] Phase 3 leftover: id=%s name=%r '
'err=%s: %s', cid, cname, errtype, errmsg,
)
# ============================================================
# Phase 4 - Recompute area_kind on all fp.job.step rows
# ============================================================
Step = env['fp.job.step']
steps = Step.search([])
if steps:
steps._compute_area_kind()
steps.flush_recordset(['area_kind'])
_logger.info(
'[recipe-cleanup] Phase 4: recomputed area_kind on %s steps',
len(steps),
)
# ============================================================
# Phase 5 - Recompute active_step_id + card_state on in-flight jobs
# ============================================================
Job = env['fp.job']
jobs = Job.search([
('state', 'in', ('confirmed', 'in_progress')),
])
if jobs:
jobs._compute_active_step_id()
jobs._compute_card_state()
jobs.flush_recordset(['active_step_id', 'card_state'])
_logger.info(
'[recipe-cleanup] Phase 5: recomputed active_step_id + '
'card_state on %s in-flight jobs',
len(jobs),
)

View File

@@ -144,9 +144,14 @@ class FpJob(models.Model):
active_step_id = fields.Many2one(
'fp.job.step',
compute='_compute_active_step_id',
store=True,
index=True,
string='Active Step',
help='Currently in-progress step (lowest sequence if multiple — '
'defensive). Drives JobWorkspace landing focus.',
help='Currently the live step under the priority chain '
'(in_progress > paused > ready > pending). Drives '
'JobWorkspace landing focus + card_state. Stored so '
'card_state\'s `active_step_id.area_kind` dependency '
'can search back to dependent jobs without erroring.',
)
# ===== 2026-05-23 Plant-view kanban — card_state + mini_timeline ====
@@ -257,13 +262,21 @@ class FpJob(models.Model):
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)
# Edge: no live step (all steps done OR no steps at all).
# - job.state='done' → 'done' (defensive — done jobs are
# filtered off the Shop Floor board upstream, but the
# field still needs a value).
# - confirmed + parts not yet received → 'no_parts'.
# - else → 'ready' (job awaiting work, no steps yet OR
# recipe not assigned).
if not job.active_step_id:
if (job.state == 'confirmed'
if job.state == 'done':
job.card_state = 'done'
elif (job.state == 'confirmed'
and job._fp_inbound_not_received()):
job.card_state = 'no_parts'
else:
job.card_state = 'contract_review'
job.card_state = 'ready'
continue
step = job.active_step_id
@@ -384,11 +397,32 @@ class FpJob(models.Model):
@api.depends('step_ids.state', 'step_ids.sequence')
def _compute_active_step_id(self):
"""Pick the "live" step — first match by priority then sequence.
Priority order:
in_progress > paused > ready > first pending
in_progress is the most informative (someone is actively
working on it). paused means someone was working and stopped —
the card belongs at that station so the next operator can
pick it up. ready is the next-up step waiting for an operator.
The first pending after a done step is the "next gate"
where the card visually waits between steps.
Returns False only when every step is `done` (job finished)
or when there are no steps at all (recipe not assigned).
See spec 2026-05-24-shopfloor-live-step-fix-design.md Change 1.
"""
PRIORITY_STATES = ('in_progress', 'paused', 'ready', 'pending')
for job in self:
active = job.step_ids.filtered(
lambda s: s.state == 'in_progress'
).sorted('sequence')
job.active_step_id = active[:1].id if active else False
ordered = job.step_ids.sorted('sequence')
live = job.env['fp.job.step']
for state in PRIORITY_STATES:
live = ordered.filtered(lambda s: s.state == state)
if live:
break
job.active_step_id = live[:1].id if live else False
# ------------------------------------------------------------------
# Sub 14 — Configurable workflow state (status bar milestone)

View File

@@ -17,60 +17,11 @@ 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',
}
# 2026-05-24 — Shop Floor live-step fix (19.0.10.24.0):
# The legacy `_STEP_KIND_TO_AREA` dict that lived here was removed.
# fp.step.kind now self-declares its area_kind, so the kind taxonomy
# IS the source of truth for Shop Floor column routing.
# See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
class FpJobStep(models.Model):
@@ -169,21 +120,40 @@ class FpJobStep(models.Model):
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.',
'(1) work_centre.area_kind if set; else (2) the area_kind on '
'recipe_node.kind_id; else (3) the safe catch-all "plating". '
'Drives plant-view kanban grouping.',
)
@api.depends('work_centre_id.area_kind', 'recipe_node_id.default_kind')
@api.depends(
'work_centre_id.area_kind',
'recipe_node_id.kind_id.area_kind',
)
def _compute_area_kind(self):
"""Resolve the plant-view column this step belongs in.
Priority chain:
1. work_centre.area_kind (explicit operator setup wins)
2. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
3. catch-all 'plating' (data integrity issue if we land here)
The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
now self-declares its area_kind, so the kind taxonomy IS the
source of truth. See spec
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
"""
for step in self:
# 1. Explicit work_centre wins
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]
# 2. Kind taxonomy
node = step.recipe_node_id
if node and node.kind_id and node.kind_id.area_kind:
step.area_kind = node.kind_id.area_kind
continue
# 3. Catch-all — only reached for orphaned steps (no
# work_centre AND no recipe_node).
step.area_kind = 'plating'
last_activity_at = fields.Datetime(
@@ -827,6 +797,13 @@ class FpJobStep(models.Model):
per-step data trail; finishing a step with missing prompts breaks
the audit chain.
2026-05-24: also blocks orphaned steps (recipe_node_id NULL —
happens when the source recipe was deleted, e.g. a per-part clone
cleanup). Without a recipe link there's no way to verify required
prompts; defaulting to "let it through" was a silent compliance
gap. Managers can bypass via the same flag, audit chatter records
the override.
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.
@@ -839,6 +816,17 @@ class FpJobStep(models.Model):
)) % (step.name, self.env.user.name))
return
for step in self:
# Orphan-step block — NULL recipe_node means we can't list
# required prompts, so we conservatively refuse to finish.
if not step.recipe_node_id:
raise UserError(_(
'Step "%(step)s" cannot be finished — this step has '
'no recipe link (the source recipe was deleted or the '
'job was created before recipes were assigned). '
'Required-input verification is impossible without '
'the recipe. Escalate to a manager — they can bypass '
'with an audit-chatter entry.'
) % {'step': step.name})
missing = step._fp_missing_required_step_inputs()
if not missing:
continue
@@ -901,7 +889,8 @@ class FpJobStep(models.Model):
skipped.
"""
for step in self:
job = step.job_id
# sudo() — technicians lack sale.order ACL (Rule 13m).
job = step.sudo().job_id
if not job.sale_order_line_ids:
continue
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
@@ -921,7 +910,8 @@ class FpJobStep(models.Model):
in-flight serials to `inspected` so the shipper sees them ready
for packing. Conservative — only promotes from `in_process`."""
for step in self:
job = step.job_id
# sudo() — technicians lack sale.order ACL (Rule 13m).
job = step.sudo().job_id
if not job.sale_order_line_ids:
continue
# Is this the highest-sequence non-cancelled step on the job?
@@ -976,7 +966,8 @@ class FpJobStep(models.Model):
Falls through to None when no part can be resolved (no SO line,
SO line without x_fc_part_catalog_id, etc.)."""
self.ensure_one()
for so_line in self.job_id.sale_order_line_ids:
# sudo() — technicians lack sale.order ACL (Rule 13m).
for so_line in self.sudo().job_id.sale_order_line_ids:
if (so_line.x_fc_part_catalog_id
and 'fp.contract.review' in self.env):
return so_line.x_fc_part_catalog_id
@@ -1177,7 +1168,11 @@ class FpJobStep(models.Model):
for step in self:
if step._fp_is_contract_review_step():
continue
so = step.job_id.sale_order_id
# sudo() — technicians don't have sale.order ACL but the
# gate's purpose is checking a denormalized state field.
# Rule 13m: cross-module reads in tablet/floor controllers
# must sudo() the source recordset.
so = step.sudo().job_id.sale_order_id
if not so:
# Internal rework / no SO — gate doesn't apply.
continue
@@ -1427,6 +1422,50 @@ class FpJobStep(models.Model):
},
}
def action_mark_gating_passed(self):
"""1-click pass for gating steps (kind=='gating').
Performs button_start (or button_resume if paused) followed by
button_finish in the same transaction. Posts a chatter audit on
the parent job naming the user.
Only valid for kind='gating' steps in state in (ready, pending,
paused). NOOPs on already-terminal steps for idempotency. Raises
UserError if called on a non-gating step (defensive — UI dispatcher
only renders Mark Passed for gating kinds).
Bypasses the S21 required-inputs gate (gating steps have no
required inputs by design — they're admin gates).
Spec: 2026-05-24-workspace-step-actions-design.md Change 5.
"""
for step in self:
if step.state in ('done', 'skipped', 'cancelled'):
continue
kind_code = (
step.recipe_node_id.kind_id.code
if step.recipe_node_id and step.recipe_node_id.kind_id
else None
)
if kind_code != 'gating':
raise UserError(_(
"Mark Passed is only valid for gating steps. "
"This step's kind is %s."
) % (kind_code or 'unknown'))
if step.state not in ('ready', 'pending', 'paused'):
continue
if step.state == 'paused':
step.button_resume()
if step.state != 'in_progress':
step.button_start()
step.with_context(
fp_skip_required_inputs_gate=True,
).button_finish()
step.job_id.message_post(body=_(
'Gate "%(name)s" marked passed by %(user)s.'
) % {'name': step.name, 'user': self.env.user.name})
return True
def _fp_contract_review_redirect(self):
"""Return an ir.actions.act_window opening the part's QA-005
Contract Review form, or False to indicate "no redirect needed".

View File

@@ -236,6 +236,52 @@ export class FpRecordInputsDialog extends Component {
return "any";
}
// Stepper helpers — give the operator a tap-to-increment / -decrement
// pair next to each numeric input so they don't have to open the
// keyboard for small adjustments. Field is one of: value_number,
// value_min, value_max. Increment uses stepFor() so taps move in the
// same decimal magnitude the recipe spec was written in. Clamps at 0
// (typical qty/time/temp on a plating shop floor doesn't go negative;
// if a recipe needs negatives, the operator can still type the value
// by tapping the input).
_stepDelta(row) {
const s = this.stepFor(row);
if (s === "any") return 1;
const n = parseFloat(s);
return isNaN(n) || n <= 0 ? 1 : n;
}
_stepRound(n, delta) {
// Avoid floating-point fuzz (0.1+0.2=0.30000004). Round to the
// delta's decimal precision.
const decimals = (String(delta).split(".")[1] || "").length;
if (!decimals) return Math.round(n);
const factor = Math.pow(10, decimals);
return Math.round(n * factor) / factor;
}
onIncrement(row, field) {
const cur = parseFloat(row[field]) || 0;
const delta = this._stepDelta(row);
row[field] = this._stepRound(cur + delta, delta);
}
onDecrement(row, field) {
const cur = parseFloat(row[field]) || 0;
const delta = this._stepDelta(row);
row[field] = Math.max(0, this._stepRound(cur - delta, delta));
}
inputModeFor(row) {
// Tablet keyboard hint — show numeric keypad instead of full
// keyboard when the operator does tap the input. 'decimal' is
// safer than 'numeric' because it includes the decimal point
// (needed for pH, thickness, temperature).
const t = row.input_type || "";
if (t === "time_seconds" || t === "time_hms") return "numeric";
return "decimal";
}
_fpCountDecimals(n) {
if (n === null || n === undefined || n === "" || n === 0) return 0;
const s = String(n);

View File

@@ -801,3 +801,49 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
color: $rid-required;
}
}
// Numeric stepper (tap-to-increment / tap-to-decrement around the input).
// Operator can still tap the input to open the keypad (inputmode="decimal"
// gives them the number keypad on iPad).
.o_fp_ri_stepper {
display: flex;
align-items: stretch;
gap: 0;
max-width: 16rem;
}
.o_fp_ri_stepper_btn {
flex: 0 0 auto;
width: 3.2rem;
border: 1px solid #cdd0d4;
background: #f5f6f8;
color: #1d1d1f;
font-size: 1.3rem;
cursor: pointer;
transition: background 80ms;
display: flex;
align-items: center;
justify-content: center;
&:hover, &:active { background: #e5e7eb; }
&.o_fp_ri_stepper_minus {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
border-right: 0;
}
&.o_fp_ri_stepper_plus {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
border-left: 0;
}
}
.o_fp_ri_stepper_input {
flex: 1 1 auto;
border-radius: 0;
text-align: center;
font-size: 1.2rem;
font-weight: 600;
min-width: 4rem;
}

View File

@@ -120,11 +120,26 @@
<!-- Numeric — single value (no range defined) -->
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
class="o_fp_ri_numeric">
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_number"
t-att-placeholder="row.target_min or '0.00'"/>
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_number')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_number"
t-att-placeholder="row.target_min or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_number')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
<t t-set="hint" t-value="rangeHint(row)"/>
<span t-if="hint"
class="o_fp_ri_range_hint"
@@ -138,22 +153,52 @@
Constrained to numeric so it doesn't duplicate
the pass_fail+range branch above. -->
<div t-if="isNumeric(row) and hasRangeEntry(row)" class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_min')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_min')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_max')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_max')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
@@ -169,22 +214,52 @@
prompt (e.g. Bore inspection: 0.005-0.007 in). -->
<t t-if="isPassFail(row) and hasRangeEntry(row)">
<div class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_min')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_min')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_max')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_max')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"

View File

@@ -1,16 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Add "All Jobs" and "Steps" as children of fusion_plating_shopfloor's
Add "Work Orders" and "Steps" as children of fusion_plating_shopfloor's
Shop Floor menu. We can reference shopfloor's xmlid here because
fusion_plating_jobs declares it as a depend.
Sequences fit between Tablet Station (10) and Bake Windows (20)
in shopfloor's existing fp_menu.xml.
Renamed 2026-05-25 — "All Jobs" → "Work Orders" for consistency
with the rest of the UI (SO smart button is "WO", tablet cards
show "WO # 00001", KPI tile says "Work Orders"). xmlid kept
as menu_fp_jobs_all_jobs so bookmarks and inherits don't break.
-->
<menuitem id="menu_fp_jobs_all_jobs"
name="All Jobs"
name="Work Orders"
parent="fusion_plating_shopfloor.menu_fp_shopfloor"
action="fusion_plating.action_fp_job"
sequence="15"/>

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""Battle test S24 — Live step priority chain + board state filter.
Run end-to-end via odoo shell with stdin redirection:
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \\
\"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" \\
< /mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_s24_between_steps.py'"
Asserts:
1. Job between steps (one done, next pending) has live active_step_id
pointing at the next pending step, NOT False.
2. Card column resolves to that pending step's area_kind, NOT receiving.
3. Paused steps still count as active.
4. state='done' jobs are excluded from the live-board search domain.
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
"""
import logging
_logger = logging.getLogger(__name__)
def _resolve_card_area(job):
"""Mirror of plant_kanban._resolve_card_area for test purposes."""
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
return 'receiving'
def run():
partner = env['res.partner'].search([('customer_rank', '>', 0)], limit=1)
if not partner:
raise AssertionError(
'No customer partner found — seed test data first'
)
recipe = env['fusion.plating.process.node'].search([
('node_type', '=', 'recipe'),
('child_ids', '!=', False),
], limit=1)
if not recipe:
raise AssertionError('No recipe found — seed test data first')
job = env['fp.job'].create({
'partner_id': partner.id,
'recipe_id': recipe.id,
'qty': 1,
})
job._fp_generate_steps_from_recipe()
steps = job.step_ids.sorted('sequence')
if len(steps) < 3:
raise AssertionError('Need at least 3 steps for the test')
# === Phase A — between-step assertion ===
s1 = steps[0]
s2 = steps[1]
s1.button_start()
s1.button_finish()
job.invalidate_recordset(['active_step_id', 'card_state'])
if job.active_step_id.id != s2.id:
raise AssertionError(
'Expected active_step_id = %s (next pending), got %s'
% (s2.id, job.active_step_id.id)
)
if _resolve_card_area(job) != s2.area_kind:
raise AssertionError(
'Card column should match s2.area_kind=%s, got %s'
% (s2.area_kind, _resolve_card_area(job))
)
_logger.info('[bt_s24] Phase A OK — between-step routing correct')
# === Phase B — paused step assertion ===
s2.button_start()
s2.button_pause('lunch break')
job.invalidate_recordset(['active_step_id', 'card_state'])
if job.active_step_id.id != s2.id:
raise AssertionError(
'Paused step should remain the live step, got %s'
% job.active_step_id.id
)
_logger.info('[bt_s24] Phase B OK — paused step stays live')
# === Phase C — done job filter ===
for s in steps:
if s.state != 'done':
if s.state == 'paused':
s.button_resume()
if s.state != 'in_progress':
s.button_start()
s.button_finish()
job.with_context(
fp_skip_step_gate=True,
fp_skip_qty_reconcile=True,
fp_skip_bake_gate=True,
).button_mark_done()
if job.state != 'done':
raise AssertionError('job did not transition to done')
jobs_on_board = env['fp.job'].search([
('state', 'in', ('confirmed', 'in_progress')),
])
if job.id in jobs_on_board.ids:
raise AssertionError(
'Done job %s should be filtered off board' % job.id
)
_logger.info('[bt_s24] Phase C OK — done jobs filtered off board')
_logger.info('[bt_s24] ALL ASSERTIONS PASSED')
run()

View File

@@ -5,10 +5,9 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.33.1.1',
'version': '19.0.33.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """
Fusion Plating — Shop Floor
===========================
@@ -53,7 +52,6 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/res_users_views.xml',
'views/fp_bake_oven_views.xml',
'views/fp_bake_window_views.xml',
'views/fp_first_piece_gate_views.xml',
'views/fp_plant_overview_views.xml',
'views/tank_status_template.xml',
'views/fp_tablet_session_event_views.xml',
@@ -114,6 +112,16 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
# ---- Damage dialog for receiving (Spec C1+C2 2026-05-24) ----
# Loaded AFTER job_workspace.js because job_workspace imports
# FpDamageDialog; the asset bundler doesn't care about JS module
# order (ES modules resolve at runtime) but keep adjacent for
# readability.
'fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml',
'fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js',
# ---- Finish block dialog (required-inputs gate + manager bypass) ----
'fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml',
'fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js',
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',

View File

@@ -65,9 +65,13 @@ class PlantKanbanController(http.Controller):
else env['fp.work.centre'])
paired_area = paired.area_kind if paired else None
# Base domain — every job with active recipe steps
# Base domain — only in-flight jobs.
# 2026-05-24 (spec 2026-05-24-shopfloor-live-step-fix-design.md
# Defect 4 / Change 3): done + cancelled jobs drop off the live
# board. They stay reachable via smart buttons, the Plating Jobs
# backend list, and history reports.
domain = [
('state', 'in', ('confirmed', 'in_progress', 'done')),
('state', 'in', ('confirmed', 'in_progress')),
]
filters = filters or {}
if filters.get('overdue'):
@@ -161,12 +165,26 @@ def fields_today_ts():
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).
Active-step area_kind wins, EXCEPT for no_parts cards which always
land in Receiving regardless of active step — the receiver is who
needs to act, and they work the Receiving column. With the
live-step priority chain (see fp.job._compute_active_step_id),
active_step_id is False only when the job has NO steps at all
(recipe not assigned) OR every step is `done`. Done jobs are
filtered off the board upstream (state-domain in plant_kanban),
so the orphan fallback fires only for truly orphaned cards.
See specs 2026-05-24-shopfloor-live-step-fix-design.md Change 4
and 2026-05-24-recipe-cleanup-design.md Change 6.
"""
# no_parts cards belong in Receiving regardless of where the
# active step is — the receiver is who acts.
if job.card_state == 'no_parts':
return 'receiving'
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
# Fallback: receiving column
# Orphan fallback represents a data integrity issue, not a
# normal state. Cards here have NO steps assigned at all.
return 'receiving'

View File

@@ -641,8 +641,8 @@ class FpShopfloorController(http.Controller):
# /fp/landing/kanban. The Tablet Station menu now points at the new
# surface. This endpoint stays live as long as the legacy
# fp_shopfloor_tablet OWL component is still registered — it consumes
# the rich payload (my_queue, active_wo, baths, bake_windows, gates,
# holds, pending_qcs, stations). Phase 5 cleanup will retire both the
# the rich payload (my_queue, active_wo, baths, bake_windows, holds,
# pending_qcs, stations). Phase 5 cleanup will retire both the
# legacy component and this endpoint together.
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
def tablet_overview(self, station_id=None, facility_id=None):
@@ -673,7 +673,6 @@ class FpShopfloorController(http.Controller):
Step = env['fp.job.step']
BakeWindow = env['fusion.plating.bake.window']
Gate = env['fusion.plating.first.piece.gate']
Hold = env['fusion.plating.quality.hold']
def _fac_dom(dom):
@@ -704,7 +703,6 @@ class FpShopfloorController(http.Controller):
awaiting = BakeWindow.search_count(bake_dom + [('state', '=', 'awaiting_bake')])
in_progress_bakes = BakeWindow.search_count(bake_dom + [('state', '=', 'bake_in_progress')])
missed = BakeWindow.search_count(bake_dom + [('state', '=', 'missed_window')])
pending_gates = Gate.search_count(_fac_dom([('result', '=', 'pending')]))
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
if my_job_ids_for_kpi and 'x_fc_job_id' in Hold._fields:
hold_dom.append(('x_fc_job_id', 'in', my_job_ids_for_kpi))
@@ -715,7 +713,6 @@ class FpShopfloorController(http.Controller):
{'label': 'In Progress', 'value': steps_progress, 'tone': 'success', 'icon': 'fa-cogs'},
{'label': 'Awaiting Bake', 'value': awaiting, 'tone': 'warning', 'icon': 'fa-fire'},
{'label': 'Missed Windows', 'value': missed, 'tone': 'danger' if missed else 'muted', 'icon': 'fa-exclamation-triangle'},
{'label': 'First-Piece', 'value': pending_gates, 'tone': 'info', 'icon': 'fa-flag-checkered'},
{'label': 'Quality Holds', 'value': open_holds, 'tone': 'danger' if open_holds else 'muted', 'icon': 'fa-pause-circle'},
]
@@ -873,23 +870,6 @@ class FpShopfloorController(http.Controller):
for bw in bws
]
# -- First-piece gates -------------------------------------------
gate_domain = _fac_dom([('result', 'in', ('pending', 'fail'))])
gates = Gate.search(gate_domain, order='first_piece_produced desc', limit=6)
gates_data = [
{
'id': g.id,
'name': g.name,
'part_ref': g.part_ref or '',
'customer': g.customer_ref or '',
'bath': g.bath_id.name or '',
'result': g.result,
'first_piece': fp_format(request.env, g.first_piece_produced),
'inspector': g.inspector_id.name or '',
}
for g in gates
]
# -- Quality holds -----------------------------------------------
# v19.0.24.3.0 — scope holds to operator's jobs so Carlos's
# sidebar isn't flooded with plant-wide HOLD-XXXX from other
@@ -986,7 +966,6 @@ class FpShopfloorController(http.Controller):
'active_wo': active_wo,
'baths': baths_data,
'bake_windows': bw_data,
'gates': gates_data,
'holds': holds_data,
'pending_qcs': pending_qcs,
'stations': stations,
@@ -1007,26 +986,6 @@ class FpShopfloorController(http.Controller):
})
return {'ok': True}
# ----------------------------------------------------------------------
# Mark a first-piece gate result from the tablet
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
def mark_gate(self, gate_id, result):
env = request.env
gate = env['fusion.plating.first.piece.gate'].browse(int(gate_id))
if not gate.exists():
return {'ok': False, 'error': 'Gate not found.'}
try:
if result == 'pass':
gate.action_mark_pass()
elif result == 'fail':
gate.action_mark_fail()
else:
return {'ok': False, 'error': f'Unknown result {result}'}
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
return {'ok': True, 'state': gate.result}
# ----------------------------------------------------------------------
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
# ----------------------------------------------------------------------
@@ -1403,7 +1362,6 @@ class FpShopfloorController(http.Controller):
'due_today': 25,
'priority': 20,
'due_soon': 15,
'first_piece': 10,
'normal': 0,
}

View File

@@ -16,6 +16,7 @@ import logging
from datetime import timedelta
from odoo import _, fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_user_tz
from odoo.exceptions import AccessDenied, UserError
from odoo.http import request
@@ -198,9 +199,13 @@ class FpTabletController(http.Controller):
# Attempt the real Odoo session swap via the custom auth manager.
# session.authenticate validates credentials through _check_credentials,
# issues a new sid, sets the cookie, returns the user dict.
# NB: Odoo 19's Session.authenticate signature is (env, credential) —
# passing request.db (a string) raises TypeError: 'str' object is not
# callable on the internal env(user=None, su=False) reset. Pass
# request.env instead.
try:
request.session.authenticate(
request.db,
request.env,
{'type': 'fp_tablet_pin',
'login': target.login,
'pin': pin},
@@ -355,8 +360,9 @@ class FpTabletController(http.Controller):
'needs_kiosk_relogin': True}
try:
# Odoo 19 signature: (env, credential) — see unlock_session note.
request.session.authenticate(
request.db,
request.env,
{'type': 'password',
'login': kiosk_login,
'password': kiosk_password},
@@ -450,12 +456,18 @@ class FpTabletController(http.Controller):
raise_if_not_found=False,
)
kiosk_uid = kiosk.id if kiosk else None
# tz_name — resolved per fp_tz.fp_user_tz (kiosk user.tz → company
# x_fc_default_tz → UTC). Frontend uses Intl.DateTimeFormat with
# this tz so the lock-screen clock follows the FP regional
# setting, not the iPad's system tz (caught 2026-05-25 when the
# workspace timer-offset bug surfaced the broader pattern).
return {
'ok': True,
'company': _lock_company_payload(request.env),
'tiles': tiles,
'kiosk_uid': kiosk_uid,
'current_uid': request.env.uid,
'tz_name': str(fp_user_tz(request.env)),
}
# ======================================================================

View File

@@ -20,7 +20,8 @@ Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan
import logging
from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.addons.fusion_plating.models.fp_tz import fp_format, fp_isoformat_utc
from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
@@ -80,15 +81,21 @@ class FpWorkspaceController(http.Controller):
'work_centre_name': step.work_centre_id.name or '',
'duration_actual': step.duration_actual or 0,
'duration_expected': step.duration_expected or 0,
'date_started_iso': fp_format(
env, step.date_started, fmt='%Y-%m-%d %H:%M:%S',
) if step.date_started else '',
# fp_isoformat_utc — preserves UTC with explicit +00:00
# offset so the JS timer parses it as UTC (not local wall
# time). fp_format would convert to user tz first, then
# the JS would re-interpret that wall time as UTC and
# offset the timer by the user's tz offset (4h on EDT).
'date_started_iso': fp_isoformat_utc(step.date_started),
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'dwell_time_minutes': step.dwell_time_minutes or 0,
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
'requires_rack_assignment': bool(getattr(
step, 'requires_rack_assignment', False,
)),
'can_start': bool(step.can_start) if 'can_start' in step._fields else (
step.state in ('ready', 'paused') and step.blocker_kind == 'none'
),
@@ -130,6 +137,58 @@ class FpWorkspaceController(http.Controller):
else job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
)
# ---- Receivings (pre-recipe box-count gate) ---------------------
# Spec C1+C2 (2026-05-24): tablet UI for box count + per-line
# received qty + damage log. Renders a card above the step list
# for any receiving in state draft/counted. Closed receivings drop
# off (recipe takes over). See fp.receiving model + the receiver
# persona note in CLAUDE.md Sub 8.
receivings_payload = []
so = job.sale_order_id if 'sale_order_id' in job._fields else False
if so and 'x_fc_receiving_ids' in so._fields:
for rec in so.x_fc_receiving_ids.filtered(
lambda r: r.state in ('draft', 'counted')
):
receivings_payload.append({
'id': rec.id,
'name': rec.name or '',
'state': rec.state,
'state_label': dict(rec._fields['state'].selection).get(
rec.state, rec.state,
),
'box_count_in': int(rec.box_count_in or 0),
'expected_qty': int(rec.expected_qty or 0),
'received_qty': int(rec.received_qty or 0),
'received_date': fp_format(
env, rec.received_date, fmt='%Y-%m-%d %H:%M',
) if rec.received_date else '',
'received_by_name': rec.received_by_id.name or '',
'lines': [{
'id': line.id,
'part_number': line.part_number or (
line.part_catalog_id.part_number
if line.part_catalog_id else ''
),
'description': line.description or '',
'expected_qty': int(line.expected_qty or 0),
'received_qty': int(line.received_qty or 0),
'condition': line.condition or 'good',
} for line in rec.line_ids],
'damages': [{
'id': dmg.id,
'severity': dmg.severity or 'cosmetic',
'severity_label': dict(
dmg._fields['severity'].selection,
).get(dmg.severity, dmg.severity),
'description': dmg.description or '',
'action_required': dmg.action_required or 'none',
'action_label': dict(
dmg._fields['action_required'].selection,
).get(dmg.action_required, dmg.action_required),
'photo_count': len(dmg.photo_ids),
} for dmg in rec.damage_ids],
})
return {
'ok': True,
'job': {
@@ -186,6 +245,14 @@ class FpWorkspaceController(http.Controller):
for m in chatter
],
'required_certs': required_certs,
'receivings': receivings_payload,
# 2026-05-24 — is_manager surfaces to the JS so it can offer
# the manager-bypass affordance (e.g. on the required-inputs
# gate dialog). Server-side endpoints re-check the group
# before honouring any bypass param — this is for UI only.
'is_manager': env.user.has_group(
'fusion_plating.group_fusion_plating_manager',
),
}
# ======================================================================
@@ -253,6 +320,77 @@ class FpWorkspaceController(http.Controller):
'attachment_id': attachment_id,
}
# ======================================================================
# /fp/workspace/finish_step — structured-error finish with manager bypass
# ======================================================================
# Wraps step.button_finish with structured response shape so the
# frontend can render the FpFinishBlockDialog (Cancel / Record /
# Bypass) instead of a generic toast. Manager bypass requires the
# caller to set bypass_required_inputs=True AND the server confirms
# the user has the fusion_plating_manager group — trusting the
# context flag alone would let any operator bypass via raw RPC.
@http.route('/fp/workspace/finish_step', type='jsonrpc', auth='user')
def finish_step(self, step_id, bypass_required_inputs=False):
env = request.env
step = env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found'}
# Server-side bypass authorization — can't trust the client.
is_manager = env.user.has_group(
'fusion_plating.group_fusion_plating_manager',
)
if bypass_required_inputs and not is_manager:
return {
'ok': False,
'error': 'Manager privilege required to bypass required inputs.',
'gate': 'permission_denied',
'bypass_available': False,
}
ctx = {}
if bypass_required_inputs:
ctx['fp_skip_required_inputs_gate'] = True
try:
step.with_context(**ctx).button_finish()
except UserError as e:
msg = str(e.args[0]) if e.args else str(e)
# Classify the gate so the JS knows whether to show the
# block dialog (required_inputs) vs a plain toast.
gate = 'other'
if 'required input' in msg.lower() or 'no recipe link' in msg.lower():
gate = 'required_inputs'
elif 'sign-off' in msg.lower() or 'sign off' in msg.lower():
gate = 'signoff'
elif 'predecessor' in msg.lower():
gate = 'predecessor'
elif 'contract review' in msg.lower():
gate = 'contract_review'
# Collect the per-prompt list when the gate is required_inputs
# AND the step has a recipe_node (orphan steps have nothing to list).
missing = []
if gate == 'required_inputs' and step.recipe_node_id:
try:
for p in step._fp_missing_required_step_inputs():
missing.append({'id': p.id, 'name': p.name or ''})
except Exception:
pass
return {
'ok': False,
'error': msg,
'gate': gate,
'missing_prompts': missing,
'orphan_step': not bool(step.recipe_node_id),
'bypass_available': is_manager and gate == 'required_inputs',
}
except Exception as exc:
_logger.exception("workspace/finish_step unexpected error")
return {'ok': False, 'error': str(exc), 'gate': 'unexpected'}
return {'ok': True}
# ======================================================================
# /fp/workspace/sign_off — capture signature + finish step atomically
# ======================================================================
@@ -340,3 +478,147 @@ class FpWorkspaceController(http.Controller):
'next_milestone_action': job.next_milestone_action or '',
'next_milestone_label': job.next_milestone_label or '',
}
# ======================================================================
# Receiving — pre-recipe box-count + damage log (Spec C1+C2 2026-05-24)
# ======================================================================
# Mirrors the backend fp.receiving form just enough for the receiver
# persona to count boxes, log damage with photos, and close the
# receiving from the tablet workspace. No new backend models — wraps
# action_mark_counted / action_close and fp.receiving.damage CRUD.
@http.route('/fp/workspace/receiving_save_lines',
type='jsonrpc', auth='user')
def receiving_save_lines(self, receiving_id, box_count_in=None,
lines=None):
"""Bulk-save box_count_in (on the receiving) + per-line
received_qty/condition. Called on input blur for autosave.
`lines` is a list of {id, received_qty, condition}.
"""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
if rec.state not in ('draft', 'counted'):
return {'ok': False, 'error': (
'Receiving is %s — only Awaiting Parts / Counted are editable.'
) % rec.state}
try:
if box_count_in is not None:
rec.box_count_in = int(box_count_in or 0)
for line_dict in (lines or []):
line = env['fp.receiving.line'].browse(int(line_dict['id']))
if not line.exists() or line.receiving_id.id != rec.id:
continue # stale/foreign line id — skip silently
line.write({
'received_qty': int(line_dict.get('received_qty') or 0),
'condition': line_dict.get('condition') or 'good',
})
except Exception as exc:
_logger.exception("workspace/receiving_save_lines failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True}
@http.route('/fp/workspace/receiving_mark_counted',
type='jsonrpc', auth='user')
def receiving_mark_counted(self, receiving_id):
"""Receiver finished counting → advance state draft → counted."""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
try:
rec.action_mark_counted()
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
except Exception as exc:
_logger.exception("workspace/receiving_mark_counted failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'state': rec.state}
@http.route('/fp/workspace/receiving_close',
type='jsonrpc', auth='user')
def receiving_close(self, receiving_id):
"""Close the receiving → advance state counted → closed.
Clears the no_parts card_state on the linked job(s).
"""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
try:
rec.action_close()
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
except Exception as exc:
_logger.exception("workspace/receiving_close failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'state': rec.state}
@http.route('/fp/workspace/damage_create',
type='jsonrpc', auth='user')
def damage_create(self, receiving_id, description,
severity='cosmetic', action_required='none',
photos=None):
"""Create a fp.receiving.damage row. `photos` is a list of
{filename, data_base64}; each gets attached as ir.attachment
and added to damage.photo_ids.
"""
env = request.env
rec = env['fp.receiving'].browse(int(receiving_id))
if not rec.exists():
return {'ok': False, 'error': 'Receiving not found'}
if not description or not description.strip():
return {'ok': False, 'error': 'Description is required'}
try:
damage = env['fp.receiving.damage'].create({
'receiving_id': rec.id,
'description': description.strip(),
'severity': severity or 'cosmetic',
'action_required': action_required or 'none',
})
# Attach photos (base64 from camera / file picker). Failure
# on a single attach doesn't roll back the damage row —
# operator can re-upload via the back office form if needed.
photo_atts = env['ir.attachment']
for p in (photos or []):
try:
att = env['ir.attachment'].create({
'name': p.get('filename') or 'damage.jpg',
'datas': p.get('data_base64') or '',
'res_model': 'fp.receiving.damage',
'res_id': damage.id,
})
photo_atts |= att
except Exception:
_logger.exception(
'damage_create: photo attach failed for damage %s',
damage.id,
)
if photo_atts:
damage.photo_ids = [(6, 0, photo_atts.ids)]
except UserError as e:
return {'ok': False, 'error': str(e.args[0])}
except Exception as exc:
_logger.exception("workspace/damage_create failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True, 'damage_id': damage.id}
@http.route('/fp/workspace/damage_delete',
type='jsonrpc', auth='user')
def damage_delete(self, damage_id):
"""Remove a damage row (operator changed their mind, or logged
in error). CASCADE removes the attachment link rows; the
ir.attachment records themselves persist (might be referenced
elsewhere)."""
env = request.env
dmg = env['fp.receiving.damage'].browse(int(damage_id))
if not dmg.exists():
return {'ok': False, 'error': 'Damage row not found'}
try:
dmg.unlink()
except Exception as exc:
_logger.exception("workspace/damage_delete failed")
return {'ok': False, 'error': str(exc)}
return {'ok': True}

View File

@@ -120,39 +120,8 @@
<field name="notes" type="html"><p>Window missed — operator shift change, parts left on rack. Flagged for quality review.</p></field>
</record>
<!-- ========== FIRST PIECE GATES ========== -->
<record id="demo_fpg_1" model="fusion.plating.first.piece.gate">
<field name="bath_id" ref="fusion_plating.demo_bath_en_mp"/>
<field name="part_ref">P/N 4422-B — Hydraulic Cylinder Rod</field>
<field name="customer_ref">WO-8841</field>
<field name="routing_first_run" eval="True"/>
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="first_piece_inspected" eval="(DateTime.now() - timedelta(hours=2, minutes=30)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="result">pass</field>
<field name="rest_of_lot_released" eval="True"/>
<field name="notes" type="html"><p>Thickness: 0.0005" ± 0.0001" — within spec. Adhesion bend test passed. Lot released for full production.</p></field>
</record>
<record id="demo_fpg_2" model="fusion.plating.first.piece.gate">
<field name="bath_id" ref="fusion_plating.demo_bath_cr_hard"/>
<field name="part_ref">P/N 7810-A — Landing Gear Pin</field>
<field name="customer_ref">WO-8835</field>
<field name="routing_first_run" eval="False"/>
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(hours=4)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="result">pending</field>
<field name="rest_of_lot_released" eval="False"/>
</record>
<record id="demo_fpg_3" model="fusion.plating.first.piece.gate">
<field name="bath_id" ref="fusion_plating.demo_bath_an_typeii"/>
<field name="part_ref">P/N 3300-F — Enclosure Panel</field>
<field name="customer_ref">WO-8830</field>
<field name="routing_first_run" eval="True"/>
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(days=1, hours=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="first_piece_inspected" eval="(DateTime.now() - timedelta(days=1, hours=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="result">fail</field>
<field name="rest_of_lot_released" eval="False"/>
<field name="notes" type="html"><p>Colour variation on test coupon — dye bath concentration too low. Bath adjusted and retested before proceeding.</p></field>
</record>
<!-- First-piece gate demo records retired with the fp.first.piece.gate
model removal (19.0.33.2.0, 2026-05-25). The feature was a skeleton
(manual create, no enforcement, no job link) with 0 entech rows. -->
</odoo>

View File

@@ -14,13 +14,4 @@
<field name="company_id" eval="False"/>
</record>
<record id="seq_fp_first_piece_gate" model="ir.sequence">
<field name="name">Fusion Plating: First-Piece Gate</field>
<field name="code">fusion.plating.first.piece.gate</field>
<field name="prefix">FPG/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.33.2.0 — Drop fp.first.piece.gate model + all dependents.
The first-piece gate model was a skeleton: manual-create only, no
enforcement gate, no FK to fp.job, 0 production rows on entech after
months. Audit on 2026-05-25 concluded REMOVE.
Per Rule "Removing menus/records — Odoo does NOT auto-delete orphans":
deleting <menuitem> / <record> tags from XML does NOT remove the
corresponding DB rows. We have to explicitly drop them here.
Pre-migrate (vs post-migrate): runs BEFORE the new XML loads, so
there's no window where the loader tries to reference the deleted
model/action/view records and fails. Also runs before model __init__,
so the registry never sees the (now-removed) Python class try to
match against an existing-but-renamed DB table.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
_logger.info("Dropping fp.first.piece.gate model + all dependents")
# ---- 1. Drop the table (CASCADE wipes any inbound FKs we missed) ----
cr.execute("""
DROP TABLE IF EXISTS fusion_plating_first_piece_gate CASCADE
""")
# ---- 2. ir.model row (cascades to ir.model.fields, ir.model.access,
# and ir.model.constraint via Odoo's own FK rules) -----------
cr.execute("""
DELETE FROM ir_model
WHERE model = 'fusion.plating.first.piece.gate'
""")
# ---- 3. Orphan ir.ui.menu — the menuitem was removed from
# fp_menu.xml; without explicit delete it'd linger ----------
cr.execute("""
DELETE FROM ir_ui_menu
WHERE id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_shopfloor'
AND model = 'ir.ui.menu'
AND name = 'menu_fp_shopfloor_first_piece'
)
""")
# ---- 4. Orphan ir.actions.act_window ------------------------------
cr.execute("""
DELETE FROM ir_act_window
WHERE id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_plating_shopfloor'
AND model = 'ir.actions.act_window'
AND name = 'action_fp_first_piece_gate'
)
""")
# ---- 5. Drop the ir.sequence row + its companion postgres
# sequence (Odoo creates a real PG seq named "fusion_plating_first_piece_gate") ----
cr.execute("""
DELETE FROM ir_sequence
WHERE code = 'fusion.plating.first.piece.gate'
""")
cr.execute("""
DROP SEQUENCE IF EXISTS fusion_plating_first_piece_gate
""")
# ---- 6. Drop ALL remaining ir.model.data rows tagged for the
# retired model so noupdate=1 demo seeds, view xmlids, etc.
# don't ghost-haunt future upgrades ------------------------
cr.execute("""
DELETE FROM ir_model_data
WHERE module = 'fusion_plating_shopfloor'
AND ( name = 'menu_fp_shopfloor_first_piece'
OR name = 'action_fp_first_piece_gate'
OR name = 'seq_fp_first_piece_gate'
OR name LIKE 'view_fp_first_piece%'
OR name LIKE 'demo_fpg_%')
""")
_logger.info(
"fp.first.piece.gate removal complete: table dropped, ir.model "
"row gone, menu/action/sequence/views/demo xmlids purged."
)

View File

@@ -5,7 +5,6 @@
from . import fp_shopfloor_station
from . import fp_bake_oven
from . import fp_bake_window
from . import fp_first_piece_gate
from . import fp_operator_queue
from . import fp_tank
from . import res_users

View File

@@ -1,128 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpFirstPieceGate(models.Model):
"""First-piece inspection gate per routing.
Aerospace, nuclear and many automotive customers require that the FIRST
piece off a routing be inspected and dispositioned BEFORE the rest of
the lot is allowed to run. This model captures that gate.
"""
_name = 'fusion.plating.first.piece.gate'
_description = 'Fusion Plating — First-Piece Inspection Gate'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'first_piece_produced desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self._default_name(),
tracking=True,
)
bath_id = fields.Many2one(
'fusion.plating.bath',
string='Bath',
ondelete='restrict',
tracking=True,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
related='bath_id.facility_id',
store=True,
readonly=True,
)
part_ref = fields.Char(
string='Part Reference',
tracking=True,
)
customer_ref = fields.Char(
string='Customer Reference',
tracking=True,
)
routing_first_run = fields.Boolean(
string='First Run of Routing',
help='Tick if this is the first time this routing runs this part.',
)
first_piece_produced = fields.Datetime(
string='First Piece Produced',
tracking=True,
)
first_piece_inspected = fields.Datetime(
string='First Piece Inspected',
tracking=True,
)
inspector_id = fields.Many2one(
'res.users',
string='Inspector',
tracking=True,
)
result = fields.Selection(
[
('pending', 'Pending'),
('pass', 'Pass'),
('fail', 'Fail'),
],
string='Result',
default='pending',
required=True,
tracking=True,
)
rest_of_lot_released = fields.Boolean(
string='Rest of Lot Released',
tracking=True,
)
notes = fields.Html(
string='Notes',
)
status_color = fields.Integer(
compute='_compute_status_color',
)
active = fields.Boolean(default=True)
# ==========================================================================
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.first.piece.gate')
return seq or '/'
@api.depends('result', 'rest_of_lot_released')
def _compute_status_color(self):
for rec in self:
if rec.result == 'fail':
rec.status_color = 1 # red
elif rec.result == 'pass' and rec.rest_of_lot_released:
rec.status_color = 4 # green
elif rec.result == 'pass':
rec.status_color = 3 # yellow — passed but not released
else:
rec.status_color = 5 # purple — pending
# ==========================================================================
# Actions
# ==========================================================================
def action_mark_pass(self):
self.write({
'result': 'pass',
'first_piece_inspected': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
def action_mark_fail(self):
self.write({
'result': 'fail',
'first_piece_inspected': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
def action_release_lot(self):
self.filtered(lambda r: r.result == 'pass').write({
'rest_of_lot_released': True,
})

View File

@@ -65,20 +65,7 @@ class FpOperatorQueue(models.TransientModel):
'source_id': bw.id,
})
gate_domain = [('result', '=', 'pending')]
if facility_id:
gate_domain.append(('facility_id', '=', facility_id))
gates = self.env['fusion.plating.first.piece.gate'].search(gate_domain)
for g in gates:
rows.append({
'operator_id': user_id,
'facility_id': g.facility_id.id,
'label': f"First piece: {g.name}",
'description': f"Part {g.part_ref or '?'}",
'priority': 80,
'source_model': 'fusion.plating.first.piece.gate',
'source_id': g.id,
})
# First-piece-gate aggregation retired (19.0.33.2.0) with the model.
# ----- MRP work orders (if fusion_plating_bridge_mrp installed) -----
# Show two buckets, in this order:

View File

@@ -17,27 +17,22 @@ class FpTank(models.Model):
x_fp_shopfloor_queue_size = fields.Integer(
string='Shopfloor Queue',
compute='_compute_shopfloor_queue_size',
help='Number of bake windows + first-piece gates currently waiting on '
'this tank\'s current bath.',
help='Number of bake windows currently waiting on this tank\'s '
'current bath.',
)
def _compute_shopfloor_queue_size(self):
# First-piece-gate contribution dropped 19.0.33.2.0 with the model.
BakeWindow = self.env['fusion.plating.bake.window']
Gate = self.env['fusion.plating.first.piece.gate']
for rec in self:
bath_ids = rec.bath_ids.ids
if not bath_ids:
rec.x_fp_shopfloor_queue_size = 0
continue
count = BakeWindow.search_count([
rec.x_fp_shopfloor_queue_size = BakeWindow.search_count([
('bath_id', 'in', bath_ids),
('state', 'in', ('awaiting_bake', 'bake_in_progress')),
])
count += Gate.search_count([
('bath_id', 'in', bath_ids),
('result', '=', 'pending'),
])
rec.x_fp_shopfloor_queue_size = count
def action_open_tablet_view(self):
"""Open the tablet client action focused on this tank."""

View File

@@ -8,9 +8,6 @@ access_fp_bake_oven_manager,fp.bake.oven.manager,model_fusion_plating_bake_oven,
access_fp_bake_window_operator,fp.bake.window.operator,model_fusion_plating_bake_window,fusion_plating.group_fp_technician,1,1,1,0
access_fp_bake_window_supervisor,fp.bake.window.supervisor,model_fusion_plating_bake_window,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_bake_window_manager,fp.bake.window.manager,model_fusion_plating_bake_window,fusion_plating.group_fp_manager,1,1,1,1
access_fp_first_piece_gate_operator,fp.first.piece.gate.operator,model_fusion_plating_first_piece_gate,fusion_plating.group_fp_technician,1,1,1,0
access_fp_first_piece_gate_supervisor,fp.first.piece.gate.supervisor,model_fusion_plating_first_piece_gate,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_first_piece_gate_manager,fp.first.piece.gate.manager,model_fusion_plating_first_piece_gate,fusion_plating.group_fp_manager,1,1,1,1
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fp_technician,1,1,1,1
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
8 access_fp_bake_window_operator fp.bake.window.operator model_fusion_plating_bake_window fusion_plating.group_fp_technician 1 1 1 0
9 access_fp_bake_window_supervisor fp.bake.window.supervisor model_fusion_plating_bake_window fusion_plating.group_fp_shop_manager_v2 1 1 1 0
10 access_fp_bake_window_manager fp.bake.window.manager model_fusion_plating_bake_window fusion_plating.group_fp_manager 1 1 1 1
access_fp_first_piece_gate_operator fp.first.piece.gate.operator model_fusion_plating_first_piece_gate fusion_plating.group_fp_technician 1 1 1 0
access_fp_first_piece_gate_supervisor fp.first.piece.gate.supervisor model_fusion_plating_first_piece_gate fusion_plating.group_fp_shop_manager_v2 1 1 1 0
access_fp_first_piece_gate_manager fp.first.piece.gate.manager model_fusion_plating_first_piece_gate fusion_plating.group_fp_manager 1 1 1 1
11 access_fp_operator_queue_operator fp.operator.queue.operator model_fusion_plating_operator_queue fusion_plating.group_fp_technician 1 1 1 1
12 access_fp_operator_queue_supervisor fp.operator.queue.supervisor model_fusion_plating_operator_queue fusion_plating.group_fp_shop_manager_v2 1 1 1 1
13 access_fp_operator_queue_manager fp.operator.queue.manager model_fusion_plating_operator_queue fusion_plating.group_fp_manager 1 1 1 1

View File

@@ -0,0 +1,142 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpDamageDialog
//
// Tablet-friendly modal for logging damage during receiving. Captures:
// - Severity (Cosmetic / Functional / Rejected) — pill picker
// - Description (required textarea)
// - Action Required (None / Notify / Return / Proceed) — pill picker
// - Photos — both camera capture (capture="environment") AND file picker
//
// Wired from FpJobWorkspace via onAddDamage. POSTs to
// /fp/workspace/damage_create on Save; caller refreshes after onCreated().
//
// Spec C1+C2 2026-05-24 (receiving tablet UI).
// =============================================================================
import { Component, useState, useRef } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
const SEVERITIES = [
{ code: "cosmetic", label: "Cosmetic", cssClass: "o_fp_dmg_sev_cosmetic" },
{ code: "functional", label: "Functional", cssClass: "o_fp_dmg_sev_functional" },
{ code: "rejected", label: "Rejected", cssClass: "o_fp_dmg_sev_rejected" },
];
const ACTIONS = [
{ code: "none", label: "None" },
{ code: "notify_customer", label: "Notify Customer" },
{ code: "return_parts", label: "Return Parts" },
{ code: "proceed_as_is", label: "Proceed As-Is" },
];
export class FpDamageDialog extends Component {
static template = "fusion_plating_shopfloor.FpDamageDialog";
static components = { Dialog };
static props = {
close: Function,
receivingId: Number,
onCreated: { type: Function, optional: true },
};
setup() {
this.notification = useService("notification");
this.fileInputRef = useRef("fileInput");
this.cameraInputRef = useRef("cameraInput");
this.state = useState({
severity: "cosmetic",
description: "",
actionRequired: "none",
photos: [], // [{filename, data_base64, preview_url}]
saving: false,
});
this.severities = SEVERITIES;
this.actions = ACTIONS;
}
setSeverity(code) { this.state.severity = code; }
setAction(code) { this.state.actionRequired = code; }
triggerCamera() { this.cameraInputRef.el?.click(); }
triggerFile() { this.fileInputRef.el?.click(); }
async onFilesPicked(ev) {
const files = Array.from(ev.target.files || []);
for (const file of files) {
try {
const base64 = await this._readAsBase64(file);
this.state.photos.push({
filename: file.name || `damage_${Date.now()}.jpg`,
data_base64: base64,
preview_url: URL.createObjectURL(file),
});
} catch (err) {
this.notification.add(
`Couldn't read ${file.name}: ${err.message}`,
{ type: "danger" },
);
}
}
ev.target.value = ""; // reset so picking the same file twice re-fires
}
_readAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// strip the "data:image/jpeg;base64," prefix — backend wants raw base64
const dataUrl = reader.result || "";
const idx = String(dataUrl).indexOf(",");
resolve(idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl);
};
reader.onerror = () => reject(reader.error || new Error("read failed"));
reader.readAsDataURL(file);
});
}
removePhoto(index) {
const p = this.state.photos[index];
if (p && p.preview_url) {
try { URL.revokeObjectURL(p.preview_url); } catch { /* noop */ }
}
this.state.photos.splice(index, 1);
}
async onSave() {
if (!this.state.description.trim()) {
this.notification.add("Description is required.", { type: "warning" });
return;
}
this.state.saving = true;
try {
const res = await rpc("/fp/workspace/damage_create", {
receiving_id: this.props.receivingId,
description: this.state.description.trim(),
severity: this.state.severity,
action_required: this.state.actionRequired,
photos: this.state.photos.map((p) => ({
filename: p.filename,
data_base64: p.data_base64,
})),
});
if (res && res.ok) {
this.notification.add("Damage logged.", { type: "success" });
if (this.props.onCreated) await this.props.onCreated(res);
this.props.close();
} else {
this.notification.add(
(res && res.error) || "Save failed",
{ type: "danger" },
);
this.state.saving = false;
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
this.state.saving = false;
}
}
onCancel() { this.props.close(); }
}

View File

@@ -0,0 +1,62 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpFinishBlockDialog
//
// Shown when /fp/workspace/finish_step returns ok=false with gate='required_inputs'.
// Non-managers see: Cancel + Record Inputs.
// Managers see: Cancel + Record Inputs + ⚠ Bypass & Finish (audit).
//
// On Record Inputs → caller opens the Record Inputs dialog for the step.
// On Bypass → caller calls finish_step again with bypass_required_inputs=true.
//
// Spec: workspace step actions follow-up 2026-05-24.
// =============================================================================
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class FpFinishBlockDialog extends Component {
static template = "fusion_plating_shopfloor.FpFinishBlockDialog";
static components = { Dialog };
static props = {
close: Function,
stepName: String,
// Server-classified gate. 'required_inputs' is the only one the
// current Record/Bypass UI handles — other gates fall back to
// showing the message + a Cancel button only.
gate: String,
message: String,
missingPrompts: { type: Array, optional: true },
orphanStep: { type: Boolean, optional: true },
canBypass: { type: Boolean, optional: true },
// Callbacks
onRecordInputs: { type: Function, optional: true },
onBypass: { type: Function, optional: true },
};
setup() {
this.state = useState({ bypassing: false });
}
onCancel() { this.props.close(); }
async onRecord() {
this.props.close();
if (this.props.onRecordInputs) await this.props.onRecordInputs();
}
async onBypassClick() {
if (!window.confirm(
`Bypass required inputs and finish "${this.props.stepName}"?\n\n` +
`This will be logged to the job chatter naming you as the ` +
`bypassing manager. Use only for documented deviations.`
)) return;
this.state.bypassing = true;
try {
if (this.props.onBypass) await this.props.onBypass();
this.props.close();
} finally {
this.state.bypassing = false;
}
}
}

View File

@@ -27,11 +27,14 @@ import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog";
import { FpDamageDialog } from "./fp_damage_dialog";
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
setup() {
this.notification = useService("notification");
@@ -43,21 +46,90 @@ export class FpJobWorkspace extends Component {
data: null,
jobId: null,
focusStepId: null,
// Reactive monotonic tick — bumped every 1s by _tickInterval so
// the active-step timer re-renders without an RPC. The template
// reads tickNow and re-runs formatActiveStepElapsed each second.
tickNow: Date.now(),
});
onMounted(async () => {
const params = (this.props.action && this.props.action.params) || {};
this.state.jobId = params.job_id || null;
this.state.focusStepId = params.focus_step_id || null;
// After Hand Off + PIN relogin the action remounts without
// params (action params aren't URL-encoded), so jobId is
// null and refresh() exits early — workspace was stuck on
// "Loading Job Workspace…" indefinitely. Fall back to the
// plant kanban so the operator lands somewhere usable.
if (!this.state.jobId) {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
});
return;
}
await this.refresh();
// If load failed (job no longer accessible, server error, etc.)
// also redirect — leaving the user on a perma-loading screen
// with no recovery path is worse than dropping them at the
// kanban where they can re-enter.
if (!this.state.data) {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
});
return;
}
this._refreshInterval = setInterval(() => this.refresh(), 15000);
// 1s tick — pure client-side; no RPC. Drives the live timer
// on the active step's badge area.
this._tickInterval = setInterval(() => {
this.state.tickNow = Date.now();
}, 1000);
});
onWillUnmount(() => {
if (this._refreshInterval) clearInterval(this._refreshInterval);
if (this._tickInterval) clearInterval(this._tickInterval);
});
}
// ---- Live timer helpers (Spec 2026-05-24 follow-up) -------------------
// formatActiveStepElapsed reads step.date_started_iso (UTC string from
// the payload), parses to ms, and returns HH:MM:SS elapsed since.
// Returns '' when no start time. Re-runs every 1s via state.tickNow.
formatActiveStepElapsed(step) {
if (!step || !step.date_started_iso) return "";
// Controller now sends fp_isoformat_utc — a proper ISO-8601 with
// explicit +00:00 offset. Parse directly. (Previously appended
// "Z" to a fp_format string, which had been converted to user's
// local tz, so the timer was offset by the tz delta — 4h on EDT.)
const startedMs = Date.parse(step.date_started_iso);
if (!startedMs || isNaN(startedMs)) return "";
// touch state.tickNow so OWL re-evaluates this getter every tick
const now = this.state.tickNow || Date.now();
const totalSec = Math.max(0, Math.floor((now - startedMs) / 1000));
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = (n) => (n < 10 ? "0" + n : "" + n);
return pad(h) + ":" + pad(m) + ":" + pad(s);
}
isActiveStepOvertime(step) {
// True when elapsed > 1.5× expected duration (drives red colour).
// duration_expected is in minutes.
if (!step || !step.date_started_iso || !step.duration_expected) return false;
const startedMs = Date.parse(step.date_started_iso);
if (!startedMs || isNaN(startedMs)) return false;
const now = this.state.tickNow || Date.now();
const elapsedMin = (now - startedMs) / 1000 / 60;
return elapsedMin > step.duration_expected * 1.5;
}
// ---- Data refresh ------------------------------------------------------
async refresh() {
if (!this.state.jobId) return;
@@ -138,6 +210,79 @@ export class FpJobWorkspace extends Component {
return step.state === "in_progress";
}
// ---- Per-kind action dispatcher (Spec A 2026-05-24) -------------------
// getStepActions returns the list of action descriptors to render for a
// step based on its current state + kind. dispatchStepAction routes the
// selected action key to the right handler. See spec
// 2026-05-24-workspace-step-actions-design.md.
getStepActions(step) {
// Terminal states render no action buttons (template also guards).
if (["done", "skipped", "cancelled"].includes(step.state)) return [];
// Blocked / opt-out steps show GateViz / notice, no actions.
if (step.blocker_kind && step.blocker_kind !== "none") return [];
if (step.override_excluded) return [];
const actions = [];
if (step.state === "in_progress") {
actions.push({ key: "record_inputs", label: "Record Inputs",
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
actions.push({ key: "pause", label: "Pause",
icon: "fa fa-pause", cssClass: "btn btn-light" });
actions.push({
key: "finish",
label: step.requires_signoff ? "Finish & Sign Off" : "Finish",
icon: "fa fa-check", cssClass: "btn btn-success",
});
return actions;
}
if (step.state === "paused") {
actions.push({ key: "resume", label: "Resume",
icon: "fa fa-play", cssClass: "btn btn-primary" });
actions.push({ key: "record_inputs", label: "Record Inputs",
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
actions.push({
key: "finish",
label: step.requires_signoff ? "Finish & Sign Off" : "Finish",
icon: "fa fa-check", cssClass: "btn btn-success",
});
return actions;
}
// state in ('pending', 'ready') — entry-point per kind.
if (step.kind === "contract_review") {
actions.push({ key: "open_contract_review", label: "Open QA-005 Form",
icon: "fa fa-file-text-o", cssClass: "btn btn-primary" });
return actions;
}
if (step.kind === "gating") {
actions.push({ key: "mark_passed", label: "Mark Passed",
icon: "fa fa-check-circle", cssClass: "btn btn-success" });
return actions;
}
if (step.requires_rack_assignment) {
actions.push({ key: "start_with_rack", label: "Start (Assign Rack)",
icon: "fa fa-server", cssClass: "btn btn-primary" });
return actions;
}
// Default — plain Start
actions.push({ key: "start", label: "Start",
icon: "fa fa-play", cssClass: "btn btn-primary" });
return actions;
}
async dispatchStepAction(step, key) {
switch (key) {
case "start": return this.onStartStep(step.id);
case "resume": return this.onResumeStep(step);
case "pause": return this.onPauseStep(step);
case "record_inputs": return this.onRecordInputs(step);
case "finish": return this.onFinishStep(step);
case "mark_passed": return this.onMarkPassed(step);
case "open_contract_review": return this.onOpenContractReview(step);
case "start_with_rack": return this.onStartWithRack(step);
}
}
// ---- Step actions ------------------------------------------------------
async onStartStep(stepId) {
try {
@@ -198,16 +343,224 @@ export class FpJobWorkspace extends Component {
});
return;
}
// Plain finish — no signature required
// Plain finish — route through /fp/workspace/finish_step which
// returns structured errors so we can show the FpFinishBlockDialog
// for required-inputs failures (with manager bypass option).
await this._callFinishStep(step, /* bypass */ false);
}
async _callFinishStep(step, bypassRequiredInputs) {
try {
const res = await fpRpc("/fp/shopfloor/stop_wo", {
workorder_id: step.id, finish: true,
const res = await rpc("/fp/workspace/finish_step", {
step_id: step.id,
bypass_required_inputs: !!bypassRequiredInputs,
});
if (res && res.ok) {
this.notification.add("Step finished.", { type: "success" });
this.notification.add(
bypassRequiredInputs
? "Step finished (required-inputs bypassed; audit logged)."
: "Step finished.",
{ type: "success" },
);
await this.refresh();
return;
}
// Structured-error branch — show block dialog for the
// required_inputs gate (it has the rich Record / Bypass UX).
// Other gates fall back to a plain notification.
if (res && res.gate === "required_inputs") {
this.dialog.add(FpFinishBlockDialog, {
stepName: step.name,
gate: res.gate,
message: res.error || "Cannot finish step.",
missingPrompts: res.missing_prompts || [],
orphanStep: !!res.orphan_step,
canBypass: !!res.bypass_available,
onRecordInputs: async () => this.onRecordInputs(step),
onBypass: async () => this._callFinishStep(step, true),
});
return;
}
this.notification.add(
(res && res.error) || "Finish failed",
{ type: "danger" },
);
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
}
}
// ---- New per-kind handlers (Spec A 2026-05-24) ------------------------
async onPauseStep(step) {
const reason = window.prompt(`Pause reason for "${step.name}"?`, "");
if (reason === null) return; // operator cancelled
try {
await rpc("/web/dataset/call_kw", {
model: "fp.job.step", method: "button_pause",
args: [[step.id]],
kwargs: { reason: reason || "no reason given" },
});
this.notification.add("Step paused.", { type: "success" });
await this.refresh();
} catch (err) {
this.notification.add(err.message || "Pause failed", { type: "danger" });
}
}
async onResumeStep(step) {
try {
await rpc("/web/dataset/call_kw", {
model: "fp.job.step", method: "button_resume",
args: [[step.id]], kwargs: {},
});
this.notification.add("Step resumed.", { type: "success" });
await this.refresh();
} catch (err) {
this.notification.add(err.message || "Resume failed", { type: "danger" });
}
}
async onMarkPassed(step) {
try {
await rpc("/web/dataset/call_kw", {
model: "fp.job.step", method: "action_mark_gating_passed",
args: [[step.id]], kwargs: {},
});
this.notification.add("Gate marked passed.", { type: "success" });
await this.refresh();
} catch (err) {
this.notification.add(err.message || "Mark passed failed", { type: "danger" });
}
}
async onOpenContractReview(step) {
try {
const result = await rpc("/web/dataset/call_kw", {
model: "fp.job.step", method: "_fp_open_contract_review",
args: [[step.id]], kwargs: {},
});
if (result) {
await this.action.doAction(result, { onClose: () => this.refresh() });
}
} catch (err) {
this.notification.add(err.message || "Couldn't open QA-005", { type: "danger" });
}
}
async onStartWithRack(step) {
const job = this.state.data.job;
this.dialog.add(FpRackPartsDialog, {
jobId: this.state.jobId,
stepId: step.id,
partRef: job.part_number || "",
defaultQty: job.qty || 1,
onCommitted: async () => {
// Rack assigned → now start the step
await this.onStartStep(step.id);
},
});
}
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
// The receiver card at the top of the workspace lets the dock receiver
// count boxes, set per-line received quantities + condition, log damage
// (with camera photos), and transition draft → counted → closed. Once
// closed, the linked job's no_parts gate clears + the card disappears.
async onReceivingBoxCountBlur(rcv, ev) {
const newVal = parseInt(ev.target.value, 10) || 0;
if (newVal === (rcv.box_count_in || 0)) return;
rcv.box_count_in = newVal;
try {
await rpc("/fp/workspace/receiving_save_lines", {
receiving_id: rcv.id, box_count_in: newVal, lines: [],
});
} catch (err) {
this.notification.add(err.message || "Save failed", { type: "danger" });
}
}
async onReceivingLineQtyBlur(rcv, ln, ev) {
const newVal = parseInt(ev.target.value, 10) || 0;
if (newVal === (ln.received_qty || 0)) return;
ln.received_qty = newVal;
await this._saveReceivingLine(rcv, ln);
}
async onReceivingLineCondChange(rcv, ln, ev) {
ln.condition = ev.target.value || "good";
await this._saveReceivingLine(rcv, ln);
}
async _saveReceivingLine(rcv, ln) {
try {
await rpc("/fp/workspace/receiving_save_lines", {
receiving_id: rcv.id,
lines: [{ id: ln.id, received_qty: ln.received_qty, condition: ln.condition }],
});
} catch (err) {
this.notification.add(err.message || "Save failed", { type: "danger" });
}
}
async onReceivingMarkCounted(rcv) {
try {
const res = await rpc("/fp/workspace/receiving_mark_counted", {
receiving_id: rcv.id,
});
if (res && res.ok) {
this.notification.add(`Receiving ${rcv.name} marked counted.`, { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Finish failed", { type: "danger" });
this.notification.add((res && res.error) || "Mark counted failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
async onReceivingClose(rcv) {
// No confirmation — Mark Counted is already a deliberate prior
// step, and the native browser confirm() popup looks out of place
// on the tablet UI. If a receiver hits Close prematurely, an
// admin can reset via fp.receiving.action_reset_to_counted from
// the back office.
try {
const res = await rpc("/fp/workspace/receiving_close", {
receiving_id: rcv.id,
});
if (res && res.ok) {
this.notification.add(`Receiving ${rcv.name} closed.`, { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Close failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
onAddDamage(rcv) {
this.dialog.add(FpDamageDialog, {
receivingId: rcv.id,
onCreated: () => this.refresh(),
});
}
async onDeleteDamage(rcv, dmg) {
if (!window.confirm(`Remove ${dmg.severity_label} damage: "${dmg.description}"?`)) {
return;
}
try {
const res = await rpc("/fp/workspace/damage_delete", {
damage_id: dmg.id,
});
if (res && res.ok) {
this.notification.add("Damage entry removed.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Delete failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });

View File

@@ -196,7 +196,12 @@ export class PlantOverview extends Component {
this.state.facilityName = result.facility_name || "Plant 1";
this.state.columns = result.columns || [];
this.state.racks = result.racks || [];
this.state.lastRefresh = new Date().toLocaleTimeString();
// Prefer the server-formatted timestamp — it's in the
// FP-configured tz (fp_format → user.tz → company tz).
// Browser tz fallback only kicks in if the endpoint
// drops the field for some reason.
this.state.lastRefresh = result.server_time
|| new Date().toLocaleTimeString();
}
} catch (err) {
this.notification.add(

View File

@@ -239,16 +239,8 @@ export class ShopfloorTablet extends Component {
await this.refresh();
}
async onGateResult(gateId, result) {
try {
await rpc("/fp/shopfloor/mark_gate", { gate_id: gateId, result });
this.setMessage(`First-piece marked ${result.toUpperCase()}.`,
result === "pass" ? "success" : "danger");
} catch (err) {
this.setMessage(`Gate update failed: ${err.message || err}`, "danger");
}
await this.refresh();
}
// onGateResult / /fp/shopfloor/mark_gate retired with the
// fp.first.piece.gate model removal (19.0.33.2.0).
async onQueueItemClick(row) {
if (row.source_model && row.source_id) {

View File

@@ -45,10 +45,17 @@ export class FpTabletLock extends Component {
loadingTiles: false,
// 2026-05-24 redesign — clock + company branding
// Seeded synchronously so the first render shows real values
// (no flash of empty content).
clockText: this._formatTime(new Date()),
dateText: this._formatDate(new Date()),
// (no flash of empty content). tz=null on first render falls
// back to browser tz; _loadTiles() then sets state.tz from
// res.tz_name (FP regional setting) and the next 60s tick
// recomputes with the right tz.
clockText: this._formatTime(new Date(), null),
dateText: this._formatDate(new Date(), null),
company: null,
// FP-configured tz from /fp/tablet/tiles. Set after _loadTiles
// so all subsequent clock ticks render in the shop's tz, not
// the iPad's system tz.
tz: null,
// Kiosk identity from bootstrap so we can tell when the
// current browser session belongs to a tech (= unlocked) vs.
// the kiosk (= locked).
@@ -72,8 +79,8 @@ export class FpTabletLock extends Component {
// 60s is enough; the displayed precision is minute-level only.
this._clockInterval = setInterval(() => {
const now = new Date();
this.state.clockText = this._formatTime(now);
this.state.dateText = this._formatDate(now);
this.state.clockText = this._formatTime(now, this.state.tz);
this.state.dateText = this._formatDate(now, this.state.tz);
}, 60000);
// If we're already on a TECH session (uid != kiosk), start
// the idle/ceiling timer immediately. This handles the case
@@ -109,6 +116,13 @@ export class FpTabletLock extends Component {
this.state.company = res.company || null;
this.state.kioskUid = res.kiosk_uid || null;
this.state.currentUid = res.current_uid || null;
this.state.tz = res.tz_name || null;
// Re-render the clock immediately with the FP tz so the
// user doesn't see browser-tz for up to 60s before the
// next tick.
const now = new Date();
this.state.clockText = this._formatTime(now, this.state.tz);
this.state.dateText = this._formatDate(now, this.state.tz);
// Decorate each tile with an animation-delay (50ms staggered,
// capped at 300ms so the screen doesn't take 3s to settle on
// shops with 20+ operators).
@@ -188,23 +202,27 @@ export class FpTabletLock extends Component {
// === 2026-05-24 redesign helpers =====================================
_formatTime(d) {
// 12-hour H:MM AM/PM. Per project rule 20 this MUST live in JS,
// not the template — padStart isn't in OWL scope. Hour is NOT
// zero-padded (1:05 PM, not 01:05 PM) to match phone-clock idiom.
let h = d.getHours();
const meridiem = h >= 12 ? "PM" : "AM";
h = h % 12 || 12; // 0 → 12
const mm = String(d.getMinutes()).padStart(2, "0");
return h + ":" + mm + " " + meridiem;
_formatTime(d, tz) {
// 12-hour H:MM AM/PM in the FP-configured tz (falls back to
// browser tz if tz is null — first paint before _loadTiles).
// Per project rule 20 this MUST live in JS, not the template.
// Hour is NOT zero-padded (1:05 PM, not 01:05 PM) to match
// phone-clock idiom.
const opts = { hour: "numeric", minute: "2-digit", hour12: true };
if (tz) opts.timeZone = tz;
// en-US picks 12h-with-AM/PM reliably; locale-default could vary.
return new Intl.DateTimeFormat("en-US", opts).format(d);
}
_formatDate(d) {
// 'SATURDAY · MAY 23' style. Uses Intl for locale-correct weekday
// + month abbreviations, then upcases for the industrial tracking.
const weekday = d.toLocaleDateString(undefined, { weekday: "long" });
const month = d.toLocaleDateString(undefined, { month: "short" });
const day = d.getDate();
_formatDate(d, tz) {
// 'SATURDAY · MAY 23' style in the FP-configured tz.
const wkOpts = { weekday: "long" };
const moOpts = { month: "short" };
const dyOpts = { day: "numeric" };
if (tz) { wkOpts.timeZone = moOpts.timeZone = dyOpts.timeZone = tz; }
const weekday = new Intl.DateTimeFormat(undefined, wkOpts).format(d);
const month = new Intl.DateTimeFormat(undefined, moOpts).format(d);
const day = new Intl.DateTimeFormat(undefined, dyOpts).format(d);
return (weekday + " · " + month + " " + day).toUpperCase();
}

View File

@@ -1,19 +1,21 @@
// _column_header.scss — depends on _plant_tokens.scss
.o_fp_col_header {
padding: 6px 8px;
padding: 8px 10px;
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;
// No own background or outer border — the parent .col carries them
// (full-height column-as-card layout). Just a bottom divider so the
// header reads as a distinct band above the scrollable body.
background: transparent;
border-bottom: 1px solid $plant-card-border;
flex: 0 0 auto;
&.mine {
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
border-color: $plant-mine-border;
// .col already paints the mine gradient + gold border.
border-bottom-color: $plant-mine-border;
}
.col-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }

View File

@@ -317,3 +317,393 @@ $_ws-text-hex: #1d1d1f;
align-items: center;
flex-wrap: wrap;
}
// =============================================================================
// PRE-RECIPE RECEIVING CARD (Spec C1+C2 2026-05-24)
// =============================================================================
.o_fp_ws_rcv {
background: $_ws-card-hex;
border: 2px solid #f1c40f; // amber — draws receiver's eye
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
&.o_fp_ws_rcv_counted {
border-color: #27ae60; // green when counted
}
}
.o_fp_ws_rcv_head {
display: flex;
align-items: center;
gap: 0.6rem;
border-bottom: 1px solid $_ws-border-hex;
padding-bottom: 0.5rem;
}
.o_fp_ws_rcv_icon {
font-size: 1.4rem;
}
.o_fp_ws_rcv_title {
font-size: 1.1rem;
font-weight: 600;
flex: 1;
}
.o_fp_ws_rcv_status {
padding: 0.2rem 0.6rem;
border-radius: 4px;
background: #fef3c7;
color: #78350f;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
.o_fp_ws_rcv_counted & {
background: #d1fae5;
color: #064e3b;
}
}
.o_fp_ws_rcv_field {
display: flex;
flex-direction: column;
gap: 0.3rem;
label {
font-weight: 600;
color: var(--text-secondary, #555);
font-size: 0.9rem;
}
}
.o_fp_ws_rcv_box_input {
font-size: 1.5rem;
font-weight: 600;
max-width: 12rem;
text-align: center;
}
.o_fp_ws_rcv_lines {
background: $_ws-page-hex;
border-radius: 6px;
padding: 0.6rem;
}
.o_fp_ws_rcv_lines_head {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary, #666);
margin-bottom: 0.5rem;
letter-spacing: 0.05em;
}
.o_fp_ws_rcv_line {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
gap: 1rem;
border-bottom: 1px solid $_ws-border-hex;
&:last-child { border-bottom: 0; }
}
.o_fp_ws_rcv_line_part {
flex: 1;
}
.o_fp_ws_rcv_line_qty {
display: flex;
align-items: center;
gap: 0.6rem;
label {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.9rem;
margin: 0;
}
}
.o_fp_ws_rcv_qty_input {
width: 6rem;
text-align: center;
font-weight: 600;
}
.o_fp_ws_rcv_cond_select {
width: 8rem;
}
.o_fp_ws_rcv_summary {
background: $_ws-page-hex;
border-radius: 6px;
padding: 0.8rem;
font-size: 1rem;
}
.o_fp_ws_rcv_summary_parts {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.o_fp_ws_rcv_damage_section {
border-top: 1px dashed $_ws-border-hex;
padding-top: 0.6rem;
}
.o_fp_ws_rcv_damage_head {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #666);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
.o_fp_ws_rcv_damage_empty {
font-style: italic;
font-size: 0.9rem;
padding: 0.4rem 0;
}
.o_fp_ws_rcv_damage {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.8rem;
background: $_ws-page-hex;
border-left: 4px solid #fbbf24;
border-radius: 4px;
margin-bottom: 0.4rem;
font-size: 0.95rem;
&.o_fp_ws_rcv_damage_functional {
border-left-color: #f97316;
}
&.o_fp_ws_rcv_damage_rejected {
border-left-color: #dc2626;
background: rgba(220, 38, 38, 0.05);
}
}
.o_fp_ws_rcv_damage_sev {
font-weight: 700;
text-transform: uppercase;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: rgba(0,0,0,0.06);
border-radius: 3px;
}
.o_fp_ws_rcv_damage_desc {
flex: 1;
}
.o_fp_ws_rcv_damage_photos {
color: var(--text-secondary, #666);
font-size: 0.85rem;
}
.o_fp_ws_rcv_damage_x {
border: 0;
background: transparent;
color: #aaa;
cursor: pointer;
padding: 0.2rem 0.4rem;
&:hover { color: #dc2626; }
}
.o_fp_ws_rcv_actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding-top: 0.4rem;
.btn { font-size: 1rem; padding: 0.5rem 1.2rem; }
}
// =============================================================================
// DAMAGE DIALOG (Spec C1+C2 2026-05-24)
// =============================================================================
.o_fp_dmg_dialog .modal-body { padding: 1.5rem; }
.o_fp_dmg_body {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; }
.o_fp_dmg_label { font-weight: 600; color: var(--text-secondary, #555); }
.o_fp_dmg_req { color: #dc2626; }
.o_fp_dmg_pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.o_fp_dmg_pill {
background: $_ws-page-hex;
border: 2px solid $_ws-border-hex;
color: $_ws-text-hex;
padding: 0.6rem 1.1rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 80ms, border-color 80ms;
&:hover { background: rgba(0,0,0,0.04); }
&.o_fp_dmg_pill_active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
&.o_fp_dmg_sev_functional.o_fp_dmg_pill_active {
background: #f97316;
border-color: #f97316;
}
&.o_fp_dmg_sev_rejected.o_fp_dmg_pill_active {
background: #dc2626;
border-color: #dc2626;
}
}
.o_fp_dmg_textarea { font-size: 1rem; }
.o_fp_dmg_photo_buttons {
display: flex;
gap: 0.6rem;
.btn { padding: 0.6rem 1rem; }
}
.o_fp_dmg_photo_grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.6rem;
}
.o_fp_dmg_photo_tile {
position: relative;
width: 110px;
height: 110px;
border: 1px solid $_ws-border-hex;
border-radius: 6px;
overflow: hidden;
background: $_ws-page-hex;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.o_fp_dmg_photo_x {
position: absolute;
top: 4px;
right: 4px;
border: 0;
background: rgba(0,0,0,0.6);
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
// =============================================================================
// LIVE TIMER on active step (2026-05-24 follow-up)
// Ticks every 1s via state.tickNow; flips red when > 1.5x expected duration.
// =============================================================================
.o_fp_ws_step_timer {
display: inline-flex;
align-items: center;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-weight: 700;
font-variant-numeric: tabular-nums;
background: #d1fae5;
color: #064e3b;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.95rem;
margin-left: 0.4rem;
letter-spacing: 0.02em;
}
.o_fp_ws_step_timer_over {
background: #fee2e2;
color: #7f1d1d;
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
}
@keyframes o_fp_ws_timer_pulse {
0%, 100% { opacity: 1.0; }
50% { opacity: 0.6; }
}
// =============================================================================
// FINISH BLOCK DIALOG (required-inputs gate, manager bypass)
// =============================================================================
.o_fp_finish_block .modal-body { padding: 1.5rem; }
.o_fp_finish_block_body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.o_fp_finish_block_step {
font-size: 1.1rem;
color: #b45309;
background: #fef3c7;
padding: 0.7rem 1rem;
border-radius: 6px;
border-left: 4px solid #f59e0b;
}
.o_fp_finish_block_msg {
color: var(--text-secondary, #333);
}
.o_fp_finish_block_list {
margin: 0;
padding: 0 0 0 1rem;
list-style: none;
li {
padding: 0.3rem 0;
font-weight: 600;
}
}
.o_fp_finish_block_action_note {
color: var(--text-secondary, #555);
font-style: italic;
padding: 0.6rem 0.8rem;
background: #f3f4f6;
border-radius: 4px;
}

View File

@@ -3,7 +3,13 @@
.o_fp_plant_kanban {
padding: 8px;
background: $plant-bg;
min-height: 100vh;
// Full viewport height + flex column so .board can grow to fill all
// remaining vertical space. min-height: 100vh would let .board's
// intrinsic height bubble up and put the horizontal scrollbar
// mid-page; height + flex pins the scrollbar to the viewport bottom.
height: 100vh;
display: flex;
flex-direction: column;
color: $plant-text;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -14,9 +20,8 @@
padding: 8px 12px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
position: sticky;
top: 0;
z-index: 10;
// Keep header at natural height; board takes remaining space.
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
@@ -97,35 +102,49 @@
// (~280px) sits comfortably with breathing room. On a 1920px
// desktop ~6 columns visible; on a 1366px tablet ~4 visible with
// smooth horizontal scroll. User explicitly accepted side-scrolling.
//
// flex: 1 + min-height: 0 — the board fills all remaining vertical
// space below the sticky header, so the horizontal scrollbar pins
// to the viewport bottom (not mid-page where the board's natural
// height would have ended).
.board {
display: grid;
grid-template-columns: repeat(9, minmax(300px, 1fr));
gap: 8px;
min-height: 520px;
flex: 1 1 auto;
min-height: 0;
overflow-x: auto;
padding-bottom: 8px; // room for the horizontal scrollbar
padding-bottom: 4px; // room for the horizontal scrollbar
}
// Each .col is now a proper bordered card that runs full board
// height — same visual treatment as Trello / Asana columns. The
// header (.o_fp_col_header) sits at the top with a divider; the
// scrollable body (.col-scroll) takes the rest. Previously .col
// had bg = page-bg (invisible) so columns visually ended at the
// header card — empty columns looked unbounded.
.col {
background: $plant-bg;
background: $plant-card-bg;
border: 1px solid $plant-card-border;
border-radius: 8px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0; // grid-track minmax handles sizing
min-width: 0; // grid-track minmax handles sizing
min-height: 0; // allow flex children to scroll inside
overflow: hidden; // contain rounded corners over inner content
&.mine {
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
border: 1px solid $plant-mine-border;
border-color: $plant-mine-border;
}
}
.col-scroll {
flex: 1 1 auto;
min-height: 0; // critical — without this the children
// push the column past its parent height
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 2px;
max-height: calc(100vh - 260px);
min-height: 200px;
padding: 6px;
}
.col-empty {
font-size: 10px;

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.FpDamageDialog">
<Dialog title="'Log Damage'" size="'lg'" contentClass="'o_fp_dmg_dialog'">
<div class="o_fp_dmg_body">
<!-- Severity pill picker -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Severity</label>
<div class="o_fp_dmg_pills">
<t t-foreach="severities" t-as="sv" t-key="sv.code">
<button type="button"
t-att-class="'o_fp_dmg_pill ' + sv.cssClass +
(state.severity === sv.code ? ' o_fp_dmg_pill_active' : '')"
t-on-click="() => this.setSeverity(sv.code)">
<t t-esc="sv.label"/>
</button>
</t>
</div>
</div>
<!-- Description textarea -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Description <span class="o_fp_dmg_req">*</span></label>
<textarea class="form-control o_fp_dmg_textarea"
rows="3"
placeholder="e.g. dent on top box, two parts crushed"
t-model="state.description"/>
</div>
<!-- Action required pill picker -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Action Required</label>
<div class="o_fp_dmg_pills">
<t t-foreach="actions" t-as="ac" t-key="ac.code">
<button type="button"
t-att-class="'o_fp_dmg_pill ' +
(state.actionRequired === ac.code ? 'o_fp_dmg_pill_active' : '')"
t-on-click="() => this.setAction(ac.code)">
<t t-esc="ac.label"/>
</button>
</t>
</div>
</div>
<!-- Photos: camera capture + file picker -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Photos</label>
<div class="o_fp_dmg_photo_buttons">
<button type="button" class="btn btn-primary"
t-on-click="() => this.triggerCamera()">
<i class="fa fa-camera me-1"/> Take Photo
</button>
<button type="button" class="btn btn-light"
t-on-click="() => this.triggerFile()">
<i class="fa fa-upload me-1"/> Upload
</button>
<!-- Hidden inputs -->
<input type="file"
accept="image/*"
capture="environment"
t-ref="cameraInput"
style="display: none"
t-on-change="onFilesPicked"/>
<input type="file"
accept="image/*"
multiple="multiple"
t-ref="fileInput"
style="display: none"
t-on-change="onFilesPicked"/>
</div>
<div t-if="state.photos.length" class="o_fp_dmg_photo_grid">
<t t-foreach="state.photos" t-as="p" t-key="p_index">
<div class="o_fp_dmg_photo_tile">
<img t-att-src="p.preview_url" t-att-alt="p.filename"/>
<button type="button" class="o_fp_dmg_photo_x"
t-on-click="() => this.removePhoto(p_index)">
<i class="fa fa-times"/>
</button>
</div>
</t>
</div>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-light"
t-on-click="() => this.onCancel()"
t-att-disabled="state.saving">Cancel</button>
<button class="btn btn-success"
t-on-click="() => this.onSave()"
t-att-disabled="state.saving">
<i class="fa fa-check me-1"/>
<t t-if="state.saving">Saving…</t>
<t t-else="">Save Damage</t>
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.FpFinishBlockDialog">
<Dialog title="'Cannot Finish Step'" size="'md'" contentClass="'o_fp_finish_block'">
<div class="o_fp_finish_block_body">
<div class="o_fp_finish_block_step">
<i class="fa fa-exclamation-circle me-2"/>
<strong t-esc="props.stepName"/>
</div>
<!-- Orphan step (NULL recipe link) — different copy -->
<t t-if="props.orphanStep">
<div class="o_fp_finish_block_msg">
This step has <strong>no recipe link</strong> (the source
recipe was deleted or the job was created before recipes
were assigned). Required-input verification can't happen
without the recipe.
</div>
<div class="o_fp_finish_block_action_note">
<i class="fa fa-user-md me-1"/>
Escalate to a manager — they can bypass with an
audit-chatter entry.
</div>
</t>
<!-- Standard missing-prompts case -->
<t t-elif="props.missingPrompts and props.missingPrompts.length">
<div class="o_fp_finish_block_msg">
<t t-esc="props.missingPrompts.length"/> required input(s)
haven't been recorded yet:
</div>
<ul class="o_fp_finish_block_list">
<t t-foreach="props.missingPrompts" t-as="p" t-key="p.id">
<li>
<i class="fa fa-square-o me-1"/>
<t t-esc="p.name"/>
</li>
</t>
</ul>
</t>
<!-- Fallback: server returned something else -->
<t t-else="">
<div class="o_fp_finish_block_msg" t-esc="props.message"/>
</t>
</div>
<t t-set-slot="footer">
<button class="btn btn-light"
t-on-click="() => this.onCancel()"
t-att-disabled="state.bypassing">Cancel</button>
<button t-if="!props.orphanStep and props.onRecordInputs"
class="btn btn-primary"
t-on-click="() => this.onRecord()"
t-att-disabled="state.bypassing">
<i class="fa fa-pencil me-1"/> Record Inputs
</button>
<button t-if="props.canBypass"
class="btn btn-danger"
t-on-click="() => this.onBypassClick()"
t-att-disabled="state.bypassing">
<i class="fa fa-exclamation-triangle me-1"/>
<t t-if="state.bypassing">Bypassing…</t>
<t t-else="">Bypass &amp; Finish (Audit)</t>
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -79,6 +79,140 @@
<!-- STEP LIST -->
<div class="o_fp_ws_steps">
<!-- PRE-RECIPE: RECEIVING CARDS (Spec C1+C2 2026-05-24)
Renders one card per fp.receiving in state
draft/counted on the linked SO. Card disappears
once receiving is closed; recipe takes over. -->
<t t-foreach="state.data.receivings || []"
t-as="rcv" t-key="rcv.id">
<div t-att-class="'o_fp_ws_rcv o_fp_ws_rcv_' + rcv.state">
<div class="o_fp_ws_rcv_head">
<span class="o_fp_ws_rcv_icon">📦</span>
<span class="o_fp_ws_rcv_title">
Receiving <t t-esc="rcv.name"/>
</span>
<span class="o_fp_ws_rcv_status"
t-esc="rcv.state_label"/>
</div>
<!-- DRAFT mode: editable inputs -->
<t t-if="rcv.state === 'draft'">
<div class="o_fp_ws_rcv_field">
<label>Boxes received</label>
<input type="number"
class="form-control o_fp_ws_rcv_box_input"
inputmode="numeric"
t-att-value="rcv.box_count_in || ''"
t-on-blur="(ev) => this.onReceivingBoxCountBlur(rcv, ev)"/>
</div>
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_lines">
<div class="o_fp_ws_rcv_lines_head">PARTS</div>
<t t-foreach="rcv.lines" t-as="ln" t-key="ln.id">
<div class="o_fp_ws_rcv_line">
<div class="o_fp_ws_rcv_line_part">
<strong t-esc="ln.part_number or 'Part'"/>
<span t-if="ln.description" class="text-muted">
<t t-esc="ln.description"/>
</span>
</div>
<div class="o_fp_ws_rcv_line_qty">
<span class="text-muted">Expected: <t t-esc="ln.expected_qty"/></span>
<label>Received
<input type="number"
class="form-control o_fp_ws_rcv_qty_input"
inputmode="numeric"
t-att-value="ln.received_qty || ''"
t-on-blur="(ev) => this.onReceivingLineQtyBlur(rcv, ln, ev)"/>
</label>
<select class="form-select o_fp_ws_rcv_cond_select"
t-on-change="(ev) => this.onReceivingLineCondChange(rcv, ln, ev)">
<option value="good" t-att-selected="ln.condition === 'good'">Good</option>
<option value="damaged" t-att-selected="ln.condition === 'damaged'">Damaged</option>
<option value="mixed" t-att-selected="ln.condition === 'mixed'">Mixed</option>
</select>
</div>
</div>
</t>
</div>
</t>
<!-- COUNTED mode: read-only summary -->
<t t-if="rcv.state === 'counted'">
<div class="o_fp_ws_rcv_summary">
<div>
<strong t-esc="rcv.box_count_in"/> box(es) counted
<t t-if="rcv.received_date">
at <t t-esc="rcv.received_date"/>
</t>
<t t-if="rcv.received_by_name">
by <t t-esc="rcv.received_by_name"/>
</t>
</div>
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_summary_parts">
<t t-foreach="rcv.lines" t-as="ln" t-key="ln.id">
<span class="o_fp_chip o_fp_chip_info">
<t t-esc="ln.part_number"/>: <t t-esc="ln.received_qty"/> / <t t-esc="ln.expected_qty"/>
</span>
</t>
</div>
</div>
</t>
<!-- DAMAGE LOG (visible in both draft + counted) -->
<div class="o_fp_ws_rcv_damage_section">
<div class="o_fp_ws_rcv_damage_head">
DAMAGE LOG
<button type="button"
class="btn btn-sm btn-light ms-2"
t-on-click="() => this.onAddDamage(rcv)">
<i class="fa fa-plus"/> Add Damage
</button>
</div>
<div t-if="!rcv.damages.length"
class="o_fp_ws_rcv_damage_empty text-muted">
No damage logged.
</div>
<t t-foreach="rcv.damages" t-as="dmg" t-key="dmg.id">
<div t-att-class="'o_fp_ws_rcv_damage o_fp_ws_rcv_damage_' + dmg.severity">
<span class="o_fp_ws_rcv_damage_sev"
t-esc="dmg.severity_label"/>
<span class="o_fp_ws_rcv_damage_desc"
t-esc="dmg.description"/>
<span t-if="dmg.action_required and dmg.action_required !== 'none'"
class="o_fp_chip o_fp_chip_warning"
t-esc="dmg.action_label"/>
<span t-if="dmg.photo_count"
class="o_fp_ws_rcv_damage_photos">
<i class="fa fa-camera"/> <t t-esc="dmg.photo_count"/>
</span>
<button type="button"
class="o_fp_ws_rcv_damage_x"
t-on-click="() => this.onDeleteDamage(rcv, dmg)"
title="Remove damage entry">
<i class="fa fa-times"/>
</button>
</div>
</t>
</div>
<!-- Footer: state-transition button -->
<div class="o_fp_ws_rcv_actions">
<button t-if="rcv.state === 'draft'"
class="btn btn-primary"
t-on-click="() => this.onReceivingMarkCounted(rcv)">
<i class="fa fa-check"/> Mark Counted
</button>
<button t-if="rcv.state === 'counted'"
class="btn btn-success"
t-on-click="() => this.onReceivingClose(rcv)">
<i class="fa fa-check-circle"/> Close Receiving
</button>
</div>
</div>
</t>
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
<i class="fa fa-exclamation-circle fa-2x"/>
<div>Recipe not generated for this WO.</div>
@@ -86,26 +220,39 @@
<t t-foreach="state.data.steps" t-as="step" t-key="step.id">
<div t-att-class="'o_fp_ws_step ' + step.state +
(isStepActive(step) ? ' active' : '') +
(step.state === 'in_progress' ? ' active' : '') +
(step.state === 'paused' ? ' paused' : '') +
(step.override_excluded ? ' excluded' : '') +
(step.blocker_kind !== 'none' ? ' blocked' : '')"
t-att-data-step-id="step.id">
<!-- ALWAYS visible: line 1 (icon + step# + name + badges + live timer + meta) -->
<div class="o_fp_ws_step_l1">
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
<span class="o_fp_ws_step_name" t-esc="step.name"/>
<span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
<!-- Live ticking HH:MM:SS timer for in_progress steps.
Re-renders every 1s via state.tickNow.
Flips red when > 1.5x expected duration. -->
<span t-if="step.state === 'in_progress' and step.date_started_iso"
t-att-class="'o_fp_ws_step_timer' + (isActiveStepOvertime(step) ? ' o_fp_ws_step_timer_over' : '')">
<i class="fa fa-clock-o me-1"/>
<t t-esc="formatActiveStepElapsed(step)"/>
</span>
<span class="o_fp_ws_step_meta">
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
</span>
</div>
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
<!-- NON-TERMINAL: read-ahead detail (chips + instructions + opt-out + GateViz) -->
<t t-if="!['done', 'skipped', 'cancelled'].includes(step.state)">
<div class="o_fp_ws_step_detail">
<!-- Recipe chips (only on active step) -->
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
<!-- Recipe chips: visible on every non-done step so operator reads ahead -->
<div class="o_fp_ws_step_chips"
t-if="step.thickness_target or step.dwell_time_minutes or step.bake_setpoint_temp or step.requires_signoff or step.requires_rack_assignment">
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
</span>
@@ -118,10 +265,13 @@
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
✎ Sign-off required
</span>
<span t-if="step.requires_rack_assignment" class="o_fp_chip o_fp_chip_info">
🔧 Rack assignment
</span>
</div>
<!-- Recipe author instructions (only on active step) -->
<div t-if="step.instructions and isStepActive(step)"
<!-- Recipe author instructions -->
<div t-if="step.instructions"
class="o_fp_ws_step_instr">
<t t-esc="step.instructions"/>
</div>
@@ -140,30 +290,15 @@
jumpTargetId="step.blocker_jump_target_id"
onJump.bind="onJumpToBlocker"/>
<!-- Action buttons (only when unblocked) -->
<!-- Action buttons: dispatched per-kind via getStepActions -->
<div class="o_fp_ws_step_actions"
t-if="isStepActive(step) and step.blocker_kind === 'none'">
<button class="btn btn-secondary me-2"
t-on-click="() => this.onRecordInputs(step)">
<i class="fa fa-pencil"/> Record Inputs
</button>
<button t-if="step.requires_signoff"
class="btn btn-success"
t-on-click="() => this.onFinishStep(step)">
<i class="fa fa-check"/> Finish &amp; Sign Off
</button>
<button t-else=""
class="btn btn-success"
t-on-click="() => this.onFinishStep(step)">
<i class="fa fa-check"/> Finish
</button>
</div>
<div class="o_fp_ws_step_actions"
t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
<button class="btn btn-primary"
t-on-click="() => this.onStartStep(step.id)">
<i class="fa fa-play"/> Start
</button>
t-if="!step.override_excluded and step.blocker_kind === 'none'">
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
<button t-att-class="action.cssClass + ' me-2'"
t-on-click="() => this.dispatchStepAction(step, action.key)">
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
</button>
</t>
</div>
</div>
</t>

View File

@@ -31,7 +31,7 @@
<!-- KPI strip -->
<div t-if="state.data" class="kpi-strip">
<FpKpiTile value="state.data.kpis.active_jobs"
label="'Active Jobs'"
label="'Work Orders'"
kind="'good'"
active="!!state.filters.all"
onClick="() => this.toggleFilter('all')"/>

View File

@@ -342,53 +342,9 @@
</ul>
</section>
<section class="o_fp_panel">
<div class="o_fp_panel_head">
<h3><i class="fa fa-flag-checkered"/>First-Piece Gates</h3>
<span class="o_fp_panel_count"><t t-esc="state.overview.gates.length"/></span>
</div>
<div t-if="!state.overview.gates.length" class="o_fp_empty">
<i class="fa fa-flag-checkered"/>
<div>No pending first-piece inspections.</div>
</div>
<ul class="o_fp_bake_list" t-if="state.overview.gates.length">
<t t-foreach="state.overview.gates" t-as="g" t-key="g.id">
<li class="o_fp_bake_row" t-att-data-state="g.result">
<div class="o_fp_bake_main">
<div class="o_fp_bake_name">
<t t-esc="g.name"/>
<span class="text-muted ms-1"><t t-esc="g.part_ref"/></span>
</div>
<div class="o_fp_bake_meta">
<t t-esc="g.customer"/>
<t t-if="g.bath"> · Bath <t t-esc="g.bath"/></t>
</div>
</div>
<div class="o_fp_bake_time">
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(g.result)">
<t t-esc="g.result"/>
</span>
</div>
<div class="o_fp_bake_actions">
<button t-if="g.result === 'pending'"
class="btn btn-success"
t-on-click="() => this.onGateResult(g.id, 'pass')">
Pass
</button>
<button t-if="g.result === 'pending'"
class="btn btn-danger"
t-on-click="() => this.onGateResult(g.id, 'fail')">
Fail
</button>
<button class="btn btn-light"
t-on-click="() => this.openRecord('fusion.plating.first.piece.gate', g.id)">
Open
</button>
</div>
</li>
</t>
</ul>
</section>
<!-- First-piece gate panel retired with the fp.first.piece.gate
model removal (19.0.33.2.0). The feature was never wired
up — manual create, no enforcement, no rows in production. -->
<!-- ===== Pending QC banner (S19 follow-up) ===== -->
<!-- Shows whenever Carlos's job has an open QC. Tap -->

View File

@@ -1,185 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<record id="view_fp_first_piece_gate_list" model="ir.ui.view">
<field name="name">fp.first.piece.gate.list</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<list string="First-Piece Gates"
decoration-success="result == 'pass' and rest_of_lot_released"
decoration-warning="result == 'pass' and not rest_of_lot_released"
decoration-danger="result == 'fail'"
decoration-muted="result == 'pending'">
<field name="name"/>
<field name="bath_id"/>
<field name="part_ref"/>
<field name="customer_ref" optional="show"/>
<field name="routing_first_run" widget="boolean_toggle" optional="hide"/>
<field name="first_piece_produced" optional="show"/>
<field name="first_piece_inspected" optional="show"/>
<field name="inspector_id" optional="show"/>
<field name="result" widget="badge"
decoration-success="result == 'pass'"
decoration-danger="result == 'fail'"
decoration-muted="result == 'pending'"/>
<field name="rest_of_lot_released" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_first_piece_gate_form" model="ir.ui.view">
<field name="name">fp.first.piece.gate.form</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<form string="First-Piece Gate">
<header>
<button name="action_mark_pass" string="Mark Pass" type="object"
class="oe_highlight" invisible="result != 'pending'"/>
<button name="action_mark_fail" string="Mark Fail" type="object"
invisible="result != 'pending'"/>
<button name="action_release_lot" string="Release Lot" type="object"
invisible="result != 'pass' or rest_of_lot_released"/>
<field name="result" widget="statusbar"
statusbar_visible="pending,pass"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Job">
<field name="bath_id"/>
<field name="facility_id" readonly="1"/>
<field name="part_ref"/>
<field name="customer_ref"/>
<field name="routing_first_run"/>
</group>
<group string="Inspection">
<field name="first_piece_produced"/>
<field name="first_piece_inspected"/>
<field name="inspector_id"/>
<field name="rest_of_lot_released"/>
</group>
</group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<!--
Kanban rebuilt 2026-04 to match the Plant Overview card design.
Shared base styles in fp_kanbans.scss (.o_fp_kcard); the
wrapping .o_fp_fpg_kanban scopes per-result stripe colours.
-->
<record id="view_fp_first_piece_gate_kanban" model="ir.ui.view">
<field name="name">fp.first.piece.gate.kanban</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<kanban default_group_by="result" class="o_fp_fpg_kanban">
<field name="id"/>
<field name="name"/>
<field name="part_ref"/>
<field name="customer_ref"/>
<field name="bath_id"/>
<field name="first_piece_produced"/>
<field name="inspector_id"/>
<field name="result"/>
<field name="rest_of_lot_released"/>
<templates>
<t t-name="card">
<div class="o_fp_kcard"
t-att-data-result="record.result.raw_value">
<div class="o_fp_kcard_title">
<field name="name"/>
</div>
<div class="o_fp_kcard_sub" t-if="record.part_ref.raw_value">
<field name="part_ref"/>
</div>
<div class="o_fp_kcard_meta">
<span t-if="record.bath_id.raw_value">
<i class="fa fa-flask me-1"/><field name="bath_id"/>
</span>
<span class="o_fp_kcard_meta_sep"
t-if="record.bath_id.raw_value and record.customer_ref.raw_value">·</span>
<span t-if="record.customer_ref.raw_value">
<field name="customer_ref"/>
</span>
</div>
<div class="o_fp_kcard_meta"
t-if="record.inspector_id.raw_value or record.first_piece_produced.raw_value">
<span t-if="record.inspector_id.raw_value">
<i class="fa fa-user me-1"/><field name="inspector_id"/>
</span>
<span class="o_fp_kcard_meta_sep"
t-if="record.inspector_id.raw_value and record.first_piece_produced.raw_value">·</span>
<span t-if="record.first_piece_produced.raw_value">
<field name="first_piece_produced"/>
</span>
</div>
<div class="o_fp_kcard_footer">
<span t-att-class="'o_fp_kcard_chip ' + (
record.result.raw_value === 'pass' ? 'tone-success'
: record.result.raw_value === 'fail' ? 'tone-danger'
: record.result.raw_value === 'pending' ? 'tone-warning'
: 'tone-muted')">
<field name="result"/>
</span>
<span class="o_fp_fpg_released"
t-if="record.rest_of_lot_released.raw_value">
<i class="fa fa-check"/> Released
</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_fp_first_piece_gate_search" model="ir.ui.view">
<field name="name">fp.first.piece.gate.search</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<search string="First-Piece Gates">
<field name="name"/>
<field name="part_ref"/>
<field name="customer_ref"/>
<field name="bath_id"/>
<field name="inspector_id"/>
<separator/>
<filter string="Pending" name="pending" domain="[('result','=','pending')]"/>
<filter string="Passed" name="passed" domain="[('result','=','pass')]"/>
<filter string="Failed" name="failed" domain="[('result','=','fail')]"/>
<separator/>
<filter string="Lot Released" name="released" domain="[('rest_of_lot_released','=',True)]"/>
<filter string="Lot On Hold" name="on_hold"
domain="[('result','=','pass'),('rest_of_lot_released','=',False)]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Result" name="group_result" context="{'group_by':'result'}"/>
<filter string="Customer" name="group_customer" context="{'group_by':'customer_ref'}"/>
<filter string="Inspector" name="group_inspector" context="{'group_by':'inspector_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_first_piece_gate" model="ir.actions.act_window">
<field name="name">First-Piece Gates</field>
<field name="res_model">fusion.plating.first.piece.gate</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_fp_first_piece_gate_search"/>
<field name="context">{'search_default_pending': 1}</field>
</record>
</odoo>

View File

@@ -43,12 +43,6 @@
action="action_fp_bake_window"
sequence="20"/>
<menuitem id="menu_fp_shopfloor_first_piece"
name="First-Piece Gates"
parent="menu_fp_shopfloor"
action="action_fp_first_piece_gate"
sequence="30"/>
<!-- Phase 2 — both under Shop Setup. -->
<menuitem id="menu_fp_shopfloor_stations_cfg"
name="Shopfloor Stations"

View File

@@ -1054,54 +1054,7 @@ if env['fusion.plating.bake.window'].search_count([]) < 6:
})
LOG(" +3 additional bake windows (awaiting / in-progress)")
# First-piece inspection gates — seed 4 variants
Gate = env['fusion.plating.first.piece.gate']
if Gate.search_count([]) < 4:
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'HW-TOR-5501',
'customer_ref': 'Honeywell Toronto',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(minutes=35),
'result': 'pending',
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'AP-WGL-7200',
'customer_ref': 'Amphenol Canada',
'routing_first_run': False,
'first_piece_produced': datetime.now() - timedelta(hours=2),
'first_piece_inspected': datetime.now() - timedelta(hours=1, minutes=40),
'inspector_id': env.user.id,
'result': 'pass',
'rest_of_lot_released': True,
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'MG-WG-8801',
'customer_ref': 'Magellan Aerospace',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(hours=4),
'first_piece_inspected': datetime.now() - timedelta(hours=3, minutes=30),
'inspector_id': env.user.id,
'result': 'pass',
'rest_of_lot_released': False, # passed but awaiting release
'notes': '<p>Thickness 1.95 mils — within tolerance. Lot released pending planner signoff.</p>',
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'CY-STR-240',
'customer_ref': 'Cyclone Manufacturing',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(hours=6),
'first_piece_inspected': datetime.now() - timedelta(hours=5, minutes=30),
'inspector_id': env.user.id,
'result': 'fail',
'notes': '<p>Thickness 0.8 mils — below spec (min 1.2). Rework required.</p>',
})
LOG(" 4 first-piece gates: 1 pending / 1 passed+released / 1 passed-holding / 1 failed")
else:
LOG(f" Already has {Gate.search_count([])} first-piece gates — skipping")
# First-piece-gate seeding retired with the model (19.0.33.2.0).
# Quality holds on active MOs — gives the Shop Floor quality-holds panel content
Hold = env['fusion.plating.quality.hold']
@@ -1374,7 +1327,6 @@ LOG(f" Bath logs: {env['fusion.plating.bath.log'].search_count([])}")
LOG(f" Replenishments: {env['fusion.plating.bath.replenishment.suggestion'].search_count([])}")
LOG(f" Bake windows: {env['fusion.plating.bake.window'].search_count([])}")
LOG(f" Stations: {env['fusion.plating.shopfloor.station'].search_count([])}")
LOG(f" First-piece: {env['fusion.plating.first.piece.gate'].search_count([])}")
LOG(f" Quality holds: {env['fusion.plating.quality.hold'].search_count([])}")
LOG(f" Racks: {env['fusion.plating.rack'].search_count([])}")
LOG(f" Operator certs: {env['fp.operator.certification'].search_count([])}")

View File

@@ -853,17 +853,7 @@ if BakeWin is not None and job:
'bake window auto-created',
f'{len(bw)} record(s) for {job.name}')
# First-piece gate auto-created?
FPG = env.get('fusion.plating.first.piece.gate')
if FPG is not None:
# FPG model may not have production_id either; try common link fields
fpg = FPG.search([]) # take any recent
fpg_for_mo = fpg.filtered(
lambda g: getattr(g, 'production_id', False) and g.production_id.id == mo.id
) if 'production_id' in FPG._fields else fpg.browse([])
finding('PASS' if fpg_for_mo else 'WARN',
'first-piece gate',
f'{len(fpg_for_mo)} for MO (coating-driven; OK if 0)')
# First-piece-gate check retired with the model (19.0.33.2.0).
# Each operator can see their OWN assigned WOs via the tablet
# (queue is a TransientModel; tablet calls build_for_user on load)