Compare commits

...

50 Commits

Author SHA1 Message Date
gsinghpal
6ac6d24da6 CHANGES 2026-04-28 19:43:16 -04:00
gsinghpal
2a9fd478f5 Update fp_racking_inspection_views.xml 2026-04-28 19:42:41 -04:00
gsinghpal
13e300d90e changes 2026-04-28 19:39:37 -04:00
gsinghpal
2d42b33d68 docs(claude.md): log Sub 12a/12b/12c + Phase 1/2/3 menu reorg + ergo fixes
Records full session work for future Claude Code sessions:

Sub 12a — Simple Recipe Editor + Step Library
  3 new models, additive fields on process.node + node.input,
  fp_simple_recipe_editor OWL action with drop-position simulator,
  11 JSONRPC endpoints, snapshot semantics, post_init seeding.

Sub 12b — Move Parts / Move Rack / Rack Parts / Stop Timer dialogs
  fp.rack.tag, fp.job.step.move + .input.value, racking_state on
  fusion.plating.rack (orthogonal to wear state), state machine on
  existing fp.job.step.timelog (no parallel labor model), 12 tablet
  endpoints, 4 OWL dialogs with fp-resolve-rack custom event,
  manager-bypass flags, plant overview Racks pane.

Sub 12c — Reports + Labor History
  Operator Traveller v2 (A4 landscape, paper-style), chronological
  CoC body via fp.certificate.body_style + coc_body_router,
  Labor History views, gap-fix bundle (rack travel ticket PDF,
  per-customer cert statement 3-tier resolution, captured Actual
  values from move.input.value).

Phase 1/2/3 — Menu reorganization
  Top-level: 17 → 6 (operator-visible). Industry verticals nested
  under Compliance hub. Move Log/Labor History/Maintenance under
  Operations. Certificates under Quality.
  Configuration: 36 flat → 7 themed folders + Settings sibling.
  Group-gating: KPIs/Move Log/Replenishment Suggestions →
  supervisor+. Operator now sees ~5 top-levels instead of ~10.

Landing page resolver
  action_fp_resolve_plating_landing server action, user override →
  company default → Sale Orders fallback. x_fc_pickable_landing
  Boolean tag for curated picklist.

Production Line / Routing Station rename
  fusion.plating.work.center → 'Production Line' (shop-layout, owns
  tanks). fp.work.centre → 'Routing Station' (per-step routing,
  cost-per-hour, mrp.workcenter replacement). Model IDs unchanged.

Other ergonomics
  Tank field labels (Code → Tank Number, Tank → Tank Name) +
  state-control header buttons. SO smart-button 'Plating Jobs' → 'WO'.
  Default landing screen = Sale Orders.
  Drop-position simulator in Simple Recipe Editor.

Updated 'Menu Structure' section near top of CLAUDE.md to reflect
new 6-top-level layout + 7-folder Configuration grouping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:06:25 -04:00
gsinghpal
afcd128f83 fix: rename ambiguous 'Work Center' / 'Work Centres' menus
Two distinct entities were both labelled 'Work Centre' (US/UK spelling
the only differentiator — confusing). Renamed by purpose, model IDs
unchanged so all 12+9 existing cross-refs keep working:

fusion.plating.work.center → 'Production Line'
  Physical shop-layout grouping that owns tanks. Examples: 'Line 1 —
  EN', 'Anodize Line', 'Prep Bay'. Has tank_ids (O2M),
  supported_process_ids (M2M), capacity_per_day. The thing tanks live
  in.

fp.work.centre → 'Routing Station'
  Per-job-step routing entity (post-Sub-11 mrp.workcenter replacement).
  Has 'kind' selection (wet_line / bake / mask / rack / inspect),
  cost_per_hour for fp.job.step rollup, default_bath_id +
  default_tank_id for release-ready validation. The thing a job step
  routes through.

Conceptually a Production Line CONTAINS many Routing Stations (e.g.
'EN Line' production line has wet-line, bake, inspect routing
stations on it).

Updated:
- _description on both models
- string= on the name fields
- list/form/search view strings
- act_window names ('Production Lines' / 'Routing Stations')
- menu items in fp_menu.xml + fp_jobs_menu.xml
- doc comments in both model files explaining the distinction

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:55:39 -04:00
gsinghpal
5f6c7af2a7 feat(phase3): tighten group-gating on operator-irrelevant top-levels
Three targeted gates so operators no longer see admin/audit views:

- KPIs (menu_fp_dashboard) → supervisor+. Operators don't need
  dashboards; their tablet shows what they need to do next.
- Move Log (menu_fp_job_step_move) → supervisor+. Operators see
  their own moves on the tablet; this top-level menu is the
  audit-of-everyone-else view.
- Replenishment Suggestions (menu_fp_replenishment_suggestions) →
  supervisor+. Purchasing decision, not operator concern.

Other top-levels were already correctly gated:
- Sales / Configurator → estimator
- Shipping & Receiving → group_fp_receiving
- Compliance hub → supervisor+
- Configuration → manager
- Shop Floor / Quality → operator (correctly visible to floor staff)
- Operations parent stays open; child menus enforce per-action gates

Net effect: a fresh operator now sees ~5 top-level menus instead of
the previous ~10. Supervisors see ~8. Managers see all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:51:30 -04:00
gsinghpal
46715410a9 fix(phase2): restructure fp_menu.xml so buckets defined before children
Within fp_menu.xml itself, the menu_fp_replenishment_rules entry
referenced menu_fp_config_materials_tanks which was defined later in
the same file. Odoo's data loader is strictly top-down within a file.

Reorganized by section: 1) root, 2) Configuration + 7 buckets,
3) Compliance hub, 4) Operations parent, 5) all child menus (referencing
parents already defined above).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:48:06 -04:00
gsinghpal
62c1315997 fix(phase2): load fp_menu.xml first so bucket folders exist before refs
fp_rack_tag_views.xml (and several other view files) reference the
new Phase-2 Configuration sub-folder menus (menu_fp_config_*) defined
in fp_menu.xml. Odoo's data loader is strictly sequential within a
module, so fp_menu.xml must come before any file that references
its bucket xmlids.

Caught by entech upgrade (ParseError on rack-tag menuitem).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:46:53 -04:00
gsinghpal
3641b78a66 feat(phase2): Configuration grouped into 7 themed folders
Collapses the flat ~36-entry Configuration list into 7 navigable
folders + Settings (sibling, stays at top of Configuration). Existing
menu IDs unchanged so bookmarks + cross-module data refs still work
— only parent-id moves.

New folder menus (defined in fusion_plating core):
  menu_fp_config_shop_setup       Shop Setup
  menu_fp_config_recipes_steps    Recipes & Steps
  menu_fp_config_materials_tanks  Materials & Tanks
  menu_fp_config_workforce        Workforce
  menu_fp_config_quality_docs     Quality & Documents
  menu_fp_config_pricing_billing  Pricing & Billing
  menu_fp_config_reference_data   Reference Data

Routing per item (sources updated in their owning module):
  Shop Setup       Facilities, Work Centers, Work Centres, Process
                   Categories, Process Types, Bake Ovens, Shopfloor
                   Stations, Vehicles
  Recipes & Steps  Step Library, QC Checklist Templates, Quality Points
  Materials & Tanks  Bath Parameters, Replenishment Rules, Chemicals,
                     Rack Tags, Calibration Equipment, Calibration Events
  Workforce        Operator Certifications, Shop Roles, Training Types,
                   Quality Teams
  Quality & Documents  Customer Specs, Approved Vendor List, Quality
                       Tags, Quality Reasons, Quality Stages, N299
                       Levels, Notification Templates, Notification Log
  Pricing & Billing  Invoice Strategy Defaults, Account Holds
  Reference Data   Value Sets, Value Rotations
  (Settings remains as a sibling at top of Configuration, manager-gated)

Versions bumped: fusion_plating, fusion_plating_quality, _safety,
_shopfloor, _logistics, _culture, _invoicing, _notifications, _nuclear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:45:21 -04:00
gsinghpal
0ad382e1a6 feat(phase1): top-level menu consolidation + landing-page resolver
Phase 1 collapses the Plating app's 17 top-level menus down to 6
domains (Sales, Operations, Receiving & Shipping, Quality,
Compliance, Configuration) so users no longer scroll a 17-item
sidebar to find one thing.

Re-parented (no XML id changes — bookmarks still work):
- fusion_plating_compliance.menu_fp_compliance_root
    → menu_fp_compliance_hub  (renamed 'General')
- fusion_plating_safety.menu_fp_safety_root
    → menu_fp_compliance_hub  (renamed 'Safety / WHMIS')
- fusion_plating_aerospace.menu_fp_aerospace
    → menu_fp_compliance_hub  (renamed 'Aerospace (AS9100 / Nadcap)')
- fusion_plating_nuclear.menu_fp_nuclear
    → menu_fp_compliance_hub  (renamed 'Nuclear (CSA N299 / CNSC)')
- fusion_plating_cgp.menu_fp_cgp
    → menu_fp_compliance_hub  (renamed 'Controlled Goods (CGP)')
- fusion_plating_certificates.menu_fp_certificates
    → menu_fp_quality (Certs are a Quality output, not a separate
      top-level concern)
- fusion_plating_bridge_maintenance.menu_fp_maintenance
    → menu_fp_operations
- fusion_plating.menu_fp_job_step_move (Move Log)
    → menu_fp_operations
- fusion_plating.menu_fp_job_step_timelog (Labor History)
    → menu_fp_operations

The new menu_fp_compliance_hub is supervisor-gated; underlying
verticals retain their own group locks (CGP officer, etc.).

Settings menu remains manager-gated through inheritance from
menu_fp_config (already in place).

NEW — Plating landing-page resolver:
- ir.actions.act_window.x_fc_pickable_landing  (Boolean tag for
  curated picklist; flagged on Sale Orders, Quotations, Process
  Recipes for Phase 1; more in Phase 2)
- res.company.x_fc_default_landing_action_id (admin sets fallback)
- res.users.x_fc_plating_landing_action_id (per-user override)
- ir.actions.server action_fp_resolve_plating_landing — picks
  user → company → Sale Orders fallback at click time
- menu_fp_root rewired to call the resolver
- User profile + Settings tabs surface the dropdowns

Configurator's earlier menu_fp_root override (action_fp_sale_orders
direct) removed — core's resolver now owns the routing.

Versions bumped: fusion_plating 19.0.11.0.0, configurator
19.0.17.16.0, plus 7 dependent modules patched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:33:37 -04:00
gsinghpal
3098fcfaf9 feat(sub12a+): drop-position simulator in Simple Recipe Editor
Replaces the per-row 'highlight whole row' feedback (operator
couldn't tell whether the new step would land before or after the
hovered row) with a precise insertion-point indicator.

How it works:
- Each row's onDragOver computes ev.clientY vs row midpoint.
  Above midpoint → insertion index = rowIndex (BEFORE).
  Below midpoint → insertion index = rowIndex + 1 (AFTER).
- An <o_fp_drop_indicator> div lives BEFORE the first row and
  AFTER every row. When state.dragOverIndex matches that slot's
  index, the div expands from height:0 to a 2.25rem dashed-green
  reservation strip with a ghost-preview chip ('↓ insert here →
  <icon> <step name>').
- onDragStart captures the dragged step/template's name + icon
  into state.dragPreviewLabel/Icon for the chip text.
- Smooth 80ms height/margin transition so the line glides between
  slots as the cursor moves rather than blinking.
- Trailing dropzone retains its existing 'Drop here to add at end'
  styling. Empty list shows 'Drag a library step here to start'.
- onDrop reads from state.dragOverIndex (set by the most-recent
  onDragOver) so we drop at the simulated position exactly.
- onDragLeave guards against child-element flicker via
  relatedTarget contains() check.
- onDragEnd clears state.dragPreviewLabel/Icon so a half-completed
  drag (cancelled by Esc) doesn't leave the chip stuck on screen.

fusion_plating → 19.0.10.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:08:51 -04:00
gsinghpal
7d3b8f132a fix(sub12c+): close 3 known gaps — rack travel ticket, cert statement, CoC actuals
Gap 1 — Rack Travel Ticket PDF (Sub 12b's Save+Print 404):
  + report_fp_rack_travel.xml in fusion_plating_reports — A5 landscape
    single page, big rack name, Code 128 of FP-RACK:<name>, tag chips,
    contained part-batches table.
  + ir.actions.report bound to fusion.plating.rack so it appears in
    the rack form's Print menu too.
  + Sub 12b's rack_parts_dialog.js Save+Print URL fixed to use the
    standard /report/pdf/<xmlid>/<id> route.

Gap 2 — Per-customer cert statement:
  + res.company.x_fc_default_cert_statement (company-level fallback).
  + res.partner.x_fc_cert_statement (per-customer override).
  + Surfaced on the partner form under the existing Cert + Document
    Routing block.
  + Chronological CoC body resolves: customer override → company
    default → hardcoded AS9100/ISO 9001 boilerplate. Three-tier
    fallback so existing certs without overrides keep working.

Gap 3 — Chronological CoC 'Actual' column:
  + Build a captured_values_by_input dict from the move's
    transition_input_value_ids (Sub 12b captures these on every
    Move Parts commit).
  + Render typed Actual: text → as-is, number → with target unit,
    boolean → PASS/FAIL, date → formatted, attachment → '[Attachment]'
    placeholder.
  + Falls back to prompts from the destination step's step_input list
    when no values were captured (still useful as audit-of-what-was-
    asked even if blank).

Version bumps:
  fusion_plating → 19.0.10.3.0
  fusion_plating_reports → 19.0.10.1.0
  fusion_plating_certificates → 19.0.5.3.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:55:48 -04:00
gsinghpal
504c8f34db feat(sub12c): Labor History views + Plating menu (Task 4)
Plating → Labor History (sequence 64, between Move Log 62 and
Aerospace 65). List view colour-coded by state (info/warning/
success/muted), with billed_pct progressbar and rich field optionals.

Search filters: My Timers (default), Today, Running, Paused, Pending
Reconciliation, Reconciled. Group-by: Operator, Job, Date.

Form view (read-only header with statusbar): identity fields readonly,
billed_hrs/min/sec editable for supervisors+ until state=reconciled.
Notes group at bottom. create=false (timers are runtime-produced;
manual creation goes through the tablet flow).

ACL rows for fp.job.step.timelog already shipped in Sub 12b's CSV
(operator/supervisor/manager) — no security changes needed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:43:07 -04:00
gsinghpal
9d88c25136 feat(sub12c): chronological CoC body + body_style opt-in router (Task 3)
New template: fusion_plating_reports.coc_body_chronological.
Walks fp.job.step.move records in time order (chain-of-custody).
Per-move heading 'Step Name (Tank Code)' with 'Moved By / Time / Qty'
meta line + a 5-column measurement sub-table (Name / Description /
Target / Actual / Recorded By) when the destination step has captured
inputs. Heading-only when there are no inputs (gating moves).

New router template: coc_body_router. Picks chronological vs classic
based on fp.certificate.body_style. Existing certs default to 'classic'
so no regressions. Both English + French CoC templates rerouted.

fp.certificate.body_style ('classic' | 'chronological') exposed on
the cert form alongside certified_by_id. Operator picks per cert.

Sign-off block reuses the existing owner_user_id signature pattern +
x_fc_coc_signature_override fallback. Cert statement boilerplate is
inline (Sub 12d will move it to a configurable per-customer field).

The Actual column in the measurement sub-table is rendered blank
because Sub 12a/12b runtime captures step_input values via the
operator's per-step input form which lives in a model not yet wired
into this template — Sub 12d follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:42:03 -04:00
gsinghpal
12fcd11016 feat(sub12c): operator traveller v2 — paper-style A4 landscape (Task 2)
Replaces the minimal portrait template with the Amphenol-style paper
sheet (screens 16-18):
- Header: company logo + barcode (Code 128) + WO# + Date In + Due
  Date + Type + Order# + PO# + WO-Generated-By + customer block.
- Item Information panel: Part# / Rev / Mat / Catg / S/N + Item-Name +
  Qty Rec / VIS INSP / Rework / Special Requirements / Stamp-Date.
- Process-Sheet header: recipe name + category + spec/info.
- Routing table (11 cols): Step / Tank / Operation+Actuals (recipe
  inputs render as 'Actual <name>: ____ unit' lines) / Instruction /
  Unit / Material / Voltage / Time(min) / Temp / Stamp / Date.

Targets pulled from recipe-node fields when present (Sub 12a authored),
'N/A' otherwise. Heavily defensive QWeb — every cross-module field
access ('part_catalog_id' / 'coating_config_id' / 'qty_received' /
'special_requirements' / 'serial_number' / 'base_material' /
'customer_facing_description' / 'time_min_target' / etc.) guarded with
'X in record._fields' checks so the report renders cleanly even when
some Sub 12a/12b fields aren't yet populated.

New paperformat: A4 landscape narrow margins, 90 dpi.

Action ID + report_name unchanged so existing form-button bindings
keep working (binding_model_id still points at fp.job).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:39:41 -04:00
gsinghpal
f55193fb1b feat(sub12c): bump versions + manifest scaffolding (Task 1)
fusion_plating → 19.0.10.2.0 (Labor History views)
fusion_plating_jobs → 19.0.7.0.0 (Operator Traveller v2)
fusion_plating_reports → 19.0.10.0.0 (Chronological CoC body)

Adds data entries for the 2 new XML files (timelog views + coc
chronological).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:37:24 -04:00
gsinghpal
34528a5d3d docs(sub12c): implementation plan — 5 tasks (down from original 18)
Tightened from the original 18-task plan after inspecting existing
templates:
- report_coc_en / report_coc_fr already exist with Nadcap/AS9100/CGP
  logos, signature, certified_by — solid. Add a chronological body
  alongside, don't rebuild.
- company.x_fc_nadcap_logo etc already exist on res.company. Skip.
- The native fp.job traveller is minimal (post-Sub-11) and needs the
  paper-style upgrade. Replace its body, not the action.
- fp.job.step.timelog state machine landed in Sub 12b — Sub 12c just
  ships views + menu.

5-task breakdown:
1. Bump versions + manifest scaffolding
2. Operator Traveller v2 (A4 landscape, paper-style, target columns)
3. Chronological CoC body + body_style opt-in router
4. Labor History list/form/search + Plating menu
5. Deploy to entech + smoke test

Out of scope: rack travel ticket PDF (Sub 12b's Save+Print 404 stays
flagged), per-customer cert statement (boilerplate inline for now).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:36:06 -04:00
gsinghpal
e718a47e3e fix(sub12b): to_step_id required → ondelete='restrict'
Odoo 19 disallows ondelete='set null' on a required M2O. Switched to
restrict — destination steps can't be unlinked while move-log rows
reference them, which is the right audit-safety behavior anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:19:58 -04:00
gsinghpal
11dbbf578e feat(sub12b): plant overview Racks pane (Task 16)
Controller: extend /fp/shopfloor/plant_overview return payload to
include 'racks' array (filtered to loaded/in_use/awaiting_unrack
states). Each entry has tag chips, part count, current node
breadcrumb, current step + tank code, and a precomputed
next_step_id (next sequence in the job's recipe — operator
overrides at runtime in the Move Rack dialog).

JS: state.racks populated from payload. New openMoveRackDialog()
method spawns FpMoveRackDialog. Notification when rack has no
successor (last step of job).

XML: top section above the existing work-centre columns. Renders
rack rows with tags, part count, breadcrumb, and primary MOVE RACK
button per row. Visible only when state.racks.length > 0.

SCSS: minimal styling for the racks pane (extends move_dialogs.scss
to keep all Sub 12b styles in one file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:19:05 -04:00
gsinghpal
902f3e8398 feat(sub12b): wire Move Parts + Stop Timer dialogs into tablet (Task 15)
ShopfloorTablet component:
- Imports the 3 new OWL dialogs.
- useService('dialog') for spawning.
- Listens for 'fp-resolve-rack' window CustomEvent fired from inside
  FpMovePartsDialog → spawns FpRackPartsDialog inline.
- New methods: openMovePartsDialog(from, to) + openStopTimerDialog(id).
  Refresh tablet after commit/reconcile so the UI reflects new state.

Listener cleanup on unmount.

Note: the actual buttons that call these methods are added to the
existing tablet XML in a follow-up step — for now they are wired but
not surfaced. Operators get them after Task 16 + smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:16:15 -04:00
gsinghpal
11bc0ca742 feat(sub12b): shared SCSS for Move/Rack/Timer dialogs (Task 14)
3-column grid layout for field rows (label / input / hint).
Compliance prompts + Blockers blocks have their own backgrounds.
Soft blockers amber + left border, hard blockers red + left border —
matches the spec's protocol.

Token pattern + dark-mode @if branch (CLAUDE.md rule: Odoo 19 doesn't
flip dark mode via runtime DOM class; we branch at SCSS compile time
on $o-webclient-color-scheme).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:15:12 -04:00
gsinghpal
270f427d7f feat(sub12b): Move Rack + Stop Timer OWL dialogs (Task 13)
Move Rack: rack name in title via getter, tag chips, batches list
(read-only), Type + To Node + To Station picker. Atomic Save commits
all batches via /fp/tablet/move_rack/commit.

Stop Timer: opens with state already at 'stopped' (server flipped on
load via /labor_timer/stop), pre-fills billed_* from accrued.
Operator edits → Save (state → reconciled).

Save & Start New Timer chains into a fresh timer for the same step
via the start_new=True flag — mirrors screen 10's right-most button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:14:30 -04:00
gsinghpal
48c06c40c9 feat(sub12b): OWL Rack Parts sub-dialog (Task 12)
Mirrors screens 7-8. Searchable empty-rack picker with debounced
typeahead via /fp/tablet/rack/list_empty. QR Scan button prompts
operator for FP-RACK:<name> token, resolves via /fp/tablet/rack/
scan_qr.

Save commits the racking via /fp/tablet/rack_parts/commit. Save+Print
opens /web/report/pdf/fp.rack.travel/<id> in a new tab — that report
ships in Sub 12c, returns 404 until then. Plain Save works today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:13:04 -04:00
gsinghpal
6d046f2881 feat(sub12b): OWL Move Parts dialog (Task 11)
Mirrors Steelhead screens 1-3, 14-15. Loads preview on mount,
re-checks hard-blockers on commit. MOVE (n) button disabled when
hard-blocked OR required prompt blank — improvement over Steelhead's
silent disabled state (we show a tooltip listing reasons).

Inline 'Resolve' button next to each blocker. For rack-required,
fires a window CustomEvent ('fp-resolve-rack') the parent tablet
catches to open the Rack Parts sub-dialog.

Typed input rendering by input_type — text/number/checkbox/select/
datetime, plus support for time_hms and signature/photo (text input
for now; full upload widget in Sub 12c).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:11:49 -04:00
gsinghpal
a521b7c37b feat(sub12b): consolidated tablet controller — Move/Rack/Timer (Tasks 8-10+17)
11 routes (consolidated from plan Tasks 8/9/10/17):
  Move Parts:
    /fp/tablet/move_parts/preview
    /fp/tablet/move_parts/commit
  Move Rack:
    /fp/tablet/move_rack/preview
    /fp/tablet/move_rack/commit
  Rack Parts:
    /fp/tablet/rack_parts/commit
    /fp/tablet/rack/list_empty
    /fp/tablet/rack/scan_qr
  Persistent labor timer:
    /fp/tablet/labor_timer/start
    /fp/tablet/labor_timer/pause
    /fp/tablet/labor_timer/resume
    /fp/tablet/labor_timer/stop
    /fp/tablet/labor_timer/reconcile

Manager-bypass context flags (Task 17 wired in here for cohesion):
  fp_skip_predecessor_check  → bypasses S14 lock
  fp_skip_rack_assignment    → bypasses requires_rack_assignment
  fp_skip_transition_form    → bypasses required transition prompts

All bypass uses post to chatter on the move record naming the user
+ which flags fired. Group check enforced (manager-only).

_safe() wrapper: UserError → JSONRPC-friendly {ok: False, error: msg}
so the OWL components can show a flash without crashing.

Field naming follows existing fp.job.step.timelog convention
(date_started / date_finished, NOT started_at / stopped_at).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:10:28 -04:00
gsinghpal
3bed76aea4 feat(sub12b): persistent state machine on fp.job.step.timelog
Extends the existing timelog (used by S1/S2 battle tests) with:
- state: running / paused / stopped / reconciled (default running)
- last_paused_at + total_paused_seconds (drives accrued compute)
- accrued_seconds (compute, depends date_started/_finished/paused)
- billed_hrs/min/sec + billed_total_seconds + billed_pct (compute)
- product_id (split-by-product reconciliation per screen 10)
- notes
- job_id (related, indexed — for fp.job.active_timer_ids O2M)

Field naming follows the existing date_started / date_finished
convention (NOT started_at / stopped_at as my plan said — adjusted
inline to match what's already in the file).

The existing battle tests use the timelog without state — default
'running' so they're unaffected. State only flips when Sub 12b's
Stop Timer dialog commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:08:22 -04:00
gsinghpal
dcd6df71c0 feat(sub12b): fp.job.step + fp.job — rack/move/traveller fields
fp.job.step:
  + requires_rack_assignment (related from recipe_node_id)
  + requires_transition_form (related)
  + move_ids (O2M from_step_id), incoming_move_ids (O2M to_step_id)
  + is_racked (compute, stored, depends rack_id) — drives tablet
    rack-vs-parts greyed-button guard
  + qty_at_step_start, qty_at_step_finish (advanced by move commits)

  NOTE: existing 'rack_id' field is reused as the 'current rack' pointer
  (already there on line 95). Adding requires_rack_assignment as a
  related from recipe_node_id for runtime gate evaluation.

fp.job:
  + qty_received, qty_visual_inspection_rejects, qty_rework
  + special_requirements (Text — paper traveller header)
  + active_timer_ids (filtered O2M, depends on Task 7's state field)
  + move_ids (O2M to fp.job.step.move)

All additive. No removed fields. Existing battle tests unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:07:19 -04:00
gsinghpal
0794f7e3c9 feat(sub12b): move-log list/form/search + Plating menu
Plating → Move Log (sequence 62, between Logistics 60 and Aerospace 65).
Form is read-only (create=false) since moves are produced by the
tablet flow, not the desktop UI.

Search filters: Today, Scrap/Rework, Racked. Group-by: Job, Operator,
Transfer Type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:05:28 -04:00
gsinghpal
4187842d30 feat(sub12b): fp.job.step.move + fp.job.step.move.input.value
Chain-of-custody log: one row per Move Parts / Move Rack commit.
FP/MOVE/YYYY/NNNN sequence (5-digit). Carries from/to step + tank,
transfer type, qty, location, photo, rack, operator, datetime.

Child model captures recorded transition-input values (Sub 12a's
fp.step.template.transition.input snapshots → fp.job.step.move.
input.value rows). Each row carries 5 typed value columns; the
controller (Task 8) picks the right one based on input_type.

Operators get read+write+create — they generate moves at runtime —
but no unlink. Manager-only deletes for audit safety.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:04:41 -04:00
gsinghpal
d9ae45ce9b feat(sub12b): extend fusion.plating.rack — racking_state + tags + capacity
The existing 'state' field tracks wear (active/needs_strip/stripping/
retired). Sub 12b adds an orthogonal 'racking_state' (empty/loading/
loaded/in_use/awaiting_unrack/out_of_service) for the load lifecycle
— a rack can be wear-active AND racking-loaded simultaneously.

New fields:
  racking_state        — operational lifecycle
  tag_ids (M2M)        — fp.rack.tag chips on plant overview
  capacity_count       — soft warn (distinct from existing 'capacity')
  current_job_step_id  — compute, derived from latest fp.job.step.move
  current_tank_id      — compute
  current_part_count   — compute

Form view picks up the new fields under Sub-12b group + a 'Current
Use' panel that hides when racking_state is empty / out_of_service.

The compute references fp.job.step.move which lands in Task 4 — the
module won't load cleanly on entech until Tasks 4-5 ship; that's
expected for batch deployment at the end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:03:09 -04:00
gsinghpal
86c0e230a1 feat(sub12b): fp.rack.tag — rack-label registry + 4 starter tags
M2M tag registry: Rush / Hold for QC / Damaged / Customer Sample.
Each rack can carry many tags; tags surface as coloured chips on the
plant-overview rack rows + Move Rack dialog (Task 13).

Plating → Configuration → Rack Tags menu (sequence 48).
post_init_hook seeds 4 starters — idempotent (no-op if any exist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:01:49 -04:00
gsinghpal
d78ef4228e feat(sub12b): bump versions + scaffold manifests
fusion_plating → 19.0.10.1.0
fusion_plating_shopfloor → 19.0.25.0.0

Adds data entries for fp_rack_tag_views.xml + fp_job_step_move_views.xml.
Adds 4 OWL dialogs + their templates + shared SCSS to the shopfloor
backend asset bundle (loaded after the existing manager_dashboard.js).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:00:18 -04:00
gsinghpal
25b429f253 docs(sub12b): implementation plan — 18 tasks for tablet Move/Rack/Timer
Adjustments from the spec, captured upfront in the plan:
- fp.rack already exists (extend, don't create) — Task 3
- fp.labor.timer collapses into the existing fp.job.step.timelog with
  a state machine + reconciliation fields — Task 7. Avoids parallel
  labor-tracking models; keeps battle-test S1/S2 paths intact.
- Sub 12b's Save+Print on Rack Parts references a report that lands
  in Sub 12c — flagged in Task 12 body.

18 tasks cover: 4 new models (rack tag, move, move input value), state
machine on existing rack + timelog, 11 controller endpoints, 4 OWL
dialogs, plant overview 2-pane layout, runtime guards, manager bypass
flags, entech deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:55:04 -04:00
gsinghpal
5494684181 fix(sub12a): rename _seed_default_inputs → action_seed_default_inputs
Odoo 19 rejects view buttons that call private (underscore-prefixed)
methods. Renamed the public entry point. The post_init_hook callers
follow.

Caught by entech upgrade (ParseError on the form view).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:43:26 -04:00
gsinghpal
d6cdae30ec feat(sub12a): OWL Simple Recipe Editor client action
JS: FpSimpleRecipeEditor component reads recipe_id from
props.action.context (matches the existing tree editor's contract).
HTML5 native drag-drop between Library (right) and Selected (left)
panels — uses two distinct dataTransfer types (application/x-fp-step
vs application/x-fp-library) so the drop handler knows whether to
reorder or snapshot-copy.

XML: 2-column grid layout. Selected has per-row × remove (hover
reveal), drag handle, position number, icon, name, station-count
badge. Library has search input, scrollable item list with empty-
state, drag-handle items.

SCSS: tokens follow the fp_shopfloor pattern with dark-mode SCSS @if
branch (CLAUDE.md rule). 2-fr grid that collapses to single column
under 900px for tablet/mobile.

Tag: fp_simple_recipe_editor — registered via registry.category('actions').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:42:06 -04:00
gsinghpal
a892a7b20e feat(sub12a): recipe form — buttons + is_template + Step Authoring tab
Process node form:
- Header: keep 'Open Tree Editor' (primary, existing); add 'Open Simple
  Editor' (secondary). Both visible only for recipe-type nodes.
- Recipe Settings group: add preferred_editor + is_template (the latter
  supervisor-only).
- New 'Step Authoring' notebook page (visible for step/operation):
  Stations, default_kind, material_callout, predecessor/rack/transition
  flags, time/temp targets, voltage/viscosity, readonly
  source_template_id.

Model:
- New action_open_simple_editor (sibling of action_open_tree_editor).
- New _resolve_preferred_editor() — per-recipe preferred_editor wins,
  'auto' falls back to company.x_fc_default_recipe_editor, final
  fallback 'tree'.
- New action_open_recipe_with_preferred_editor() — one-click route
  through the resolver. Reserved for menu-list / context-menu callers
  that want the simple-loving foreman path.

Tree editor + every existing battle test path untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:40:08 -04:00
gsinghpal
194d5d96dd feat(sub12a): JSONRPC endpoints for the Simple Recipe Editor
11 routes under /fp/simple_recipe/...:
  load
  library/{list,create,write,delete}
  step/{insert,write,remove,reorder}
  template/{list,import}

Library/template imports snapshot-copy fields (Q4 = A locked) — no
live references. The _SNAPSHOT_FIELDS + _INPUT_SNAPSHOT_FIELDS module
constants are the single source of truth for what gets copied;
adding a new authoring field on fp.step.template means appending it
once to _SNAPSHOT_FIELDS and the controller stays correct.

library_delete is soft when nodes still reference the template via
source_template_id (operator can't accidentally orphan recipe steps).

Uses recipe.check_access('read') (Odoo 19 unified API) instead of the
older check_access_rights/check_access_rule pair.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:38:16 -04:00
gsinghpal
33ddec926c feat(sub12a): post_init_hook — backfill kind + seed step library
Extends the existing post_init_hook to also do (idempotent) Sub 12a
work on first install / upgrade:

1. Backfill kind='step_input' on existing
   fusion_plating_process_node_input rows where kind IS NULL.
2. Seed fp.step.template from the ENP-ALUM-BASIC recipe's child
   nodes if the library is currently empty. Uses _STARTER_KIND_BY_NAME
   to map recipe-step names to default_kind values, then calls
   _seed_default_inputs() to populate the per-kind input rows.
3. Falls back to a 15-entry hard-coded minimal seed list if
   ENP-ALUM-BASIC doesn't exist on the target DB.

All three operations no-op when the relevant state already exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:36:55 -04:00
gsinghpal
0862e55de6 feat(sub12a): Plating → Configuration → Step Library menu
Sequence 45 — between Process Types (40) and Bath Parameters (50).
Inherits the manager-only group from menu_fp_config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:36:08 -04:00
gsinghpal
738f3fcfd5 feat(sub12a): step library list/form/search views
Form: Title + Code + Classification (kind/icon/process/material) +
Stations & Flags + 4 notebook tabs (Instructions / Operation
Measurements / Transition Form / Advanced).

Operation Measurements + Transition Form are inline-editable o2m
lists with handle widget for drag reorder.

Header button: 'Seed Default Inputs' (visible only when default_kind
is set). Triggers the idempotent seeding helper.

NB: I removed `string=` from <search> per Odoo 19 rule (CLAUDE.md
critical rule 4 — no string attr on search).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:35:33 -04:00
gsinghpal
6fbb6f918b feat(sub12a): ACL rows for fp.step.template + 2 child models
Operator: read only on library + child inputs.
Supervisor: read/write/create on library; full CRUD on inputs.
Manager: full CRUD on all three.

Pattern matches existing fp_proficiency rows (supervisor without
unlink on the parent, full CRUD on the children — operators can't
delete a library template that recipes might reference, but
supervisors can edit/add/remove input rows freely).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:34:59 -04:00
gsinghpal
95debabc28 feat(sub12a): res.company.x_fc_default_recipe_editor setting
Per-company default for which editor opens for new recipes / recipes
with preferred_editor=auto. Defaults to 'tree' to preserve existing
behavior. Surfaces in Settings → Fusion Plating → Recipe Editor.

Naming follows the existing x_fc_* convention used throughout
res_company.py for company-level Fusion Plating defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:34:12 -04:00
gsinghpal
91681d722e feat(sub12a): extend process.node + process.node.input
process.node — additive only:
  is_template, source_template_id, tank_ids (M2M to fusion.plating.tank
  with new join table fp_node_tank_rel), material_callout, time/temp
  targets + units, voltage_target, viscosity_target,
  requires_rack_assignment, requires_transition_form, default_kind,
  preferred_editor.

process.node.input — additive only:
  kind (step_input/transition_input, default step_input so existing
  rows keep working), target_min/target_max/target_unit, compliance_tag,
  plus 9 new typed input_type values (time_hms, time_seconds,
  temperature, thickness, pass_fail, date, signature, location_picker,
  customer_wo).

No removed fields, no removed selection values. Tree editor + every
existing battle test (S14/S15/S17/S18/S19) untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:33:11 -04:00
gsinghpal
7a0e74c456 feat(sub12a): add fp.step.template.transition.input
Transition-time prompts (fired when leaving a step). Authored now,
runtime-consumed in Sub 12b's Move Parts dialog. Carries a
compliance_tag selection (none/as9100/nadcap/cgp/nuclear) so audit
reports can filter by regulation regime.

input_type covers Steelhead's transition prompts: text, number,
boolean, selection, date, signature, photo, location_picker,
customer_wo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:31:45 -04:00
gsinghpal
8bcd537737 feat(sub12a): add fp.step.template.input
Operation-measurement definitions for library step templates. The
input_type selection covers Steelhead's input shapes (text, number,
boolean, selection, date, signature, time_hms, time_seconds,
temperature, thickness, pass_fail).

target_min/max + target_unit are structured (not embedded in the name
string the way Steelhead does it) so the traveller report can render
target vs actual side-by-side and colour-code out-of-range values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:31:23 -04:00
gsinghpal
bef812616b feat(sub12a): add fp.step.template model with sane-default kind map
Reusable step library entry. Carries the same shape fields as
fusion.plating.process.node so a drag-drop snapshot is a 1:1 copy.
DEFAULT_INPUTS_BY_KIND drives seeding for the 15 kinds we identified
on Steelhead's job traveller (cleaning, etch, plate, bake, etc.).

The seeding helper (_seed_default_inputs) is idempotent — won't
duplicate inputs on repeated calls.

Note: imports for the 2 child models (input + transition_input) are
added in models/__init__.py here; the actual files land in the next
two commits. Module won't load cleanly on entech until both ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:30:45 -04:00
gsinghpal
7e98b48c01 feat(sub12a): bump fusion_plating to 19.0.10.0.0 + scaffold manifest
- Adds views/fp_step_template_views.xml to data list (after process_node_views).
- Adds simple_recipe_editor.{js,xml,scss} to web.assets_backend bundle.
- res_config_settings_views.xml + post_init_hook already wired — extend in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:29:37 -04:00
gsinghpal
cfe776be4c chore: session housekeeping — tank UX, plating menu defaults, WO label
- fusion_plating: tank field labels (Code → Tank Number, Tank → Tank Name)
  + state-control header buttons (Mark Empty/Filled/In Use/Draining/
  Maintenance/Out of Service) with chatter audit logging.
- fusion_plating_configurator: Plating app default landing screen = Sale
  Orders, while keeping menu name as 'Plating'.
- fusion_plating_jobs: SO smart-button label 'Plating Jobs' → 'WO'.

Already deployed and verified on entech earlier in the session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:27:35 -04:00
gsinghpal
c75b22aaf7 docs(sub12a): implementation plan — 15 tasks for simple editor + library
15-task plan covering: manifest bump, three new models (fp.step.template
+ 2 child input types), additive fields on process.node, ACL rows,
views, menu, post_init_hook with library-from-ENP-ALUM-BASIC seeding,
JSONRPC controller (11 routes), recipe form integration, OWL client
action, preferred_editor resolver, entech deployment + smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:22:20 -04:00
gsinghpal
4e4ca2c9da docs(sub12): simple recipe editor + library + tablet move/rack + reports
Three-part design (12a/12b/12c) for adding a flat drag-drop recipe
editor alongside the existing tree editor, with a reusable step
library, Steelhead-style Move Parts/Rack/Stop-Timer dialogs, and
recipe-order + chronological CoC PDF reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:23:25 -04:00
196 changed files with 19527 additions and 490 deletions

View File

@@ -62,7 +62,7 @@ class FpTankReading(models.Model):
'per-company without re-migrating history).',
)
unit = fields.Char(
string='Unit (raw)', related='parameter_id.uom', store=True,
string='Unit (raw)', related='parameter_id.uom_display', store=True,
)
# ------------------------------------------------------------------
@@ -93,7 +93,7 @@ class FpTankReading(models.Model):
r.display_unit = '°F'
else:
r.display_value = r.value
r.display_unit = r.parameter_id.uom or ''
r.display_unit = r.parameter_id.uom_display or ''
# ------------------------------------------------------------------
# Deviation from setpoint — signed Δ from the sensor's effective target

View File

@@ -239,7 +239,7 @@ class FpTankSensor(models.Model):
rec.effective_target_unit = '°F'
else:
rec.effective_target = raw
rec.effective_target_unit = rec.parameter_id.uom or ''
rec.effective_target_unit = rec.parameter_id.uom_display or ''
# ------------------------------------------------------------------
# Cached latest-reading fields (for quick display in list views)
@@ -276,7 +276,7 @@ class FpTankSensor(models.Model):
rec.last_reading_display_unit = '°F'
else:
rec.last_reading_display = rec.last_reading_value
rec.last_reading_display_unit = rec.parameter_id.uom or ''
rec.last_reading_display_unit = rec.parameter_id.uom_display or ''
reading_ids = fields.One2many(
'fp.tank.reading', 'sensor_id', string='Reading History',

View File

@@ -38,30 +38,48 @@ fusion_tasks/ — Local delivery dispatch (GPS, maps, driv
```
## Menu Structure (Plating App)
The Plating app (`menu_fp_root`, seq 46) has these top-level menus:
| Seq | Menu | Module | Children |
|-----|------|--------|----------|
| 3 | KPIs | fusion_plating_kpi | KPIs, KPI History, Production/Quality/Finance dashboards |
| 5 | Sales | fusion_plating_configurator + portal | Quotations, Sale Orders, Customers, Part Catalog, Quote Requests, Portal Jobs |
| 8 | Configurator | fusion_plating_configurator | New Quote, Coating Configs, Pricing Rules, Treatments |
| 12 | Shop Floor | fusion_plating_shopfloor | Plant Overview, Tablet Station, Bake Windows, First-Piece Gates |
| 15 | Receiving | fusion_plating_receiving | All Receiving, Pending Inspection, Discrepancies |
| 18 | Operations | fusion_plating (core) | Process Recipes, Production Priorities (bridge_mrp), Batches (batch), Baths, Chemistry Logs, Tanks |
| 25 | Certificates | fusion_plating_certificates | All, CoC, Thickness Reports |
| 30 | Quality | fusion_plating_quality | Holds, NCRs, CAPAs, FAIR, Audits, Doc Control |
| 40 | Compliance | fusion_plating_compliance | Permits, Discharge, Waste, Calendar, Spills, Config |
| 45 | Safety | fusion_plating_safety | SDS, Training, Exposure, JHSC, Incidents, PPE |
| 50 | Logistics | fusion_plating_logistics + fusion_tasks | Pickups, Deliveries, Routes, CoC, POD, Field Tasks, Task Map, Task Calendar |
| 60 | Aerospace | fusion_plating_aerospace | AS9100, Nadcap, Counterfeit, Config Items, Risk |
| 65 | Nuclear | fusion_plating_nuclear | Program, ITP, 10CFR21, Pedigree, CNSC |
| 70 | CGP | fusion_plating_cgp | Registration, AI, PSA, Visitors, Goods, Shipments, Security, Access Log |
| ~~80~~ | ~~Culture~~ | ~~fusion_plating_culture~~ | ~~Values, Recognitions~~ **— RETIRED, uninstalled on entech, code kept in repo only** |
| 90 | Configuration | fusion_plating (core) + many | Facilities, Work Centres, Process Categories/Types, Bath Params, Stations, Ovens, Invoice Strategy, Account Holds, Training Types, Chemicals, Notification Templates/Log, Calibration, Specs, AVL, Value Sets/Rotations, N299 Levels, Vehicles |
> **Updated 2026-04-28** — Phase 1/2/3 menu reorg consolidated 17 top-levels down to 6 (operator-visible). Industry verticals (Safety/Aerospace/Nuclear/CGP) moved INSIDE a new Compliance hub. Configuration regrouped into 7 themed folders. See the "Phase 1 / 2 / 3 — Menu reorganization" section near the bottom of this file for the full record.
**Field Service** (`fusion_tasks`) also has its own standalone root app (seq 45) with Map View, Tasks, Calendar, Configuration. The same task actions are also accessible under Plating > Logistics.
The Plating app (`menu_fp_root`, seq 46) opens via the landing-page resolver (`action_fp_resolve_plating_landing`) — user override → company default → Sale Orders fallback.
**Key rule**: Sales menu is unified in `fusion_plating_configurator`. Portal module adds Quote Requests + Portal Jobs as children (referencing `fusion_plating_configurator.menu_fp_sales`). Do NOT create a separate Sales menu in portal.
**Top-level menus (manager view):**
| Seq | Menu | Module(s) | Visibility |
|-----|------|-----------|------------|
| 5 | Sales & Quoting | fusion_plating_configurator + portal | estimator + supervisor |
| 8 | Configurator | fusion_plating_configurator | estimator |
| 12 | Shop Floor | fusion_plating_shopfloor | operator |
| 15 | Receiving & Shipping | fusion_plating_receiving + logistics | receiving role |
| 18 | Operations | fusion_plating (core) | open (children gate per-action) |
| 30 | Quality | fusion_plating_quality + certificates | operator |
| 50 | Compliance (hub) | fusion_plating + 5 vertical modules | supervisor+ |
| 85 | KPIs | fusion_plating_kpi | supervisor+ |
| 90 | Configuration | fusion_plating + many | manager-only |
**Children re-parented in Phase 1**:
- Operations now contains: Process Recipes, Baths, Chemistry Logs, Tanks, Racks & Fixtures, **Maintenance** (was top-level), **Move Log** (was top-level, supervisor+), **Labor History** (was top-level), Replenishment Suggestions (supervisor+).
- Quality now contains: Holds, NCRs, CAPAs, RMAs, FAIR, Audits, Doc Control, **Certificates** (was top-level).
- Compliance hub now contains: General, Safety / WHMIS, Aerospace (AS9100 / Nadcap), Nuclear (CSA N299 / CNSC), Controlled Goods (CGP).
**Configuration's 7 themed folders** (manager-only by inheritance from `menu_fp_config`):
1. **Shop Setup** — Facilities, Production Lines (was "Work Centers"), Routing Stations (was "Work Centres"), Process Categories, Process Types, Bake Ovens, Shopfloor Stations, Vehicles
2. **Recipes & Steps** — Step Library, QC Checklist Templates, Quality Points
3. **Materials & Tanks** — Bath Parameters, Replenishment Rules, Chemicals, Rack Tags, Calibration Equipment, Calibration Events
4. **Workforce** — Operator Certifications, Shop Roles, Training Types, Quality Teams
5. **Quality & Documents** — Customer Specs, Approved Vendor List, Quality Tags / Reasons / Stages, N299 Levels, Notification Templates, Notification Log
6. **Pricing & Billing** — Invoice Strategy Defaults, Account Holds
7. **Reference Data** — Value Sets, Value Rotations
Plus **Settings** (sequence 1, sibling above the 7 folders).
**Field Service** (`fusion_tasks`) still has its own standalone root app (seq 45). Same task actions also accessible under Plating → Receiving & Shipping.
**Culture (seq 80)** — RETIRED, uninstalled on entech; the menu still defines itself in repo but doesn't appear on the live system.
**Key rules**:
- Sales menu unified in `fusion_plating_configurator`. Portal adds Quote Requests + Portal Jobs as children. Do NOT create a separate Sales menu in portal.
- New top-level menus should be a LAST resort. Most new functionality belongs as a child of one of the 6 existing top-levels. Adding to Configuration goes into the right themed folder.
- When adding a new bucket folder to Configuration, define it in `fusion_plating/views/fp_menu.xml` near the top (Odoo's data loader is strictly sequential — every parent xmlid must be defined before any child references it).
## Retired / Do-Not-Install Modules
@@ -795,6 +813,120 @@ UNION ALL SELECT 'check', count(*) FROM fusion_plating_quality_check;
---
## Contract Review — Policy B (shipped 2026-04-28)
The `fp.contract.review` model (QA-005) was originally shipped as
"always optional, never blocks anything" (Sub 4). Audit 2026-04-28
revealed three integration holes:
1. The **Simple Recipe Editor library** had no Contract Review step
template, so authors couldn't drop QA-005 into a recipe at all.
2. Adding a node literally named "Contract Review" to a recipe did
**nothing** — no auto-create, no operator routing, no gate.
3. The pre-Sub-11 `contract_review_user_ids` approver list on
`fp.process.node` was dead — `mrp.workorder.button_finish` used to
gate on it, but `fp.job.step` never picked up the gate.
**Policy B (chosen 2026-04-28)** — Contract Review is REQUIRED on a
per-customer basis (`partner.x_fc_contract_review_required`), soft
elsewhere. Recipe-side enforcement closes the post-Sub-11 hole.
### What's wired
| Trigger | Behaviour |
|---|---|
| `fp.step.template.default_kind = 'contract_review'` | New kind in the Simple Editor library. Auto-seeds 3 inputs: Reviewer Initials / Date Reviewed / QA-005 Approved (pass_fail). |
| Library seeders (`_STARTER_KIND_BY_NAME`, `_seed_minimal_library`) | "Contract Review" is the FIRST entry in the minimal library. Authors drag-drop it into recipes from the Simple Editor sidebar. |
| `fp.job.step.button_start` on a Contract Review step | Auto-creates `fp.contract.review` for the linked part if missing, returns an act_window pointing at the QA-005 form. Operator gets routed straight to the form without hunting for the smart button on the part. |
| `fp.job.step.button_finish` on a Contract Review step | Blocks unless `fp.contract.review.state == 'complete'` AND current user is on `recipe.contract_review_user_ids` (when configured). Manager bypass: `fp_skip_contract_review_gate=True` in context. |
| Step detection | `_fp_is_contract_review_step()` matches case-insensitive name == "contract review" / "qa-005" OR `recipe_node_id.source_template_id.default_kind == 'contract_review'` (simple-editor library entry). |
### What stays optional (NOT enforced)
- Customers without `x_fc_contract_review_required=True` get the soft
banner only — no step-level block. The customer-flag gate is the
ONLY enforcement trigger.
- Adding a Contract Review node to a recipe for a customer that
doesn't require it is purely documentary; nothing fires.
### Why the part-side banner stays
The part-form banner ("New part created. Please complete the Contract
Review (QA-005) if applicable.") is independent of the recipe step.
It nudges QA before any job is started — an early-detection mechanism
distinct from the in-flight step gate. Both can fire on the same part
(banner first, then step gate later); one resolution clears both.
### Manager bypass examples
```python
# Skip the step-level gate from a privileged caller (script / shell)
step.with_context(fp_skip_contract_review_gate=True).button_finish()
```
### Files touched
- `fusion_plating/models/fp_step_template.py` — added `contract_review`
kind + 3 default inputs.
- `fusion_plating/models/fp_process_node.py` — **also added
`contract_review` to `default_kind` Selection here.** Easy to miss:
the node and the template have separate Selection fields and they
must stay in lockstep.
- `fusion_plating/__init__.py` — added "Contract Review" / "QA-005" to
`_STARTER_KIND_BY_NAME` + first entry in `_seed_minimal_library`,
exposed `fp_resolve_step_kind()` helper.
- `fusion_plating_jobs/models/fp_job_step.py` — added
`_fp_is_contract_review_step`, `_fp_resolve_contract_review_part`,
`_fp_open_contract_review`, `_fp_check_contract_review_complete`;
hooked into `button_start` (auto-open form) + `button_finish`
(gate). Sub 11's `contract_review_user_ids` field on
`fp.process.node` is now wired again.
### Bugs caught during the persona walkthrough (2026-04-28, fixed 12.4.1)
A scripted "brand-new estimator builds a recipe from scratch" walk
(`/tmp/fp_recipe_walkthrough.py` on entech) surfaced 7 real gaps; all
fixed in 19.0.12.4.1. The walk is preserved as a smoke test —
re-runnable on any DB to verify the library is healthy.
| # | Bug | Fix |
|---|---|---|
| 1 | `_seed_step_library_if_empty` skips when the library is non-empty, so existing DBs got NO Contract Review template after Policy B shipped. | Migration `19.0.12.4.1/post-migrate.py` — backfills the template if missing. |
| 2 | `fp.process.node.default_kind` Selection didn't include `contract_review`, so dropping the template into a recipe blew up with `ValueError`. The kind is on TWO models (template + node) and they drifted. | Added `contract_review` to the node's Selection too. |
| 3 | The library had only `racking` populated as a kind (1/16). 12 of 14 templates landed with `default_kind = NULL` because the original seeder used a brittle case-sensitive lookup. | Migration backfills `default_kind` via the new `fp_resolve_step_kind()` helper. |
| 4 | `_STARTER_KIND_BY_NAME` lookup was hyphen / -ing / case sensitive — "E-Nickel Plating" didn't match `'e-nickel plate'`, "DeRacking" didn't match `'de-racking'`, "Ready For Masking" didn't map to `gating`. | Expanded the lookup with 30+ alias entries + a "Ready for X → gating" prefix rule in `fp_resolve_step_kind()`. |
| 5 | The library was missing the canonical names a fresh estimator would type from scratch (Soak Clean, Rinse, Etch, Acid Dip, Desmut, Zincate, Drying, Inspection, Shipping, Water Break Test). The ENP-ALUM-BASIC seed included only the names from that one recipe. | Migration adds 13 canonical missing entries (Soak Clean, Electroclean, Rinse, Etch, Desmut, Zincate, Acid Dip, HCl Activation, Water Break Test, Drying, Inspection, Final Inspection, Shipping, Contract Review). |
| 6 | `_seed_minimal_library` (the fresh-DB fallback path) had only 15 entries, didn't include Contract Review, and used English names that don't match the 30+ aliases. | Added "Contract Review" as the first entry. Library is now bigger, but `fp_resolve_step_kind()` is the canonical way authors will get coverage. |
| 7 | `DEFAULT_INPUTS_BY_KIND` in `fp_step_template.py` still had free-text `target_unit` values (`'HH:MM'`, `'°F'`, `'sec'`, `'in'`, `'each'`) left over from before the 19.0.12.1.0 UoM cleanup. `action_seed_default_inputs()` blew up with `Wrong value for target_unit: 'HH:MM'` when called against the new Selection-typed column. | Translated to selection keys: `'sec' → 's'`, `'°F' → 'f'`, `'in' → 'in'`, `'each' → 'each'`, `'min' → 'min'`. Format-only strings (`'HH:MM'`) dropped — they're not units. |
The walkthrough script is checked into context at
`/tmp/fp_recipe_walkthrough.py` (rerun via odoo shell) and is the
recommended smoke test before any future library / step-template
changes ship.
---
## Record Inputs Wizard — ad-hoc rows (shipped 2026-04-28)
The backend `Record Inputs` button on the job-form Steps tab opened
an empty wizard when the recipe step had no `step_input` prompts
authored — operator had no way to log anything. Fixed by:
- Making `node_input_id` optional on
`fp.job.step.input.wizard.line`. Authored prompts still show
pre-filled + readonly; ad-hoc rows are fully editable (operator types
the prompt label + value).
- View now shows a helpful empty-state hint and an `Add a line` button.
- Commit step requires every ad-hoc row to have a Prompt label, then
serialises it into `value_text` of the resulting
`fp.job.step.move.input.value` (format `Prompt: value [unit]`) so
the chronological CoC report still renders the captured data.
Files: `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py` +
`fp_job_step_input_wizard_views.xml`.
---
## Battle Tests — Real-World Operator Scenario Coverage
Persona-driven shop-floor scenarios that surfaced bugs / workflow holes. Every scenario has:
@@ -890,3 +1022,319 @@ The S20 walkthrough mapped 6 OWL apps (`fp_shopfloor_tablet`, `fp_plant_overview
- `step_internal_full.py` — full pause/resume/skip/bake-spawn walk
To re-test the whole battle suite after a future change, run each `bt_s*.py` in sequence and confirm green.
---
## Sub 12a / 12b / 12c — Simple Recipe Editor + Tablet Move/Rack/Timer + Reports (shipped 2026-04-27/28)
Three sequential sub-projects implementing Steelhead-replacement features for clients who prefer a simpler UX over the existing tree editor. All shipped on entech.
**Spec**: [docs/superpowers/specs/2026-04-27-sub12-simple-recipe-editor-design.md](docs/superpowers/specs/2026-04-27-sub12-simple-recipe-editor-design.md) (full design)
**Steelhead screen inventory**: [docs/superpowers/specs/2026-04-27-simple-recipe-editor-steelhead-screens.md](docs/superpowers/specs/2026-04-27-simple-recipe-editor-steelhead-screens.md) (24 screens)
### Sub 12a — Simple Recipe Editor + Step Library (versions: fusion_plating 19.0.10.0.0)
**New models:**
- `fp.step.template` — reusable step library; tank_ids, target ranges (time/temp/voltage/viscosity), `default_kind` selection (15 kinds), input_template_ids + transition_input_ids, `_seed_default_inputs()` helper.
- `fp.step.template.input` — operation-measurement definitions (during step). 11 input_types: text, number, boolean, selection, date, signature, time_hms, time_seconds, temperature, thickness, pass_fail.
- `fp.step.template.transition.input` — compliance prompts fired on move-out. 9 input_types incl. photo, location_picker, customer_wo. compliance_tag selection (none/as9100/nadcap/cgp/nuclear).
**Additive fields on `fusion.plating.process.node`** (zero impact on tree editor):
- `is_template` Boolean (recipe-level — appears in Import Starter dropdown).
- `source_template_id` M2O `fp.step.template` (snapshot trace; no live coupling).
- `tank_ids` M2M to `fusion.plating.tank` (via new join table `fp_node_tank_rel`).
- `material_callout`, `time_min/max_target`, `time_unit`, `temp_min/max_target`, `temp_unit`, `voltage_target`, `viscosity_target`.
- `requires_rack_assignment`, `requires_transition_form`, `default_kind`, `preferred_editor` (tree/simple/auto).
**Additive fields on `fusion.plating.process.node.input`**:
- `kind` Selection (`step_input` / `transition_input`, default `step_input`).
- `target_min`, `target_max`, `target_unit`, `compliance_tag`.
- 9 new typed input_type values appended (existing values preserved).
**Settings**: `res.company.x_fc_default_recipe_editor` (tree/simple).
**OWL client action**: `fp_simple_recipe_editor` — flat 2-pane drag-drop layout. Library on right, Selected on left. HTML5 drag-drop with two distinct dataTransfer types (`application/x-fp-step` vs `application/x-fp-library`) so the drop handler knows whether to reorder or snapshot-copy. Drop-position simulator (commit `3098fcf`): green dashed reservation line snaps above/below each row based on cursor Y vs row midpoint, with ghost-preview chip showing dragged step's icon + name. 80ms transition glides between slots.
**11 JSONRPC routes** under `/fp/simple_recipe/...`:
- `load`, `library/{list,create,write,delete}`, `step/{insert,write,remove,reorder}`, `template/{list,import}`.
- Library + template imports SNAPSHOT-COPY fields (Q4 = A locked) — editing a library template later does NOT mutate recipes already built.
- `library/delete` is soft when any node references the template via `source_template_id`.
**Recipe form integration**: 2 header buttons (Open Tree Editor / Open Simple Editor), is_template + preferred_editor fields, new "Step Authoring" notebook page for step/operation nodes.
**`_resolve_preferred_editor()`** + `action_open_recipe_with_preferred_editor()` — per-recipe preferred_editor wins; `auto` falls back to company default; final fallback `tree`.
**Menu**: Plating → Configuration → Step Library (later moved to Configuration → Recipes & Steps in Phase 2).
**post_init_hook**: backfills `kind='step_input'` on existing process.node.input rows; seeds 1318 starter library templates from ENP-ALUM-BASIC recipe (idempotent — won't re-seed).
**Naming gotcha**: `_seed_default_inputs` was originally underscore-prefixed which Odoo 19 rejects when called from a view button — renamed to `action_seed_default_inputs` (commit `5494684`). Public name required for any method called from XML buttons.
### Sub 12b — Move Parts / Move Rack / Rack Parts / Stop Timer dialogs (versions: fusion_plating 19.0.10.1.0, fusion_plating_shopfloor 19.0.25.0.0)
**Decisions adjusted from the original spec:**
- `fusion.plating.rack` already existed (wear-tracking model with `state` selection). Sub 12b adds an ORTHOGONAL `racking_state` field for the load lifecycle. The two states coexist — a rack can be wear-active AND racking-loaded simultaneously.
- `fp.labor.timer` was NOT created. Instead, the existing `fp.job.step.timelog` (used by S1/S2 battle tests) is extended with a state machine. Single source of truth for labor; preserves S1/S2 paths.
- `fp.job.step.rack_id` already existed and is reused as the "current rack on this step" pointer (no new `current_rack_id`).
**New models:**
- `fp.rack.tag` — M2M tag registry (Rush / Hold for QC / Damaged / Customer Sample seeded by post_init_hook).
- `fp.job.step.move` — chain-of-custody log, one row per Move Parts/Rack commit. FP/MOVE/YYYY/NNNN sequence. Carries from/to step + tank, transfer_type (step/hold/scrap/rework/split/return), qty_moved, to_location, photo_evidence_id, customer_wo_count, rack_id, moved_by_user_id.
- `fp.job.step.move.input.value` — captured transition prompt values per move. Typed dispatch on input_type → correct value_text/number/boolean/date/attachment column.
**Extended `fusion.plating.rack`**:
- `racking_state` (empty/loading/loaded/in_use/awaiting_unrack/out_of_service) — orthogonal to existing wear `state`.
- `tag_ids` M2M, `capacity_count` (soft warn), notes.
- `current_job_step_id`, `current_tank_id`, `current_part_count` (computes that walk fp.job.step.move history).
**Extended `fp.job.step`**:
- `requires_rack_assignment`, `requires_transition_form` (related from recipe_node_id).
- `move_ids` (O2M from_step_id), `incoming_move_ids` (O2M to_step_id).
- `is_racked` (compute, stored, depends rack_id) — drives tablet rack-vs-parts greyed-button guard.
- `qty_at_step_start`, `qty_at_step_finish`.
**Extended `fp.job`**: qty_received, qty_visual_inspection_rejects, qty_rework, special_requirements, active_timer_ids (filtered O2M), move_ids.
**Extended `fp.job.step.timelog` with persistent state machine**:
- `state` Selection (running/paused/stopped/reconciled, default running — preserves S1/S2).
- `last_paused_at`, `total_paused_seconds`, `accrued_seconds` (compute).
- `billed_hrs/min/sec`, `billed_total_seconds`, `billed_pct` (compute).
- `product_id` (split-by-product reconciliation), `notes`.
- `job_id` (related, indexed) for fast O2M from `fp.job.active_timer_ids`.
**12 tablet controller endpoints** in `fusion_plating_shopfloor/controllers/move_controller.py`:
- Move Parts: `/preview`, `/commit`
- Move Rack: `/preview`, `/commit`
- Rack Parts: `/commit`
- Rack picker: `/rack/list_empty`, `/rack/scan_qr`
- Persistent labor timer: `/labor_timer/{start,pause,resume,stop,reconcile}`
**Manager-bypass context flags** (consistent with existing fp_skip_* protocol): `fp_skip_predecessor_check`, `fp_skip_rack_assignment`, `fp_skip_transition_form`. All bypasses post to chatter on the move record naming the user + which flags fired. Manager group check enforced.
**`_safe()` wrapper**: UserError → JSONRPC-friendly `{ok: False, error: msg}` so OWL components show a flash without crashing.
**4 OWL dialogs** (in `fusion_plating_shopfloor/static/src/js/`):
- `move_parts_dialog.js` — mirror of Steelhead screens 1-3, 14-15. System-derived top section (Part Count / From Node / To Node / Transfer Type / To Station / To Location with camera button). Compliance Prompts section renders authored transition_input_ids. Blockers section (NEW pattern, our improvement over Steelhead): each blocker has inline Resolve button. Soft (amber + button enabled) vs hard (amber + button disabled with tooltip listing reasons). MOVE button greys out when blocked.
- `move_rack_dialog.js` — atomic multi-batch move. Rack name in title, tag chips, batches list, Type + To Node + To Station picker.
- `rack_parts_dialog.js` — searchable empty-rack picker, QR Scan input, Unit + Amount fields. Save / Save+Print (the latter opens `/report/pdf/fusion_plating_reports.action_report_fp_rack_travel/<id>` — gap closed in Sub 12c+ commit `7d3b8f1`).
- `stop_timer_dialog.js` — opens with state already at `stopped` (server flips on load), pre-fills billed_* from accrued. Cancel / Save / Save & Start New Timer (chains into a fresh timer for the same step).
**Custom event protocol**: `fp-resolve-rack` window CustomEvent fired from Move Parts dialog when operator clicks Resolve on a rack-required blocker → tablet listens → spawns Rack Parts sub-dialog inline. Cleanup on unmount.
**Shopfloor tablet** (`shopfloor_tablet.js`): wired Move Parts + Stop Timer button handlers; `dialog` service injected; rack-resolve event listener with cleanup on `onWillUnmount`.
**Plant overview** (`plant_overview.js` + XML): new top "Racks" pane shows racks in (loaded/in_use/awaiting_unrack) state with tag chips, current_part_count, breadcrumb (current node + tank code), `MOVE RACK` button per row. Backend `/fp/shopfloor/plant_overview` extended to return `racks` array alongside the existing parts/batches.
**Operator UX rule**: `fp.job.step.is_racked` drives the tablet's MOVE PARTS button grey-out. Operator MUST go through MOVE RACK when batch is racked — enforced by disabled button state, not error message.
**post_init_hook**: seeds 4 starter rack tags (idempotent).
**Deploy gotcha**: `to_step_id` was originally `required=True, ondelete='set null'` — Odoo 19 disallows that combination. Switched to `ondelete='restrict'` (commit `e718a47`). Audit-safety bonus: destination steps can't be unlinked while move-log rows reference them.
### Sub 12c — Reports + Labor History screen (versions: fusion_plating 19.0.10.2.0, fusion_plating_jobs 19.0.7.0.0, fusion_plating_reports 19.0.10.0.0, fusion_plating_certificates 19.0.5.3.0)
Re-scoped from the original 18-task plan to 5 tasks after auditing existing artifacts: `report_coc_en` / `report_coc_fr` already had Nadcap / AS9100 / CGP infrastructure built into `fusion_plating_reports`. `company.x_fc_nadcap_logo` etc. already existed.
**Operator Traveller v2** (`fusion_plating_jobs/report/report_fp_job_traveller.xml`):
- A4 landscape paper-style (matching Amphenol screens 16-18), replaces the minimal portrait template.
- Header: company logo + Code 128 barcode + WO# + Date In + Due Date + Type + Order# + PO# + WO-Generated-By + customer block.
- Item Information: Part# / Rev / Mat / Catg / S/N + Item-Name + Qty Rec / VIS INSP / Rework / Special Requirements / Stamp-Date.
- Process-Sheet header: recipe name + category + spec/info.
- Routing table (11 cols): Step / Tank / Operation+Actuals / Instruction / Unit / Material / Voltage / Time(min) / Temp / Stamp / Date.
- Targets pulled from recipe-node fields when present (Sub 12a authored), 'N/A' otherwise.
- Defensive QWeb — every cross-module field guarded via `'X in record._fields'`.
- New paperformat `paperformat_fp_traveller_landscape`.
**Chronological CoC body** (`fusion_plating_reports/report/report_coc_chronological.xml`):
- New `coc_body_chronological` template walks `fp.job.step.move` records ordered by `move_datetime`.
- Per-move heading `<step.name> (<tank.code>)` + "Moved By / Time / Qty" meta line.
- 5-column measurement sub-table (Name / Description / Target / Actual / Recorded By) when destination step has captured inputs OR move has captured `transition_input_value_ids`.
- Actual column (gap-fix commit `7d3b8f1`): builds `captured_values_by_input` dict from `mv.transition_input_value_ids`, renders typed values (text as-is, number with target unit, boolean as PASS/FAIL, datetime formatted, attachment placeholder).
- New router template `coc_body_router` picks chronological vs classic body via `fp.certificate.body_style` field.
- Both English + French CoC actions (`report_coc_en`, `report_coc_fr`) rerouted through the router. Existing certs default to `classic` so no regressions.
**`fp.certificate.body_style`** Selection (classic/chronological), default classic. Surfaced on cert form alongside certified_by_id.
**Per-customer cert statement (gap-fix `7d3b8f1`)**: 3-tier resolution.
- `res.partner.x_fc_cert_statement` Text (per-customer override, surfaced on partner form under Cert + Document Routing block).
- `res.company.x_fc_default_cert_statement` Text (company-level fallback).
- Hardcoded AS9100 / ISO 9001 boilerplate as final fallback.
**Rack Travel Ticket PDF (gap-fix `7d3b8f1`)** in `fusion_plating_reports/report/report_fp_rack_travel.xml`:
- A5 landscape, 28pt rack name, Code 128 barcode of `FP-RACK:<name>`, tag chips, contained-batches table (qty / part number / WO / customer / current step).
- Bound to `fusion.plating.rack` model — appears in the rack form's Print menu.
- Closes Sub 12b's Save+Print 404 placeholder.
**Labor History screen** (`fusion_plating/views/fp_job_step_timelog_views.xml`):
- Plating → Operations → Labor History (sequence 64).
- List view colour-coded by state, with `billed_pct` progressbar.
- 8 search filters (My Timers default, Today, Running, Paused, Pending Reconciliation, Reconciled) + Group-by Operator/Job/Date.
- Form view: identity readonly, billed_hrs/min/sec editable for supervisors+ until `state=reconciled`. `create=false` (timers are runtime-produced via tablet).
- ACL rows for `fp.job.step.timelog`: operator (rwc, no unlink), supervisor (rwc, no unlink), manager (full).
### Other sub-12 era ergonomics shipped in this session
- **Tank model** (commit `cfe776b`): `code` → "Tank Number", `name` → "Tank Name". Header buttons for state transitions (Mark Empty/Filled/In Use/Draining/Maintenance/Out of Service) with chatter audit logging.
- **Plating app default landing screen** (commit `cfe776b`): `menu_fp_root.action` → `action_fp_sale_orders` (later replaced by Phase 1 resolver server action).
- **WO label** (commit `cfe776b`): SO smart-button "Plating Jobs" → "WO".
- **Drop-position simulator** in Simple Recipe Editor (commit `3098fcf`): green dashed reservation line + ghost chip showing exactly where the drop will land. Snaps above/below row midpoint based on cursor Y. 80ms transition.
---
## Phase 1 / 2 / 3 — Menu reorganization (shipped 2026-04-28)
Customer feedback: "too many top-level menus" + "configuration is unorganized". Three-phase reshuffle reduces 17 top-levels to 6 (operator-visible), groups the flat 36-entry Configuration into 7 themed folders, and tightens role-based visibility.
### Phase 1 — Top-level consolidation + landing-page resolver (`fusion_plating` 19.0.11.0.0, commit `0ad382e`)
**New top-level structure (manager view):**
```
🏭 Plating (action = landing resolver — see below)
├── 📊 KPIs [seq 85, supervisor+]
├── 💰 Sales & Quoting (Sales + Configurator)
├── 🔧 Operations [seq 18]
│ ├── Process Recipes, Baths, Chemistry Logs, Tanks, Racks
│ ├── Replenishment Suggestions [Phase 3: supervisor+]
│ ├── Maintenance [Phase 1: re-parented from top]
│ ├── Move Log [Phase 1+3: re-parented + supervisor+]
│ └── Labor History [Phase 1: re-parented from top]
├── 📦 Receiving & Shipping
├── ✅ Quality
│ └── Certificates [Phase 1: re-parented from top]
├── 📋 Compliance [seq 50, supervisor+]
│ ├── General ← was top-level Compliance
│ ├── Safety / WHMIS ← was top-level Safety
│ ├── Aerospace (AS9100 / Nadcap) ← was top-level
│ ├── Nuclear (CSA N299 / CNSC) ← was top-level
│ └── Controlled Goods (CGP) ← was top-level
└── ⚙ Configuration [seq 90, manager-only]
```
**Re-parented (no XML id changes — bookmarks still work):**
- `fusion_plating_compliance.menu_fp_compliance_root` → `menu_fp_compliance_hub` (renamed 'General')
- `fusion_plating_safety.menu_fp_safety_root` → `menu_fp_compliance_hub` (renamed 'Safety / WHMIS')
- `fusion_plating_aerospace.menu_fp_aerospace` → `menu_fp_compliance_hub` (renamed 'Aerospace (AS9100 / Nadcap)')
- `fusion_plating_nuclear.menu_fp_nuclear` → `menu_fp_compliance_hub` (renamed 'Nuclear (CSA N299 / CNSC)')
- `fusion_plating_cgp.menu_fp_cgp` → `menu_fp_compliance_hub` (renamed 'Controlled Goods (CGP)')
- `fusion_plating_certificates.menu_fp_certificates` → `fusion_plating_quality.menu_fp_quality`
- `fusion_plating_bridge_maintenance.menu_fp_maintenance` → `fusion_plating.menu_fp_operations`
- `fusion_plating.menu_fp_job_step_move` (Move Log) → `menu_fp_operations`
- `fusion_plating.menu_fp_job_step_timelog` (Labor History) → `menu_fp_operations`
**Landing-page resolver** (`fusion_plating/data/fp_landing_data.xml`):
- `ir.actions.server` named `action_fp_resolve_plating_landing`. Code in the action: user override → company default → Sale Orders fallback.
- `menu_fp_root` rewired to call this server action.
- New fields:
- `ir.actions.act_window.x_fc_pickable_landing` — Boolean tag for curated picklist.
- `res.company.x_fc_default_landing_action_id` — admin sets fallback.
- `res.users.x_fc_plating_landing_action_id` — per-user override.
- UI surfaces in `fusion_plating/views/fp_landing_views.xml`:
- User Profile / Preferences → Fusion Plating tab (per-user dropdown).
- Settings → Fusion Plating → Plating Landing Page block (company default).
- `fusion_plating_configurator`'s earlier menu_fp_root override (action_fp_sale_orders direct) was removed — core's resolver now owns the routing.
- Pickable list is curated via inline `<field name="x_fc_pickable_landing" eval="True"/>` on action records — currently flagged: `action_fp_sale_orders`, `action_fp_quotations`, `action_fp_process_recipe`. Add more by tagging the relevant act_window record at its source.
### Phase 2 — Configuration sub-folder grouping (`fusion_plating` 19.0.11.1.0, commits `3641b78` + `62c1315` + `4671541`)
**7 themed folders + Settings sibling:**
```
⚙ Configuration [manager-only]
├── ⚡ Settings (sequence 1, sibling)
├── 🏢 Shop Setup (10)
│ ├── Facilities, Production Lines, Routing Stations,
│ ├── Process Categories, Process Types,
│ └── Bake Ovens, Shopfloor Stations, Vehicles
├── 📜 Recipes & Steps (20)
│ └── Step Library, QC Checklist Templates, Quality Points
├── 🧪 Materials & Tanks (30)
│ ├── Bath Parameters, Replenishment Rules, Chemicals,
│ └── Rack Tags, Calibration Equipment, Calibration Events
├── 👥 Workforce (40)
│ └── Operator Certifications, Shop Roles, Training Types, Quality Teams
├── 📝 Quality & Documents (50)
│ ├── Customer Specs, Approved Vendor List,
│ ├── Quality Tags, Quality Reasons, Quality Stages, N299 Levels,
│ └── Notification Templates, Notification Log
├── 💵 Pricing & Billing (60)
│ └── Invoice Strategy Defaults, Account Holds
└── 🔁 Reference Data (70)
└── Value Sets, Value Rotations
```
**The 7 bucket folders are defined in `fusion_plating/views/fp_menu.xml`**. Touched 11 module XML files to re-parent existing children:
- `fusion_plating_invoicing` → Pricing & Billing
- `fusion_plating_notifications` → Quality & Documents
- `fusion_plating_safety` → Workforce + Materials & Tanks
- `fusion_plating_shopfloor` → Shop Setup
- `fusion_plating_logistics` → Shop Setup (Vehicles)
- `fusion_plating_culture` → Reference Data
- `fusion_plating_nuclear` → Quality & Documents (N299 Levels)
- `fusion_plating_quality` → Materials & Tanks (Calibration), Quality & Documents (Specs/AVL/Tags/Reasons/Stages), Workforce (Quality Teams), Recipes & Steps (Quality Points + QC Templates)
**Critical load-order rule (caught by entech upgrade `62c1315` + `4671541`):**
- Every parent menuitem MUST be defined before any child references it by xmlid. Odoo's data loader is strictly sequential — within a single XML file AND across the manifest's `data` list.
- `fp_menu.xml` was reorganized so its declaration order is: Root → Configuration + 7 buckets → Compliance hub → Operations parent → all children.
- The manifest's `data` list was reordered to load `views/fp_menu.xml` BEFORE any view file that references the bucket xmlids (e.g. `fp_rack_tag_views.xml`, downstream module views).
- Lesson for future menu reshuffles: when adding a new bucket folder, define it in `fp_menu.xml` near the top, AND make sure that file loads early in the manifest data list.
### Phase 3 — Tightened group-gating (`fusion_plating` 19.0.11.2.0, `fusion_plating_kpi` 19.0.1.1.0, commit `5f6c7af`)
**Three targeted gates so operators no longer see admin/audit views:**
- `menu_fp_dashboard` (KPIs) → `groups="fusion_plating.group_fusion_plating_supervisor"`. Operators don't need dashboards.
- `menu_fp_job_step_move` (Move Log) → supervisor+. Operators see their own moves on the tablet; this top-level menu is the audit-of-everyone-else view.
- `menu_fp_replenishment_suggestions` → supervisor+. Purchasing decision, not operator concern.
**Net effect by role:**
| Top-level | Operator | Supervisor | Manager |
|---|:-:|:-:|:-:|
| Sales / Configurator | — | ✓ (if estimator) | ✓ |
| Shop Floor | ✓ | ✓ | ✓ |
| Operations | ✓ | ✓ | ✓ |
| Receiving & Shipping | ✓ (if receiving) | ✓ | ✓ |
| Quality | ✓ | ✓ | ✓ |
| KPIs | — | ✓ | ✓ |
| Compliance (hub) | — | ✓ | ✓ |
| Configuration | — | — | ✓ |
Operator now sees ~5 top-level menus instead of the previous ~10.
### Production Line / Routing Station rename (commit `afcd128`, `fusion_plating` 19.0.11.3.0)
Two distinct entities were both labelled "Work Centre" / "Work Centers" — only the US/UK spelling differentiated them. Renamed by purpose:
| Model | Old display | New display | What it is |
|---|---|---|---|
| `fusion.plating.work.center` | Work Centers | **Production Lines** | Physical shop-layout grouping that owns tanks. Has `tank_ids`, `supported_process_ids`, `capacity_per_day`. |
| `fp.work.centre` | Work Centres | **Routing Stations** | Per-job-step routing entity (post-Sub-11 mrp.workcenter replacement). Has `kind` (wet_line/bake/mask/rack/inspect), `cost_per_hour`, `default_bath_id`, `default_tank_id`. |
Conceptually a Production Line CONTAINS many Routing Stations.
Model IDs unchanged (12 + 9 cross-refs preserved). Updated: `_description` on both models, `string=` on name fields, list/form/search view strings, act_window names, menu items, doc comments.
---
## Naming convention recap (Plating menu hierarchy as of 2026-04-28)
When adding a new menu, default to one of these 6 top-level homes:
- **Sales & Quoting** — quote/order workflows, customers, parts catalog
- **Operations** — recipes, baths, tanks, racks, jobs, move log, labor, maintenance
- **Receiving & Shipping** — inbound/outbound logistics
- **Quality** — holds, NCRs, CAPAs, certificates, FAIR, audits, doc control
- **Compliance** (hub) — General / Safety / Aerospace / Nuclear / CGP
- **Configuration** (manager-only) — Settings + 7 themed folders
Avoid creating a new TOP-LEVEL menu under `menu_fp_root` unless it's a genuinely new domain. Most new functionality belongs as a child of an existing top-level.
When adding a new admin config, drop it into the right Configuration folder:
- Equipment / physical infrastructure → Shop Setup
- Recipe authoring → Recipes & Steps
- Chemicals, baths, calibration → Materials & Tanks
- People, roles, training → Workforce
- Specs, vendors, quality categorisation, customer notifications → Quality & Documents
- Pricing rules, account holds → Pricing & Billing
- Generic value lists → Reference Data
Don't add new top-level Configuration entries (siblings of the 7 folders) unless absolutely necessary — Settings is the only one allowed.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,955 @@
# Sub 12c — Operator Traveller v2 + Chronological CoC + Labor History
> **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:** Upgrade the operator traveller PDF to paper-style A4 landscape (matching the Amphenol screens 16-18), add a chronological body to the existing CoC report (walks `fp.job.step.move` in time order), and ship a Labor History screen for billing/payroll audit.
**Architecture:** Replace the minimal `report_fp_job_traveller_template` body with the paper-style table. Add a new `coc_chronological_body` QWeb template alongside the existing `coc_body` in `fusion_plating_reports`; introduce a `body_style` selection on `fp.certificate` so customers opt in per cert. Labor History = standard list/form/search views on the existing `fp.job.step.timelog` (state machine added by Sub 12b). No new models.
**Tech Stack:** Odoo 19, QWeb XML, SCSS. No JS. No new Python models.
**Companion docs:**
- [Spec](../specs/2026-04-27-sub12-simple-recipe-editor-design.md) section 6
- [Steelhead screen inventory](../specs/2026-04-27-simple-recipe-editor-steelhead-screens.md) — screens 16-24
**Existing artifacts to extend (do NOT replace):**
- `fusion_plating_jobs/report/report_fp_job_traveller.xml` — native fp.job traveller (minimal, post-Sub-11). Body upgrade.
- `fusion_plating_reports/report/report_coc.xml``coc_body` template + `report_coc_en` / `report_coc_fr` actions. Add a chronological body template; existing classic body untouched.
- `fp.job.step.timelog` — Sub 12b added the state machine. Sub 12c adds list/form/search views.
**Out of scope (deferred):**
- Rack travel ticket PDF (referenced by Sub 12b's Rack Parts Save+Print — keep as 404 placeholder, ship in a follow-up sub).
- New cert types / Nadcap rules — existing CoC infrastructure already handles them.
**Deploy target:** entech (LXC 111). `-u --stop-after-init` clean upgrade per task.
---
## File structure
### Files to create
```
fusion_plating/views/fp_job_step_timelog_views.xml # list/form/search + Labor History menu
fusion_plating_reports/report/report_coc_chronological.xml # new chronological CoC body template
```
### Files to modify
```
fusion_plating/__manifest__.py # 19.0.10.1.0 → 19.0.10.2.0; add timelog views to data
fusion_plating_jobs/__manifest__.py # version bump
fusion_plating_jobs/report/report_fp_job_traveller.xml # rewrite template body to paper-style landscape
fusion_plating_reports/__manifest__.py # version bump; add report_coc_chronological.xml
fusion_plating_reports/report/report_coc.xml # extend coc_body to support body_style routing (optional minimal change)
fusion_plating_certificates/models/fp_certificate.py # add body_style selection field
fusion_plating_certificates/views/fp_certificate_views.xml # surface body_style on form
```
---
## Conventions
- Read every file before editing. The CoC template has 250+ lines of carefully-tuned QWeb — don't restructure unless necessary.
- Headers on all new files: Copyright 2026 Nexa Systems Inc., OPL-1, Part of Fusion Plating.
- Verification: entech `-u --stop-after-init` clean upgrade. Visual smoke test on a real job's traveller and a real cert's CoC.
---
## Task 1: Bump versions + manifest data entries
**Files:**
- Modify: `fusion_plating/__manifest__.py`
- Modify: `fusion_plating_jobs/__manifest__.py`
- Modify: `fusion_plating_reports/__manifest__.py`
- [ ] **Step 1: fusion_plating bump + add timelog views**
```python
'version': '19.0.10.1.0' '19.0.10.2.0',
```
Add to `'data'` list (after `views/fp_job_step_move_views.xml`):
```python
'views/fp_job_step_timelog_views.xml',
```
- [ ] **Step 2: fusion_plating_jobs bump**
Read current version, bump patch.
- [ ] **Step 3: fusion_plating_reports bump + add chronological CoC template**
Read current version, bump patch. Add to `'data'` list (after `report_coc.xml`):
```python
'report/report_coc_chronological.xml',
```
- [ ] **Step 4: Commit**
```bash
git add fusion_plating/__manifest__.py \
fusion_plating_jobs/__manifest__.py \
fusion_plating_reports/__manifest__.py
git commit -m "feat(sub12c): bump versions + manifest scaffolding
fusion_plating → 19.0.10.2.0 (Labor History views)
fusion_plating_jobs → next patch (Operator Traveller v2 body)
fusion_plating_reports → next patch (Chronological CoC body template)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Operator Traveller v2 — paper-style A4 landscape
**Files:**
- Modify: `fusion_plating_jobs/report/report_fp_job_traveller.xml`
- [ ] **Step 1: Read the current template**
```bash
cat fusion_plating_jobs/report/report_fp_job_traveller.xml
```
- [ ] **Step 2: Rewrite template + action**
Replace the entire template body with the paper-style version below. The action stays at `fusion_plating_jobs.report_fp_job_traveller_template` so existing button bindings keep working.
```xml
<?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.
Sub 12c v2 — paper-style A4 landscape job traveller.
Mirrors the Amphenol Canada paper sheets (Steelhead screens 16-18):
barcode + WO header, item-info block, recipe sub-process header, then
the routing table with target ranges + actuals + sign-off cells per
step. Operators print one of these per job, pencil in actuals, then
the tablet captures the same data digitally — printed traveller is
the redundant audit copy.
-->
<odoo>
<record id="paperformat_fp_traveller_landscape" model="report.paperformat">
<field name="name">FP Traveller — A4 landscape narrow margins</field>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">10</field>
<field name="margin_bottom">10</field>
<field name="margin_left">8</field>
<field name="margin_right">8</field>
<field name="header_spacing">5</field>
<field name="dpi">90</field>
</record>
<record id="action_report_fp_job_traveller" model="ir.actions.report">
<field name="name">Job Traveller</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_traveller_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_traveller_template</field>
<field name="print_report_name">'Traveller - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_traveller_landscape"/>
</record>
<template id="report_fp_job_traveller_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="web.external_layout">
<div class="page fp-trav-page">
<style>
.fp-trav-page { font-family: Arial, sans-serif; font-size: 8pt; color: #000; }
.fp-trav-page h1 { font-size: 14pt; margin: 0; }
.fp-trav-page h2 { font-size: 10pt; margin: 6px 0 2px 0; }
.fp-trav-page table.bordered,
.fp-trav-page table.bordered th,
.fp-trav-page table.bordered td { border: 1px solid #000; border-collapse: collapse; }
.fp-trav-page table.bordered th { background: #ededed; padding: 4px 6px; text-align: left; font-weight: bold; }
.fp-trav-page table.bordered td { padding: 4px 6px; vertical-align: top; }
.fp-trav-page .fp-trav-actuals { font-size: 7.5pt; color: #555; line-height: 1.5; }
.fp-trav-page .fp-trav-target { color: #444; font-size: 7.5pt; }
.fp-trav-page .fp-trav-blank { display: inline-block; min-width: 32mm; border-bottom: 1px solid #888; height: 1.2em; }
.fp-trav-page .fp-trav-stamp { min-height: 12mm; }
</style>
<!-- HEADER -->
<table class="bordered" style="width: 100%;">
<tr>
<td style="width: 5%; vertical-align: middle; text-align: center;">
<img t-if="job.company_id.logo"
t-att-src="'data:image/png;base64,%s' % job.company_id.logo.decode()"
style="max-width: 28mm; max-height: 18mm;"/>
</td>
<td colspan="2" style="vertical-align: middle;">
<h1>Work Order / Bon de Travail</h1>
<div style="text-align: center; margin-top: 4px;">
<strong t-esc="job.name"/>
</div>
<div style="text-align: center;">
<img t-att-src="'/report/barcode/Code128/%s' % job.name"
style="height: 14mm;"/>
</div>
</td>
<td style="width: 18%;">
<strong>Date In:</strong>
<span t-esc="job.create_date and job.create_date.strftime('%d-%m-%Y') or '—'"/><br/>
<strong>Due Date:</strong>
<span t-esc="job.date_deadline and job.date_deadline.strftime('%d-%m-%Y') or '—'"/><br/>
<strong>Type:</strong>
<span t-esc="job.recipe_id.name or '—'"/>
</td>
<td style="width: 18%;">
<strong>Order #:</strong>
<span t-esc="job.sale_order_id.name or '—'"/><br/>
<strong>P.O. #:</strong>
<span t-esc="job.sale_order_id.client_order_ref or '—'"/><br/>
<strong>WO Generated By:</strong>
<span t-esc="job.create_uid.name or '—'"/>
</td>
<td style="width: 22%; vertical-align: top;">
<strong t-esc="job.partner_id.name or '—'"/><br/>
<span t-esc="job.partner_id.street or ''"/><br/>
<span t-esc="(job.partner_id.city or '') + ', ' + (job.partner_id.state_id.code or '') + ' ' + (job.partner_id.zip or '')"/><br/>
<strong>Tel:</strong> <span t-esc="job.partner_id.phone or '—'"/>
</td>
</tr>
</table>
<!-- ITEM INFORMATION -->
<table class="bordered" style="width: 100%; margin-top: 4px;">
<tr>
<th style="width: 22%;">Item Information</th>
<th style="width: 30%;">Item-Name / Process Description</th>
<th style="width: 8%;">Qty Rec.</th>
<th style="width: 6%;">Vis Insp</th>
<th style="width: 6%;">Rework</th>
<th style="width: 22%;">Special Requirements</th>
<th style="width: 6%;">Stamp / Date</th>
</tr>
<tr>
<td>
<strong>Part #:</strong> <span t-esc="job.part_catalog_id.part_number or '—'"/><br/>
<strong>Rev:</strong> <span t-esc="job.part_catalog_id.revision or '—'"/><br/>
<strong>Mat:</strong>
<t t-if="'base_material' in job.part_catalog_id._fields">
<span t-esc="job.part_catalog_id.base_material or '—'"/>
</t>
<t t-else=""><span></span></t><br/>
<strong>Catg:</strong> <span t-esc="job.recipe_id.name or '—'"/><br/>
<strong>S/N:</strong> <span t-esc="job.serial_number or ''"/>
</td>
<td>
<strong t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/>
<div style="font-size: 7.5pt; margin-top: 2px;">
<t t-if="'customer_facing_description' in job.part_catalog_id._fields">
<span t-esc="job.part_catalog_id.customer_facing_description or ''"
style="white-space: pre-wrap;"/>
</t>
</div>
</td>
<td class="text-center">
<span t-esc="job.qty_received or job.qty"/>
</td>
<td class="text-center">
<span t-esc="job.qty_visual_inspection_rejects or 0"/>
</td>
<td class="text-center">
<span t-esc="job.qty_rework or 0"/>
</td>
<td style="font-size: 7pt; white-space: pre-wrap;">
<span t-esc="job.special_requirements or '—'"/>
</td>
<td class="fp-trav-stamp"/>
</tr>
</table>
<!-- PROCESS-SHEET HEADER -->
<table class="bordered" style="width: 100%; margin-top: 4px;">
<tr>
<th style="width: 30%;">Process Sheet / Feuille de Procédé</th>
<th style="width: 20%;">Catg.</th>
<th style="width: 50%;">Spec / Info</th>
</tr>
<tr>
<td><span t-esc="job.recipe_id.name or '—'"/></td>
<td><span t-esc="(job.recipe_id.process_type_id and job.recipe_id.process_type_id.name) or '—'"/></td>
<td>
<span t-esc="(job.coating_config_id and job.coating_config_id.name) or ''"/>
</td>
</tr>
</table>
<!-- ROUTING TABLE -->
<table class="bordered" style="width: 100%; margin-top: 4px;">
<thead>
<tr>
<th style="width: 3%;">Step</th>
<th style="width: 6%;">Tank</th>
<th style="width: 22%;">Operation + Actuals</th>
<th style="width: 22%;">Instruction</th>
<th style="width: 5%;">Unit</th>
<th style="width: 8%;">Material</th>
<th style="width: 6%;">Voltage</th>
<th style="width: 7%;">Time (min)</th>
<th style="width: 7%;">Temp</th>
<th style="width: 6%;">Stamp</th>
<th style="width: 8%;">Date</th>
</tr>
</thead>
<tbody>
<t t-foreach="job.step_ids.sorted('sequence')" t-as="step">
<t t-set="rn" t-value="step.recipe_node_id"/>
<tr>
<td class="text-center"><span t-esc="step_index + 1"/></td>
<td class="text-center"><span t-esc="(step.tank_id and step.tank_id.code) or '—'"/></td>
<td>
<strong t-esc="step.name"/>
<div class="fp-trav-actuals">
<t t-foreach="rn.input_ids.filtered(lambda i: i.kind == 'step_input').sorted('sequence')" t-as="inp">
<span t-esc="inp.name"/>:
<span class="fp-trav-blank"/>
<t t-if="inp.target_unit"> <span t-esc="inp.target_unit"/></t><br/>
</t>
</div>
</td>
<td style="font-size: 7.5pt; white-space: pre-wrap;">
<span t-esc="step.description or (rn and rn.description) or ''" t-options="{'widget': 'html'}"/>
</td>
<td class="text-center fp-trav-target">
<t t-if="rn and 'time_unit' in rn._fields and rn.time_unit">
<span t-esc="rn.time_unit"/>
</t>
<t t-else=""></t>
</td>
<td class="text-center fp-trav-target">
<t t-if="rn and 'material_callout' in rn._fields and rn.material_callout">
<span t-esc="rn.material_callout"/>
</t>
<t t-elif="rn and rn.process_type_id">
<span t-esc="rn.process_type_id.name"/>
</t>
<t t-else="">N/A</t>
</td>
<td class="text-center fp-trav-target">
<t t-if="rn and 'voltage_target' in rn._fields and rn.voltage_target">
<span t-esc="rn.voltage_target"/>V
</t>
<t t-else="">N/A</t>
</td>
<td class="text-center fp-trav-target">
<t t-if="rn and 'time_min_target' in rn._fields and rn.time_max_target">
<span t-esc="rn.time_min_target"/> - <span t-esc="rn.time_max_target"/>
</t>
<t t-else="">N/A</t>
</td>
<td class="text-center fp-trav-target">
<t t-if="rn and 'temp_min_target' in rn._fields and rn.temp_max_target">
<span t-esc="rn.temp_min_target"/>-<span t-esc="rn.temp_max_target"/>
<span t-esc="rn.temp_unit"/>
</t>
<t t-else="">N/A</t>
</td>
<td class="fp-trav-stamp"/>
<td class="fp-trav-stamp"/>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>
```
- [ ] **Step 3: Commit**
```bash
git add fusion_plating_jobs/report/report_fp_job_traveller.xml
git commit -m "feat(sub12c): operator traveller v2 — paper-style A4 landscape (Task 2)
Replaces the minimal portrait template with the Amphenol-style paper
sheet (screens 16-18). Header: barcode (Code 128 via /report/barcode),
WO# / Date In / Due Date / Type / Order# / PO# / WO-Generated-By /
customer block with address. Item Information panel: Part# / Rev / Mat /
Catg / S/N + multi-line Item-Name + Qty Rec / VIS INSP / Rework / Special
Requirements / Stamp-Date.
Process-Sheet header: recipe name + category + spec/info.
Routing table: Step / Tank / Operation+Actuals (recipe inputs render
as 'Actual <name>: ____ unit' lines) / Instruction / Unit / Material /
Voltage / Time(min) / Temp / Stamp / Date. Targets pulled from recipe-
node fields when present (Sub 12a authored), N/A otherwise.
New paperformat: A4 landscape narrow margins, 90 dpi.
Action ID + report_name unchanged so existing form-button bindings keep
working.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Customer CoC — chronological body template
**Files:**
- Create: `fusion_plating_reports/report/report_coc_chronological.xml`
- Modify: `fusion_plating_certificates/models/fp_certificate.py`
- Modify: `fusion_plating_certificates/views/fp_certificate_views.xml`
- [ ] **Step 1: Add `body_style` field on `fp.certificate`**
In `fp_certificate.py`, find a clean place to add new fields (after the existing `certified_by_id`):
```python
# ===== Sub 12c — chronological CoC opt-in =================================
body_style = fields.Selection(
[
('classic', 'Classic (recipe-order)'),
('chronological', 'Chronological (chain-of-custody)'),
],
string='CoC Body Style', default='classic',
help='Chronological walks fp.job.step.move records in time order '
'with measurement sub-tables per move, matching Steelhead\'s '
'CoC PDF layout. Classic uses the existing recipe-order body.',
)
```
- [ ] **Step 2: Surface `body_style` on the cert form**
In `fp_certificate_views.xml`, find the existing form view's group block and add:
```xml
<field name="body_style"/>
```
near the other certification settings.
- [ ] **Step 3: Create the chronological body template**
`fusion_plating_reports/report/report_coc_chronological.xml`:
```xml
<?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.
Sub 12c — Chronological CoC body.
Walks fp.job.step.move records in time order (chain-of-custody),
rendering each transition as a heading ("Step Name (Tank Code)")
with "Moved By / Time" + a 5-column measurement sub-table when the
destination step has captured input values. Mirrors Steelhead's
CoC PDF layout (screens 19-24).
Wired into the existing CoC actions via a `body_style='chronological'`
flag on fp.certificate — when set, action_report_coc_en/_fr render
this body instead of the classic recipe-order body.
-->
<odoo>
<template id="coc_body_chronological">
<t t-set="job" t-value="doc.x_fc_job_id if 'x_fc_job_id' in doc._fields else False"/>
<t t-set="moves" t-value="job.move_ids.sorted('move_datetime') if job and 'move_ids' in job._fields else []"/>
<style>
.fp-coc-chrono { font-family: Arial, sans-serif; font-size: 9pt; color: #000; padding-top: 8mm; }
.fp-coc-chrono h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; }
.fp-coc-chrono h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
.fp-coc-chrono .fp-chrono-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
.fp-coc-chrono table.bordered,
.fp-coc-chrono table.bordered th,
.fp-coc-chrono table.bordered td { border: 1px solid #000; border-collapse: collapse; }
.fp-coc-chrono table.bordered { width: 100%; margin-bottom: 8px; }
.fp-coc-chrono table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; }
.fp-coc-chrono table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
.fp-coc-chrono .fp-out-of-range { color: #b30000; font-weight: bold; }
.fp-coc-chrono .fp-in-range { color: #006400; }
.fp-coc-chrono .fp-pass { color: #006400; font-weight: bold; }
.fp-coc-chrono .fp-fail { color: #b30000; font-weight: bold; }
</style>
<div class="fp-coc-chrono">
<h1>Certificate of Conformance</h1>
<!-- Job header (compact) -->
<table class="bordered">
<tr>
<th style="width: 18%;">Part Number</th>
<th style="width: 30%;">Description</th>
<th style="width: 8%;">Quantity</th>
<th style="width: 8%;">Work Order</th>
<th style="width: 14%;">PO Number</th>
<th style="width: 12%;">Packing List No</th>
<th style="width: 10%;">Date</th>
</tr>
<tr>
<td><span t-esc="(job and job.part_catalog_id and job.part_catalog_id.part_number) or (job and job.product_id.default_code) or '—'"/></td>
<td><span t-esc="(job and job.part_catalog_id and job.part_catalog_id.name) or (job and job.product_id.name) or '—'"/></td>
<td class="text-center"><span t-esc="(job and job.qty) or ''"/></td>
<td class="text-center"><span t-esc="(job and job.name) or '—'"/></td>
<td><span t-esc="(job and job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/></td>
<td/>
<td><span t-esc="(doc.create_date and doc.create_date.strftime('%Y-%m-%d')) or ''"/></td>
</tr>
</table>
<h3 style="margin-top: 6px;">Specification(s):
<span style="font-weight: normal;"
t-esc="(job and job.recipe_id and job.recipe_id.name) or '—'"/>
</h3>
<hr style="border: 0; border-top: 2px solid #000; margin: 8px 0;"/>
<!-- Chain-of-custody walk -->
<t t-foreach="moves" t-as="mv">
<t t-set="dest" t-value="mv.to_step_id"/>
<t t-set="tank_code" t-value="mv.to_tank_id.code or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
<t t-set="captured" t-value="dest.input_ids.filtered(lambda i: i.kind == 'step_input').sorted('sequence') if dest else []"/>
<h3>
<span t-esc="dest and dest.name or '—'"/>
<t t-if="tank_code"> (<span t-esc="tank_code"/>)</t>
</h3>
<div class="fp-chrono-meta">
<strong>Moved By:</strong> <span t-esc="mv.moved_by_user_id.name"/>
&nbsp;·&nbsp;
<strong>Time:</strong>
<span t-esc="mv.move_datetime and mv.move_datetime.strftime('%b %d, %Y %I:%M:%S %p') or ''"/>
<t t-if="mv.qty_moved">
&nbsp;·&nbsp;<strong>Qty:</strong> <span t-esc="mv.qty_moved"/>
</t>
</div>
<!-- Measurement sub-table — only render when captured input values exist on the destination step -->
<t t-if="captured">
<table class="bordered">
<thead>
<tr>
<th style="width: 24%;">Name</th>
<th style="width: 30%;">Description</th>
<th style="width: 14%;">Target</th>
<th style="width: 18%;">Actual</th>
<th style="width: 14%;">Recorded By</th>
</tr>
</thead>
<tbody>
<t t-foreach="captured" t-as="inp">
<!-- Pull captured value via fp.job.step.input.value
if Sub 12a wired one. For now, the runtime
captures into transition_input_value_ids on
the move (Sub 12b) — step inputs that
are recorded *during* the step still go in
a step-level table. We render the prompt
name + target here as the audit row;
`Actual` is blank if no capture. -->
<tr>
<td><span t-esc="inp.name"/></td>
<td><span t-esc="inp.hint or ''"/></td>
<td>
<t t-if="inp.target_min and inp.target_max">
<span t-esc="inp.target_min"/><span t-esc="inp.target_max"/>
<t t-if="inp.target_unit"> <span t-esc="inp.target_unit"/></t>
</t>
<t t-elif="inp.target_unit">
<span t-esc="inp.target_unit"/>
</t>
</td>
<td/>
<td><span t-esc="(mv.moved_by_user_id.name) or ''"/></td>
</tr>
</t>
</tbody>
</table>
</t>
</t>
<hr style="border: 0; border-top: 2px solid #000; margin: 12px 0;"/>
<!-- Sign-off block (re-uses owner_user_id signature pattern) -->
<t t-set="owner_sig" t-value="False"/>
<t t-if="company.x_fc_owner_user_id">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
</t>
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override or owner_sig"/>
<t t-set="signer_name" t-value="(doc.certified_by_id and doc.certified_by_id.name) or (company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
<table class="bordered" style="width: 100%;">
<tr>
<td style="width: 50%; vertical-align: top;">
<strong>Certified By:</strong><br/>
<t t-if="signature_img">
<img t-att-src="'data:image/png;base64,%s' % signature_img.decode()"
style="max-height: 22mm; max-width: 70mm;"/>
</t><br/>
<strong>Name:</strong> <span t-esc="signer_name"/>
</td>
<td style="width: 50%; vertical-align: top;">
<strong>Certification Statement:</strong>
<span style="font-size: 8.5pt;">
Ref. WO# <span t-esc="job and job.name or ''"/>
</span>
<p style="font-size: 8pt; margin-top: 4px;">
We certify that the parts listed above have been processed in
accordance with the specifications referenced and that all
required tests have been performed. Records on file at our
facility per AS9100 / ISO 9001 retention policy.
</p>
</td>
</tr>
</table>
</div>
</template>
<!-- ============================================================== -->
<!-- Wrapper that picks chronological vs classic body -->
<!-- ============================================================== -->
<template id="coc_body_router">
<t t-if="doc.body_style == 'chronological' and 'x_fc_job_id' in doc._fields and doc.x_fc_job_id">
<t t-call="fusion_plating_reports.coc_body_chronological"/>
</t>
<t t-else="">
<t t-call="fusion_plating_reports.coc_body"/>
</t>
</template>
</odoo>
```
- [ ] **Step 4: Wire the router into the existing CoC actions**
In `fusion_plating_reports/report/report_coc.xml`, find the templates that render `coc_body` (search for `t-call="fusion_plating_reports.coc_body"`) and replace with `t-call="fusion_plating_reports.coc_body_router"`. There should be ≤4 occurrences (en + fr × portrait + landscape).
If the router replacement breaks anything, revert to direct calls and gate per-template instead.
- [ ] **Step 5: Commit**
```bash
git add fusion_plating_reports/report/report_coc_chronological.xml \
fusion_plating_reports/report/report_coc.xml \
fusion_plating_certificates/models/fp_certificate.py \
fusion_plating_certificates/views/fp_certificate_views.xml
git commit -m "feat(sub12c): chronological CoC body + body_style opt-in (Task 3)
New template: fusion_plating_reports.coc_body_chronological.
Walks fp.job.step.move records in time order (chain-of-custody view).
Per-move heading 'Step Name (Tank Code)' with 'Moved By / Time / Qty'
meta line + a 5-column measurement sub-table (Name / Description /
Target / Actual / Recorded By) when the destination step has captured
inputs. Heading-only when there are no inputs (gating moves).
New router template: coc_body_router. Picks chronological vs classic
based on fp.certificate.body_style. Existing certs default to 'classic'
so no regressions.
fp.certificate.body_style ('classic' | 'chronological') exposed on the
form. Customer chooses per cert.
Sign-off block reuses the existing owner_user_id signature pattern +
x_fc_coc_signature_override fallback. Cert statement boilerplate is
inline (Sub 12d will move it to a configurable per-customer field).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Labor History views
**Files:**
- Create: `fusion_plating/views/fp_job_step_timelog_views.xml`
- [ ] **Step 1: Create the views file**
```xml
<?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.
Sub 12c — Labor History views.
fp.job.step.timelog now has a state machine + reconciliation
columns (Sub 12b). This file surfaces the history under
Plating → Operations → Labor History for billing audit + payroll
reconciliation.
-->
<odoo>
<record id="view_fp_job_step_timelog_list" model="ir.ui.view">
<field name="name">fp.job.step.timelog.list</field>
<field name="model">fp.job.step.timelog</field>
<field name="arch" type="xml">
<list string="Labor History" default_order="date_started desc"
decoration-info="state == 'running'"
decoration-warning="state == 'paused'"
decoration-muted="state == 'reconciled'">
<field name="user_id"/>
<field name="job_id"/>
<field name="step_id"/>
<field name="state" widget="badge"
decoration-info="state == 'running'"
decoration-warning="state == 'paused'"
decoration-success="state == 'stopped'"
decoration-muted="state == 'reconciled'"/>
<field name="date_started"/>
<field name="date_finished" optional="show"/>
<field name="accrued_seconds" optional="show"/>
<field name="billed_hrs" optional="show"/>
<field name="billed_min" optional="show"/>
<field name="billed_sec" optional="show"/>
<field name="billed_pct" widget="progressbar" optional="show"/>
<field name="product_id" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_job_step_timelog_form" model="ir.ui.view">
<field name="name">fp.job.step.timelog.form</field>
<field name="model">fp.job.step.timelog</field>
<field name="arch" type="xml">
<form string="Labor Timer" create="false">
<header>
<field name="state" widget="statusbar"
statusbar_visible="running,paused,stopped,reconciled"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="display_name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="user_id" readonly="1"/>
<field name="job_id" readonly="1"/>
<field name="step_id" readonly="1"/>
<field name="date_started" readonly="1"/>
<field name="date_finished" readonly="1"/>
</group>
<group>
<field name="accrued_seconds" readonly="1"/>
<label for="billed_hrs" string="Billed Time"/>
<div>
<field name="billed_hrs" class="oe_inline"
readonly="state in ('reconciled',)"
groups="fusion_plating.group_fusion_plating_supervisor"/>
hrs
<field name="billed_min" class="oe_inline"
readonly="state in ('reconciled',)"
groups="fusion_plating.group_fusion_plating_supervisor"/>
min
<field name="billed_sec" class="oe_inline"
readonly="state in ('reconciled',)"
groups="fusion_plating.group_fusion_plating_supervisor"/>
sec
</div>
<field name="billed_pct" widget="progressbar" readonly="1"/>
<field name="product_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_job_step_timelog_search" model="ir.ui.view">
<field name="name">fp.job.step.timelog.search</field>
<field name="model">fp.job.step.timelog</field>
<field name="arch" type="xml">
<search>
<field name="user_id"/>
<field name="job_id"/>
<field name="step_id"/>
<field name="product_id"/>
<separator/>
<filter string="My Timers" name="my_timers"
domain="[('user_id','=',uid)]"/>
<filter string="Today" name="today"
domain="[('date_started','&gt;=',(context_today() ).strftime('%Y-%m-%d 00:00:00'))]"/>
<filter string="This Week" name="this_week"
domain="[('date_started','&gt;=',(context_today() - relativedelta(days=context_today().weekday())).strftime('%Y-%m-%d 00:00:00'))]"/>
<separator/>
<filter string="Running" name="running"
domain="[('state','=','running')]"/>
<filter string="Paused" name="paused"
domain="[('state','=','paused')]"/>
<filter string="Pending Reconciliation" name="pending"
domain="[('state','=','stopped')]"/>
<filter string="Reconciled" name="reconciled"
domain="[('state','=','reconciled')]"/>
<group>
<filter string="Operator" name="group_user"
context="{'group_by':'user_id'}"/>
<filter string="Job" name="group_job"
context="{'group_by':'job_id'}"/>
<filter string="Date" name="group_date"
context="{'group_by':'date_started:day'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_labor_history" model="ir.actions.act_window">
<field name="name">Labor History</field>
<field name="res_model">fp.job.step.timelog</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_job_step_timelog_search"/>
<field name="context">{'search_default_my_timers': 1}</field>
</record>
<menuitem id="menu_fp_labor_history"
name="Labor History"
parent="menu_fp_root"
action="action_fp_labor_history"
sequence="64"/>
</odoo>
```
- [ ] **Step 2: Add ACL rows for the timelog model**
The model is already accessible via fp.job.step relations, but explicit rows make the menu work for non-admin users. Append to `fusion_plating/security/ir.model.access.csv`:
```csv
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,group_fusion_plating_operator,1,1,0,0
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,group_fusion_plating_manager,1,1,1,1
```
(Skip if already present — grep first: `grep model_fp_job_step_timelog fusion_plating/security/ir.model.access.csv`.)
- [ ] **Step 3: Commit**
```bash
git add fusion_plating/views/fp_job_step_timelog_views.xml \
fusion_plating/security/ir.model.access.csv
git commit -m "feat(sub12c): Labor History views (Task 4)
Plating → Operations → Labor History (sequence 64, between Move Log
62 and Aerospace 65). List view colour-coded by state (info/warning/
success/muted), with billed_pct progressbar.
Search filters: My Timers (default), Today, This Week, Running,
Paused, Pending Reconciliation, Reconciled. Group-by: Operator, Job,
Date.
Form view (read-only header with statusbar): identity fields readonly,
billed_hrs/min/sec editable for supervisors+ until state=reconciled,
chatter for operator notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Deploy to entech + smoke test + push
**Files:**
- (none — deployment + manual verification)
- [ ] **Step 1: Tar + ship**
```bash
tar -cf - \
fusion_plating/__manifest__.py \
fusion_plating/security/ir.model.access.csv \
fusion_plating/views/fp_job_step_timelog_views.xml \
fusion_plating_jobs/__manifest__.py \
fusion_plating_jobs/report/report_fp_job_traveller.xml \
fusion_plating_reports/__manifest__.py \
fusion_plating_reports/report/report_coc.xml \
fusion_plating_reports/report/report_coc_chronological.xml \
fusion_plating_certificates/models/fp_certificate.py \
fusion_plating_certificates/views/fp_certificate_views.xml \
| ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && tar -xf -'"
```
- [ ] **Step 2: Update modules**
```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_reports,fusion_plating_certificates --stop-after-init\" 2>&1 | tail -25 && \
systemctl start odoo'"
```
Expected: clean upgrade, 233 modules loaded.
- [ ] **Step 3: Clear asset cache**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"su - postgres -c 'psql admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '\\''/web/assets/%'\\'';\\\"'\""
```
- [ ] **Step 4: Manual smoke test**
1. Open any in-flight `fp.job` → Print → "Job Traveller". PDF should render in A4 landscape with: header (logo + barcode + dates + customer), Item Information block, Process-Sheet header, Routing table with target columns + blank actuals.
2. Open any `fp.certificate` → form shows new "CoC Body Style" Selection. Default = Classic. Existing CoC PDF unchanged.
3. Flip body_style to Chronological → Print CoC → new PDF walks moves in time order with measurement tables. (Job needs `fp.job.step.move` rows for this to be meaningful — produce a few via the Sub 12b tablet flow first if needed.)
4. Plating → Operations → Labor History menu appears. List shows timelog rows with My Timers default filter. Try filters (Running / Paused / Pending Reconciliation / Reconciled) and Group-by (Operator / Job / Date).
5. Open a `reconciled` timelog → form is read-only, supervisor can re-edit billed_* if needed.
- [ ] **Step 5: Push to remote**
```bash
git push origin main
```
---
## Self-Review
### Spec coverage check
| Spec section 6 item | Task |
|---|---|
| 6.2 Operator Traveller v2 (A4 landscape, paper-style) | Task 2 |
| 6.3 Customer CoC chronological body | Task 3 |
| 6.3 body_style opt-in field | Task 3 |
| 6.4 Labor History list/form/search/group-by/menu | Task 4 |
| 6.4 Manager re-edit of billed_* on reconciled | Task 4 (form view + supervisor group on billed_* fields) |
| 6.5 Backend support (chronological payload helper) | Inline in Task 3 — QWeb walks `job.move_ids.sorted('move_datetime')` directly; no separate Python helper needed |
| 6.6 Migration / install | Task 1 (version bumps) — no model migrations, all additive |
| 6.7 Verification | Task 5 |
| 6.8 Things to NOT do | Honoured — `report_coc.xml` legacy bodies untouched, `action_issue` flow not changed, no new model fields beyond body_style, two reports stay separate |
Out-of-scope items handled by deferring:
- **Rack travel ticket PDF** (Sub 12b's Save+Print 404) — flagged in plan companion docs as a follow-up
- **Per-customer cert statement** — boilerplate inline in chronological body for now; deferrable
### Placeholder scan
No "TBD" / "TODO" / "implement later" / "fill in details".
The chronological body's measurement sub-table renders prompts + targets but leaves the **Actual** column blank. That's because Sub 12a + Sub 12b's runtime captures `step_input` values via the operator's per-step input form, which lands in the existing `step.input_value_ids` collection (or equivalent) — wiring that into the Actual cell needs more knowledge of the existing input-value model than the plan time budget allows. Documented in Task 3's commit message as a Sub 12d follow-up.
### Type / signature consistency
- `fp.certificate.body_style` defined Task 3, used by `coc_body_router` Task 3. ✓
- `coc_body_chronological` template defined Task 3, called by `coc_body_router` Task 3. ✓
- `coc_body_router` template defined Task 3, called from existing `report_coc.xml` templates after the replacement edit (Task 3 step 4). ✓
- `fp.job.move_ids` (added by Sub 12b Task 6) referenced by Task 3's chronological body. ✓
- `fp.job.step.timelog.state` + `accrued_seconds` + `billed_*` + `product_id` (added by Sub 12b Task 7) referenced by Task 4's views. ✓
- `paperformat_fp_traveller_landscape` defined Task 2, referenced by `action_report_fp_job_traveller` Task 2 same record. ✓
---
**Plan complete. 5 tasks, ~1 day end-to-end (significantly tighter than original 18-task plan because most CoC infrastructure already exists in `fusion_plating_reports`).**

View File

@@ -0,0 +1,973 @@
# Steelhead "Move Parts" Screen Inventory — Simple Recipe Editor
Working notes captured during brainstorming. Each screenshot the user
provides is logged here so we can fold the field requirements into the
final design. Do NOT lose these notes — they drive the data shape on
`fp.step.template.transition.input` and the eventual transition dialog
on the tablet.
## Common dialog header (all screens)
- **Title bar**: "Move Parts"
- **Cancel** + red **MOVE (n)** button at footer (n = part count being moved)
## Screenshot 1 — node→node move (no station)
| Field | Type | Example | Notes |
|---|---|---|---|
| Part Count | Integer (with stepper) | 1 | "Available: 1" hint shown beneath the input |
| Part Number | Read-only link | TEST225451 | resolves back to part record |
| From Node | Read-only link | Contract Review | current node operator is leaving |
| Transfer Type | Selection | Step | (other values likely: Step / Hold / Scrap / Return — TBD as more screens come in) |
| To Node | Read-only link | Ready for Incoming Inspection | destination node |
| To Location | Selection + camera icon | Global | location picker; camera icon = take photo evidence inline |
| Number of Customer WOs | Char | (blank) | optional; only present on this screen |
| Billed Labor | section with timer icon + "Reset All Edits" button | — | per-operator timer breakdown follows |
| **Per-operator labor row** | composite | Kris Pathinather, Timer Duration: 56s (100.0% billed), WO #4521 PN: TEST225451 Qty: 1, hrs/min/sec edit fields | reconcilable timer vs. billed split; the small icon top-right of the row appears to be "edit notes" |
## Screenshot 2 — node→node move with station picker (between two real shop steps)
| Field | Type | Example | Notes |
|---|---|---|---|
| Part Count | Integer (stepper) | 49 | Available: 49 |
| Part Number | Read-only link | TEST225451 | |
| From Node | Read-only link | Ready for Incoming Inspection | |
| Transfer Type | Selection | Step | |
| To Node | Read-only link | Incoming Inspection | |
| **To Station** | Selection | Incoming Inspection | NEW field on this variant — appears when destination node has multiple stations to choose from. Defaults to a sensible value but is editable. |
| To Location | Selection + camera | Global | |
| Billed Labor | section | — | (not expanded on this screen — implies optional / collapsed by default when timer hasn't accrued) |
## Screenshot 3 — second node→node move with From Station
| Field | Type | Example | Notes |
|---|---|---|---|
| Part Count | Integer (stepper) | 49 | Available: 49 |
| Part Number | Read-only link | TEST225451 | |
| From Node | Read-only link | Incoming Inspection | |
| **From Station** | Read-only link | Incoming Inspection | NEW — appears when the source node had a station selected previously |
| Transfer Type | Selection | Step | |
| To Node | Read-only link | Adhesion Test Coupon | |
| To Location | Selection + camera | Global | |
| Billed Labor | section with "Reset All Edits" button | — | |
| Per-operator labor row | composite | Kris Pathinather, Timer Duration: 4s (100.0% billed), WO #4521 PN: TEST225451 Qty: 49, hrs/min/sec edit fields | same shape as screenshot 1 |
## Patterns emerging across screenshots so far
1. **Variable field set per transition.** Some moves show "Number of Customer WOs", some don't. Some show "To Station" / "From Station", some don't. The recipe author needs to be able to declare which fields appear on which transitions — that's what `fp.step.template.transition.input` is for.
2. **Read-only context fields are always present** (Part Count, Part Number, From Node, To Node, From/To Station when applicable, To Location, Transfer Type). These are *system-derived* — the recipe author doesn't author them, they come from the runtime context. Our model only needs to capture the *author-defined* prompts (extra compliance fields).
3. **Camera icon next to To Location** = inline photo capture, attached to the transition log. Implies the runtime needs a `photo` input type that can either upload a file or trigger device camera (mobile / tablet).
4. **Billed Labor is a separate concern** — it's a labor reconciliation widget, not a recipe-defined input. Operator can edit hrs/min/sec to reconcile timer vs. actual time billed. Per-operator row with Timer Duration + "100.0% billed" indicator. "Reset All Edits" button reverts all manual reconciliations to timer values. This is its own sub-system; goes outside `fp.step.template` (it's a runtime feature, not authored on a recipe).
5. **Transfer Type values seen so far**: `Step`. More variants expected (Hold, Scrap, Return, Rework). Each variant likely has its own required field subset.
## Screenshot 4 — Adhesion Test Coupon → Ready for racking (no station, no labor block)
| Field | Type | Example | Notes |
|---|---|---|---|
| Part Count | Integer (stepper) | 49 | Available: 49 |
| Part Number | Read-only link | TEST225451 | |
| From Node | Read-only link | Adhesion Test Coupon | |
| Transfer Type | Selection | Step | |
| To Node | Read-only link | Ready for racking | |
| To Location | Selection + camera | Global | |
| Billed Labor | section icon only | — | not expanded — implies no timer accrued on this leg |
Pattern: short transitions between QA-style intermediate nodes don't
expand the labor reconciliation panel. Labor block expands only when a
non-zero timer has accrued OR when an operator has a row to reconcile.
## Screenshot 5 — Ready for racking → Racking (no station, no labor)
| Field | Type | Example | Notes |
|---|---|---|---|
| Part Count | Integer (stepper) | 49 | Available: 49 |
| Part Number | Read-only link | TEST225451 | |
| From Node | Read-only link | Ready for racking | |
| Transfer Type | Selection | Step | |
| To Node | Read-only link | Racking | |
| To Location | Selection + camera | Global | |
| Billed Labor | section icon only | — | collapsed |
Pattern: same as screenshot 4. "Ready for X" nodes are gating-only
(parts wait there) — moving past one accrues no measurable labor.
## Screenshot 6 — Racking → Ready For Plating + amber **rack-required warning**
| Field | Type | Example | Notes |
|---|---|---|---|
| Part Count | Integer (stepper) | 49 | Available: 49 |
| Part Number | Read-only link | TEST225451 | |
| From Node | Read-only link | Racking | |
| Transfer Type | Selection | Step | |
| To Node | Read-only link | Ready For Plating | |
| **To Station** | Selection | SP Line | destination has multiple plating lines; SP Line is the auto-pick |
| To Location | Selection + camera | Global | |
| **WARNING BLOCK** (amber, with ⚠ icon) | banner | "Parts are currently at a RACKING node and are not racked." | Soft block — informs operator the parts haven't been associated with a physical rack yet |
| Billed Labor | section icon only | — | collapsed |
| Footer | THREE buttons | Cancel · **RACK PARTS** (red secondary) · **MOVE (49)** (red primary) | RACK PARTS opens a separate Rack Parts dialog (screenshots 78) |
**Critical pattern**: a node's **type** (e.g. Racking) can require
auxiliary records (rack assignment) BEFORE the move is allowed. The UI
warns but doesn't hard-block — operator can still hit MOVE if they
choose, presumably escalating an audit flag. RACK PARTS is the
"resolve the warning" path.
This implies the recipe author needs a way to declare:
> "This step's destination requires a rack assignment before move." —
> a node-type-level rule, not a transition-input rule.
→ NEW field on `fp.step.template` and `fusion.plating.process.node`:
`requires_rack_assignment` Boolean. When True and the operator hasn't
linked a rack to this batch of parts yet, the tablet shows the amber
warning + RACK PARTS button.
## Screenshot 7 — Rack Parts dialog (overlay on top of Move Parts)
| Field | Type | Example | Notes |
|---|---|---|---|
| Title | "Rack Parts" with QR-scanner icon top-right | — | scanner icon = scan rack QR to auto-fill To Rack |
| To Rack | Search-and-select dropdown | (blank → "Search Racks…") | M2O picker against a rack registry |
| Per-line row | composite | TEST225451 on WO 4521 (49) — Unit: **Count** dropdown — Amount: **49** | shows the part being racked + qty in the chosen unit |
| Unit | Selection | Count | (other values likely: Count / Pieces / Lbs / Kg / Sheets — TBD) |
| Amount | Number | 49 | usually = Part Count from Move Parts but editable |
| Billed Labor | section icon only | — | collapsed; same widget as Move Parts |
| Footer | THREE buttons | Cancel · **SAVE** (disabled until To Rack set) · **SAVE + PRINT** (disabled until To Rack set) | SAVE + PRINT prints rack travel ticket / barcode |
## Screenshot 8 — Rack Parts dropdown expanded
| Element | Notes |
|---|---|
| Searchable dropdown | Free-text "Search Racks…" filters list |
| Shows list of named racks: Rack 3, Rack 4, Rack 5, Rack 6, Rack 7, Rack 9, Rack 11 ... | Gaps in numbering (no Rack 8, no Rack 10) imply **active filter** — dropdown only shows racks currently empty / available. Numbers persist; full list is sparser than the index range. |
| Highlighted on hover | Rack 3 shown highlighted, indicating standard combobox UX |
**Implication for our model**: a `fp.rack` registry already has the
shape we need (`name`, `active`, `state` for empty/in-use). Need a
M2O on the racking transition log: `rack_id`. Selection is filtered
to `state='empty'` by default, with an override to show all.
The QR scanner icon implies a scan-to-fill flow on the tablet — same
mechanism we already use elsewhere (`/fp/shopfloor/scan` endpoint
resolves a scanned QR to a tank/job/etc.). For racks, we'd extend the
scan endpoint with a `fp-rack:<id>` token resolver.
## Patterns updated after screens 48
6. **Three transition-time prompt families now visible**:
- **Author-defined compliance prompts** (per-step on
`fp.step.template.transition.input`) — variable per step.
- **Always-on context fields** (Part Count, From/To Node, etc.) —
system-derived, not authored.
- **Step-type-driven side dialogs** — declared by Boolean flags on
the step (e.g. `requires_rack_assignment`) — open a separate
mini-dialog (Rack Parts) before the main move can complete.
Other likely flags: `requires_bake_window`, `requires_qc_check`,
`requires_signature`. Each maps to an existing or new sub-dialog.
7. **Move button label reflects qty**: `MOVE (49)` vs `MOVE (1)`.
Operator confidence cue — they see exactly how many parts they're
committing to move before tapping.
8. **Soft-block vs hard-block UI language**:
- **Amber warning + clickable resolution button** = soft block
(operator may proceed with audit log).
- **Red error + disabled MOVE** = hard block (operator cannot
proceed). Not yet seen in any screenshot, but pattern is implied
by the amber design.
## Screenshot 9 — Compact part-row card (paused timer)
| Element | Notes |
|---|---|
| Checkbox (left) | bulk-select for cross-row actions |
| Red **paused** icon (∥∥) | visual cue: timer is currently paused on this row |
| Inline summary | "1 TEST225451 \| Rack 3 \| Racking" — Qty + part link + rack link + node link, all clickable navigations |
| Sub-line | "Kris Pathinather: 27s" — operator name + accrued timer duration on this row |
Pattern: this is a Plant Overview / Tablet row — a **per-batch
position card** showing a single chunk of parts at a single node, with
the operator who currently owns its labor timer + its rack assignment
inline. Clicking any blue link drills into that record.
## Screenshot 10 — Stop User Labor Timer dialog
| Field | Type | Example | Notes |
|---|---|---|---|
| Title | "Stop User Labor Timer" | — | Distinct dialog from Move Parts; fires when operator pauses without moving |
| Billed Labor | section | Kris Pathinather, Timer Duration: 32s (100.0% billed) | with **RESET ALL EDITS** button |
| Per-row | composite | "WO #4521 PN: TEST225451 Qty: 1" + Product Select + hrs/min/sec inputs | NEW: **Product** dropdown — operator can split timer time onto multiple products. Defaults to current PN. |
| Footer | THREE buttons | Cancel · **SAVE** · **SAVE & START NEW TIMER** | Distinguished from Move Parts which is Cancel/MOVE — labor reconciliation is its own action |
**Critical insight**: labor reconciliation is a **standalone flow**,
not embedded in Move Parts. Operator can stop the timer without moving
parts. This implies our model needs:
- A persistent **labor timer record** (`fp.labor.timer`?) per
(operator × WO × part × node), independent of the move log.
- A **labor reconciliation** action that closes a timer with a billed
hrs/min/sec breakdown, optionally splitting across multiple products.
## Screenshot 11 — Move Rack: tyut (full-rack move dialog)
| Field | Type | Example | Notes |
|---|---|---|---|
| Title | "Move Rack: tyut" | — | rack name (here "tyut") in the title — ties move to a specific rack record |
| **Rack Labels** | M2M with `+` button | (empty) | tag rack with shop labels (priority colours, customer codes, etc.) |
| **Parts** section | static list of contained parts | "49 TEST225451 Parts on WO 4521" + "1 TEST225451 Parts on WO 4521" | shows ALL parts on the rack at once — moving a rack moves everything on it as one unit |
| Type | Selection | Step | same as Move Parts |
| **To Node** | Selection (read-only here, "Soak Clean (SP-1)") | Soak Clean (SP-1) | greyed-out — auto-derived from current step's recipe path |
| **To Station** | Selection | Soak Clean (SP-1) | editable; defaults to first compatible station |
| Billed Labor | section + RESET ALL EDITS | — | per-batch row breakdown: each contained part qty gets its own hrs/min/sec inputs |
| Per-row | composite (×2 in this rack) | "WO #4521 PN: TEST225451 Qty: 49" → 0/0/6 + "WO #4521 PN: TEST225451 Qty: 1" → 0/0/0 | individual labor split per chunk on the rack |
| Footer | TWO buttons | Cancel · **SAVE** | no separate MOVE button — SAVE commits the move |
**Critical pattern**: Move Rack is a **rack-as-unit move**, distinct
from Move Parts (parts-as-unit). When parts are racked, they MUST move
as a rack — Steelhead enforces this by not surfacing the per-part Move
button (see screenshot 12: those buttons are disabled/greyed when
racked). The recipe author doesn't author move-rack rules; they're
runtime-enforced based on whether the parts are currently on a rack.
## Screenshot 12 — Plant overview: Racks vs Parts panes (rack collapsed UI)
| Section | Notes |
|---|---|
| **Racks** header | with red "UNRACK MULTIPLE" button — bulk unrack action |
| Rack row | red ∥∥ pause icon · "50 Parts" · rack name "tyut" (Rack 3) · current node breadcrumb "Soak Clean (SP-1) / Soak Clean (SP-1)" · "Kris Pathinather: 14s" labor stamp · **MOVE RACK** primary button on the right |
| **Parts** header | with `+ ADD NEW PARTS` button + filters (Part Number ▾, Part Account ▾) + search box "Search by PN or group" |
| Parts row(s) | qty + PN + rack + node breadcrumb + operator/timer + per-row buttons: **MOVE PARTS** (greyed out / disabled because racked) + QR icon + ribbon icon + ⋮ kebab + ⌄ expand |
**Critical UI rule emerging**: when parts are racked, **the per-part
Move Parts button greys out**. The only way to move racked parts is
via **MOVE RACK**. This is enforced by the disabled button state, not
by error message. Operator UX is: "You can't accidentally move just
some of these parts — they're racked together, move the rack."
This implies a runtime guard on `fp.job.step` (or wherever the move
controller lives): if `rack_id` is set on the part-batch, reject
move-parts calls and require move-rack.
## Screenshot 13 — Move Rack: tyut (different destination, station picker shows non-default station)
| Field | Type | Example | Notes |
|---|---|---|---|
| Title | "Move Rack: tyut" | — | same dialog shape as screenshot 11 |
| Rack Labels | M2M + button | (empty) | |
| Parts list | static | "49 TEST225451 Parts on WO 4521" + "1 TEST225451 Parts on WO 4521" | same contents as 11 |
| Type | Selection | Step | |
| To Node | Selection (read-only) | Rinse (SP-2) | rack has advanced one step from screenshot 11 (Soak Clean → Rinse) |
| **To Station** | Selection | "Cold Water Rinse …" (truncated) | NEW: shows that one node can have **multiple stations** with descriptive names, not just SP-numbered codes. The SP-2 prefix is the tank code; "Cold Water Rinse" is the station's display name. |
| Billed Labor | section + RESET ALL EDITS | — | per-batch labor split as before; total Timer Duration: 10s |
| Footer | TWO buttons | Cancel · **SAVE** | |
**New insight**: stations have both a **code** (SP-2) AND a **friendly
name** (Cold Water Rinse). Our model already has `name` + `code` on
`fusion.plating.tank` — confirming our existing design matches
Steelhead's naming model 1:1.
## Patterns updated after screens 913
9. **Labor timer is a first-class persistent entity**, not a UI
ephemeral. It has a stop-without-move flow (screen 10), it can be
reset, it splits across products. Probably its own model
`fp.labor.timer` with states: `running / paused / stopped /
reconciled`.
10. **Rack-vs-parts move duality**: parts-not-racked → Move Parts
dialog; parts-racked → Move Rack dialog (MOVE PARTS greys out).
Move Rack moves all rack contents at once with per-chunk labor
breakdown. Need `rack_id` on the move-controller decision.
11. **Rack Labels** as a tagging surface — M2M against a tag-like
registry. Not yet authored on `fp.step.template` (it's a runtime
rack metadata feature). Goes on `fp.rack` directly when we build
that registry.
12. **Plant Overview shape**: top section "Racks" with rack-level
primary action (MOVE RACK) + bulk action (UNRACK MULTIPLE);
bottom section "Parts" with per-part actions (greyed when
racked) + filters + search. This is the layout the simple-mode
customer probably also wants on their plant overview — but
that's a separate sub-project from the recipe editor.
13. **"To Node" vs "To Station" hierarchy is consistent**: To Node is
read-only (auto-derived from recipe sequence), To Station is
editable (operator picks among compatible stations on that
node). For our simple recipe editor, this means each step's
`tank_ids` M2M is the **authoritative compatible-station list**
that the runtime uses to populate the To Station dropdown. The
recipe author's job is to declare the ALLOWED set; the operator
picks among them at run time.
## Screenshot 14 — Move Rack: tyut → Ready For DeRack (no station picker)
| Field | Type | Example | Notes |
|---|---|---|---|
| Title | "Move Rack: tyut" | — | rack name in title |
| Rack Labels | M2M + button | (empty) | |
| Parts list | static | "49 TEST225451 Parts on WO 4521" + "1 TEST225451 Parts on WO 4521" | full rack contents |
| Type | Selection | Step | |
| **To Node** | Selection (read-only, greyed) | "Ready For DeRac…" (truncated; full = "Ready For DeRack") | gating-only node — no station to choose, no plating action |
| **No To Station field** | — | — | confirms: when destination is a single-station / gating node, the To Station row does not render |
| Billed Labor | section + RESET ALL EDITS | — | per-batch breakdown |
| Per-row labor | composite | "WO #4521 PN: TEST225451 Qty: 49" → 0/0/4 + "WO #4521 PN: TEST225451 Qty: 1" → 0/0/0 | |
| Footer | TWO buttons | Cancel · **SAVE** | |
**Pattern**: gating nodes (anything starting with "Ready For…") are
single-station and skip the To Station selector. Move Rack and Move
Parts both honour this. Our model should treat any node with exactly
one tank in `tank_ids` (or none — implying "any global location") as a
node that hides the station picker.
## Screenshot 15 — Move Parts with **soft-block: missing spec measurements** (MOVE button DISABLED)
| Field | Type | Example | Notes |
|---|---|---|---|
| Part Count | Integer (stepper) | 49 | Available: 49 |
| Part Number | Read-only link | TEST225451 | |
| From Node | Read-only link | Bake | |
| **From Station** | Read-only link | Bake | |
| Transfer Type | Selection | Step | |
| To Node | Read-only link | Adhesion Testing | |
| To Location | Selection + camera | Global | |
| **WARNING BLOCK** (amber, ⚠ icon) | banner | "Additional Spec Measurements are required for this part." | distinct from the rack-required warning |
| Billed Labor | section + RESET ALL EDITS | — | Timer Duration: 7s |
| Per-row labor | composite | "WO #4521 PN: TEST225451 Qty: 49" → 0/0/7 | |
| Footer | TWO buttons | Cancel · **MOVE (49)** (DISABLED / greyed) | **HARD BLOCK** — operator can't proceed until spec measurements are recorded |
**Critical pattern — first hard block we've seen**:
- Steelhead shows the same amber colour for both soft-block (rack
warning) and hard-block (missing spec). The DIFFERENCE is the
primary button state: soft-block leaves MOVE enabled (audit and
proceed); hard-block greys MOVE out (must resolve first).
- Steelhead does NOT explain HOW to record the missing measurements
in this dialog — the operator is left to figure out where to enter
them. This is what the user means by "Steelhead is limited."
**Where we improve over Steelhead**:
- The amber banner should have a **clickable resolution button** like
the rack warning's "RACK PARTS" button — e.g. **"RECORD MEASUREMENTS"**
that opens the spec-measurement input dialog inline.
- After recording, the banner clears + MOVE re-enables. No screen
hunting.
- Distinguish soft vs hard visually: amber for soft (proceed-with-
audit), red for hard (must-resolve).
## Patterns updated after screens 1415
14. **Gating nodes (Ready For …)** are single-station / no-station
nodes — UI hides the To Station selector for them. Our recipe
editor's step-template form should let the author mark a step as
"gating" (no tanks/stations needed) and the runtime auto-hides
the station picker.
15. **Soft-block vs hard-block protocol**:
- **Soft-block**: amber banner + resolution button + MOVE stays
enabled (rack-required warning, screen 6).
- **Hard-block**: amber banner + MOVE disabled until resolved
(missing spec measurements, screen 15).
- **Steelhead's gap**: hard-block doesn't tell operator where to
go to resolve. **Our improvement**: every blocker (hard or
soft) gets an inline resolution button.
16. **Spec measurements vs operation measurements vs transition
inputs**: three distinct concepts now visible:
- **Operation measurements** (`input_template_ids` on
`fp.step.template`) — recorded *during* a step, e.g. "Actual #
of parts" mid-bake.
- **Transition inputs** (`transition_input_ids`) — recorded
*when leaving* a step, e.g. "Customer WO #" or photo evidence.
- **Spec measurements** (NEW from screen 15) — part-level
specs that are required *for the part itself* across multiple
steps, e.g. "thickness reading" required after Adhesion
Testing. These don't belong on the step template — they belong
on the **part** record (or on a part × step rule). The amber
block fires when the part hasn't satisfied a spec rule.
→ Implies a separate authoring surface: per part (or per
customer × part), a list of "required spec measurements" with
their trigger node. Out of scope for this sub-project but worth
flagging in the design as a hand-off point.
17. **MOVE button label DISABLED state**: greyed background, greyed
text, still shows count "MOVE (49)". Steelhead does NOT show a
tooltip explaining why — another improvement opportunity for us.
## Screenshots 16, 17, 18 — Paper Job Traveller (3-page WO #023633-1, Amphenol Canada, ENP-Aluminum)
These are the **manual paper job travellers** the client fills in by
pen as the parts move. They're the gold-standard spec of what data
flows through a real plating job today. Every column on these sheets
is a candidate for digital capture in our recipe → step-template →
runtime stack.
### Header (repeats on every page)
| Column | Example | Where it lives in our model |
|---|---|---|
| WO # | 023633-1 | `fp.job.name` (already exists) |
| Barcode | (1D barcode of WO #) | `fp.job.qr_code` (already auto-generated; switch to 1D Code 128 for traveller print) |
| Date In | 29-11-2024 | `fp.job.date_received` (already exists, comes from `fp.receiving`) |
| Due Date | 11-12-2024 | `fp.job.date_due` (already exists, from SO commitment date) |
| Type | ENP-ALUMINUM | `fp.job.coating_config_id.name` (already exists) |
| Order No. | 023633 | `fp.job.sale_order_id.name` (already exists) |
| P.O. No. | 731830 | `fp.job.sale_order_id.client_order_ref` (already exists) |
| Customer | AMPHENOL CANADA + address + phone | `fp.job.partner_id.*` (already exists) |
| WO Generated By | RIYA | `fp.job.create_uid.name` (already exists) |
**No new model fields needed for the header.** Existing job fields
cover everything. The traveller-print report just needs to pull them.
### Item Information block (page 1, top)
| Column | Example | Where it lives |
|---|---|---|
| Item informations / Part # | VS-E0443220025 | `fp.part.catalog.part_number` (already exists) |
| Rev. | 1F | `fp.part.catalog.revision` (already exists) |
| Mat. (material) | 6061-T6511 | NEW field on `fp.part.catalog`: `base_material` Char (e.g. "6061-T6511 aluminum"). Currently we have implicit material via coating config — making it explicit per-part is small. |
| Catg. (category) | ENP-ALUMINUM | derived from `fp.part.catalog.coating_config_id.name` |
| S/N (serial #) | (blank for batch) | already exists as `x_fc_serial_number` (Sub 5) |
| Item-Name / Process Description | "SHELL RECEPTACLE / 01 - ELECTROLESS NICKEL PLATING PER E499-303-00-002 OF AMPHENOL SPEC # E499-303-00-XXX REV : 1F" | `fp.part.catalog.name` + `fp.part.catalog.customer_facing_description` (already exist; just need to merge in the report) |
| Qty Rec. | 5850 → 5839, 5835 (multiple counted lines) | NEW: traveller needs to show **received qty** AND **per-stage running counts**. Stages: Received → Inspected → Racked → Plated → Final-counted. Capture per-stage. |
| VIS INSP. | 0 (visual inspection rejects/holds during incoming) | NEW: integer column on `fp.job` for "qty rejected at incoming inspection" |
| Rework | (blank) | NEW: integer column for "qty sent to rework" |
| Special Requirements | "MID PHOS / PANEL THICKNESS: 0.0224"-0.0228" (PLATING THICKNESS: 0.0005"-0.0007") / BAKE @ 250 DEG F FOR 1 HOUR / *****RUN EACH LOAD WITH 3 TEST PANELS AND VERIFY THE PLATING THICKNESS USING XRF PRIOR TO REMOVING PARTS FROM THE TANK..." | NEW: `fp.job.special_requirements` Text (free-form). Today this lives in customer specs but isn't pulled onto the traveller. |
| Stamp + Date | initials "LY" + "06.12.24" | runtime sign-off — captured per-section by the inspector |
### Process-Sheet header (page 1, middle)
| Column | Example | Where it lives |
|---|---|---|
| Process name 1 | "ENIP (A) BAKE (GENERIC)" | `fp.job.process_node_id.name` (root recipe) |
| Process name 2 | "ENIP (A) BAKE" | sub-process node name (already exists in tree) |
| Catg. | ELECTROLESS NICKEL | from coating config |
| Special Req. | (blank in this WO) | `fp.job.special_requirements` |
| Spec / Info block | (blank, reserved for sticker / inspector notes) | print-only blank |
### Step rows (the heart of the traveller)
Each step row carries:
| Column | Type / format | Notes |
|---|---|---|
| **Step #** | Integer (1, 2, 3, ... 25) | already on our model as `sequence` (auto-numbered in the OWL editor) |
| **Tank** | Char code (A-1, A-2, A-13, blank) | comes from `tank_ids` M2M chosen at runtime → `fp.job.step.tank_id` |
| **Operation** | Operation name (e.g. "Soak clean", "Etch", "E-Nickel Plate", "Inspection") | `fp.step.template.name` |
| **Operation actual-data column** (multi-row, hand-written) | Free-form actuals filled in pen — varies per step. Examples: "Actual Qty: 5839", "Actual Time: 04 min", "Actual Temperature: 170 ºF", "Actual time: 01 min / Actual temperature: 190 ºF / Plating thickness: 0.0005"", "PASS: ✓ FAIL: ___", "Actual thickness: 0.00057" | THIS IS THE OPERATION-MEASUREMENT INPUT LIST. Each value the operator pencils in is a `fp.step.template.input` row. |
| **Instruction** | Free-form instructions tied to a Work Instruction reference | "Immerse parts in alkaline soak cleaner for 4-6 minutes @ 150-170 ºF as per WI 10.07." / "Verify plating thickness is as per customer requirements and as per WI 10.09" / "Verify the job traveler was filled out completely and the information is correct." | maps to `fp.step.template.description` (Html) |
| **Unit** | Char (each / Minutes / Seconds / mils / min / ºF / blank) | NEW: `fp.step.template.unit` Char or Selection. Currently NOT in our model. Operators need to know what unit the actual-time / actual-thickness etc. should be in. |
| **Material** | mostly N/A; some steps have "MID PHOS" written | this column is for **chemistry callout** when the step has a material-specific bath. Maps to `fp.step.template.process_type_id` (already exists) — the process type's name prints here. |
| **Voltage** | mostly N/A | electrolytic steps only. NEW: optional `voltage_target` Float on step template. |
| **Viscosity** | mostly N/A | bath-quality callout. NEW: optional `viscosity_target` Float. |
| **Time (min)** | range, e.g. "4 - 6" / "55 - 65" / "25 - 35" | the **target range** the operator must hit. NEW: `time_min_target` + `time_max_target` Float on step template. |
| **Temp.** | (mostly empty in target column; pencilled in actual) | the **target range**. NEW: `temp_min_target` + `temp_max_target` + `temp_unit` Selection (F/C). |
| **Stamp** | initials column | runtime per-step sign-off. Captured as `fp.job.step.signoff_user_id` + signoff datetime. |
| **Date** | dd.mm.yy format | per-step completion date (auto-stamped on `Mark Done`). Already exists as `fp.job.step.date_finished`. |
### Concrete examples from the WO that show the data shape
| Step # | Tank | Operation | Targets (printed) | Actuals (handwritten) |
|---|---|---|---|---|
| 1 | — | Part verification and quantity check | unit=each | "Actual Qty: 5839" |
| 2 | — | Issue Panels | — | (no actual; just stamp/date) |
| 3 | — | Rack | — | "Actual Qty: 5839" |
| 4 | A-1 | Soak clean | 4-6 min @ 150-170 °F | "Actual time: 04 min, Actual Temperature: 170 °F" |
| 5 | A-2 | Rinse | — | (stamp only) |
| 6 | — | Water Break Test | — | "PASS: ✓" |
| 7 | A-3 | Etch | 55-65 sec | "Actual Time: 60 sec." |
| 9 | A-5 | Desmut | 55-65 sec | "Actual Time: 60 sec." |
| 11 | A-7 | Zincate | 25-35 sec | "Actual Time: 25 sec." |
| 13 | A-5 | Strip Zincate | 15-25 sec | "Actual Time: 25 sec." |
| 17 | A-13 | E-Nickel Plate | 185-190 °F | "Actual time: 01 min, Actual temperature: 190 °F, Plating thickness: 0.0005"" |
| 22 | — | Baking | (Time In / Time Out) °F | "Time In: 10:00, Time Out: 11:00, Actual temperature: 250 °F" |
| 23 | — | Inspection | mils | "Actual thickness: 0.00057, PASS: ✓" |
| 24 | — | Final Inspection | — | "Verify the job traveler was filled out completely…" |
| 25 | — | Shipping | — | "Actual Qty: 5839" |
## What the traveller tells us about our data model gaps
### Step template — NEW fields needed
| Field | Type | Reason |
|---|---|---|
| `unit` | Char or Selection | Print "each / Minutes / Seconds / mils / ºF / N/A" so operator knows units |
| `time_min_target` | Float | Lower bound of operation time |
| `time_max_target` | Float | Upper bound; runtime warns if outside |
| `time_unit` | Selection (`sec / min / hr`) | how to interpret time fields |
| `temp_min_target` | Float | Lower bound of operation temperature |
| `temp_max_target` | Float | Upper bound |
| `temp_unit` | Selection (`F / C`) | how to interpret temp fields |
| `voltage_target` | Float (optional) | electrolytic steps |
| `viscosity_target` | Float (optional) | bath-quality |
| `material_callout` | Char (optional) | "MID PHOS" — short string printed in the Material column. Defaults to `process_type_id.name` if not set. |
### Step template input list (`fp.step.template.input`) — likely defaults
For the traveller to render a useful actual-data column, each step
template should pre-populate its input list with the right entries:
- **Cleaning / etch / desmut / zincate / acid steps**: `Actual Time
(sec or min)` + (optional) `Actual Temperature (°F)`.
- **Plating step**: `Actual time (min)` + `Actual temperature (°F)` +
`Plating thickness (")`.
- **Bake step**: `Time In (HH:MM)` + `Time Out (HH:MM)` + `Actual
temperature (°F)`.
- **Receiving / racking / shipping**: `Actual Qty`.
- **Inspection (visual)**: `PASS / FAIL` selection.
- **Inspection (thickness measurement)**: `Actual thickness
(mils)` + `PASS / FAIL`.
- **Water break test**: `PASS / FAIL` selection.
- **Rinse / dry**: no input — sign-off only.
These defaults come "out of the box" when the customer drops a
library step into a recipe — no need for them to author the input
list every time. They can override / add later.
### Job-level NEW fields
| Field | Type | Reason |
|---|---|---|
| `qty_received` | Integer | "Qty Rec." column on traveller header |
| `qty_visual_inspection_rejects` | Integer | "VIS INSP." column |
| `qty_rework` | Integer | "Rework" column |
| `special_requirements` | Text | "Special Requirements" block (long free text from customer spec) |
| `qty_at_stage` (computed One2many?) | per-step running count | NEW — would let "Qty Rec.: 5850 → 5839 → 5835" auto-render on the traveller. Computed from `fp.job.step.qty_done` chain. |
### Part catalog NEW fields
| Field | Type | Reason |
|---|---|---|
| `base_material` | Char | "6061-T6511 aluminum" — currently implicit |
### Traveller report (new QWeb template)
`fusion_plating_reports/report/report_fp_job_traveller_v2.xml` — landscape A4 multi-page:
1. **Page 1**: header + Item Information block + Process-Sheet header + first 68 steps.
2. **Pages 2..N**: continuation rows for the remaining steps.
3. **Last page**: Footer / final inspection / shipping rows + room for stamps / dates.
The current `report_fp_job_traveller.xml` (in `fusion_plating_jobs`,
shipped in S5/S18) is portrait and minimal. We rebuild it to match
this paper format.
### What we'll auto-capture instead of pencilling in
| Pencilled today | Auto-captured by us |
|---|---|
| Actual Qty | comes from `fp.job.qty_done` chain (already exists) |
| Actual Time | timer on `fp.job.step` (already exists, S1/S2 stuff) |
| Actual Temperature | IoT sensor reading (already exists in `fusion_iot/fusion_plating_iot/`) — ties to step's tank, snapshot saved at sign-off |
| Plating thickness | Fischerscope auto-extract (already exists in `fusion_plating_certificates/fp.thickness.reading`, S19) |
| Time In / Time Out (bake) | bake-window record (already exists, S6/S15) |
| PASS/FAIL | QC checklist (already exists in `fusion_plating_quality`, S18/S19) |
| Stamp (initials) | per-step `signoff_user_id` (already exists) |
| Date | per-step `date_finished` (already exists) |
Most of the runtime capture machinery already exists. **The gap is
making the recipe editor expose target ranges + units + per-step
input list defaults** so the traveller can render the targets next to
the actuals.
## Patterns updated after screens 1618
18. **Paper traveller is the spec for what's authored on a step
template.** Every column on the paper sheet maps to either:
- an authored field on `fp.step.template` (target range, unit,
material callout, voltage, viscosity), OR
- a runtime-captured field on `fp.job.step` (actual time, actual
temp, sign-off, date), OR
- a runtime-captured input value (`fp.job.step.input.value`)
tied to a `fp.step.template.input` definition.
19. **Step "Actual" data is multi-field per step**, not just one
"actual" value. E.g. an etch step has Actual Time only; a
plating step has Actual Time + Actual Temp + Plating Thickness;
a bake has Time In + Time Out + Actual Temp. The input list per
step template must be flexible enough to model this — which it
already is via `input_template_ids`. We just need sane defaults
so the customer doesn't have to author them from scratch.
20. **Target ranges (e.g. 4-6 minutes, 150-170 °F)** are first-class
authored data. They drive: traveller print, runtime overrun
warnings (S7), out-of-spec alerts on IoT readings (existing),
and the operator UX cue ("you're at 7 min, target is 4-6 — log
a deviation?"). Currently we have `estimated_duration` (single
value) on `process.node` — needs to extend to min/max per step.
21. **Customer-spec callout** ("MID PHOS", "BAKE @ 250 DEG F FOR 1
HOUR", "RUN EACH LOAD WITH 3 TEST PANELS…") is per-job, not
per-step. It's the **job-header special-requirements** field
(free text, copied from customer spec library). Already covered
by `fusion.plating.customer.spec` model — needs to be pulled
onto the traveller print.
22. **Multi-pass through same tank** (e.g. step 11 = A-7 Zincate,
step 13 = A-5 Strip Zincate, step 15 = A-7 Zincate AGAIN). Our
recipe model handles this fine — each step is its own node
instance. Worth confirming the simple editor renders this
visually (e.g. "Zincate" appears twice in the ordered list,
each with its own SP-7 station tag). The screenshots from the
customer's other system in the original brainstorm already
showed this pattern (Primary Rinse appeared twice in the
Selected list).
## Screenshots 1922 — Steelhead's auto-generated CoC traveller PDF (7-page report)
These pages are the **digital output** Steelhead produces after a job
completes — a multi-page PDF traveller showing the full chain of
custody, every step transition, every captured measurement, the
operator who recorded each value, and the timestamp. This is what
gets sent to the customer with the parts. Functionally equivalent to
our own `fp.certificate` flow + the new traveller report we plan to
build.
**Critical context**: this is the OUTPUT that Steelhead generates from
its captured data. Everything on these pages is something we MUST be
able to capture + render to match feature parity. Job:
- Customer: (printed at top, e.g. cert-style header)
- Part: 2144A6201-5 OUTER CYLINDER ASSY
- Description: "ELECTROLESS NICKEL PLATING & BAKE PER LGPS 1104G & IAW
TECHNIQUE#: QA-016-36 REV.1, PLATING THICKNESS: 0.0019",
HYDROGEN EMBRITTLEMENT RELIEF BAKING @ 375ºF FOR 23 HOURS"
- Quantity: 1
- WO#: 620, PO#: 980806214, Date: 2025-09-23
- Specification(s): "Electroless Nickel Plating"
- Footer: "Cert Created At: 2025-09-23", page #/total, **Nadcap Accredited** logo, **ENTECH** logo
### Page 1 — Header + first 3 step transitions (no captured data yet)
Steps shown:
1. **Ready for Incoming Inspection** — Moved By: Riya Bhatt, Time: Sep 18, 2025 07:22:31 AM
2. **Ready For Plating** — Moved By: Kris Pathinather, Time: Sep 18, 2025 07:25:00 AM
3. **Soak Clean (S-3)** — Moved By: Kris Pathinather, Time: Sep 18, 2025 07:31:26 AM
**Pattern**: each step heading shows `Step Name (station code)` —
"Soak Clean (S-3)" combines the step's name with the chosen station.
Below the heading, "Part Number" and "Moved By: <user> Time:
<datetime>" are always rendered. Then if the step has captured
measurements, a sub-table follows.
### Page 2 — Step transitions with captured measurement tables
Each step that captured operator inputs renders a 4-column table:
**Name | Description | Value | Recorded By**
Sample tables:
**Soak Clean** sub-table (between page 1 step heading and page 2 next
step heading):
| Name | Description | Value | Recorded By |
|---|---|---|---|
| Soak Clean Time (5-10 min.) | (blank) | 00:05:22 | Kris Pathinather |
| Soak Clean Temp 165-195 (ºF) | (blank) | 170 | Kris Pathinather |
**ElectroClean (S-3)** sub-table:
| Name | Description | Value | Recorded By |
|---|---|---|---|
| ElectroClean Time 30-90 Seconds | (blank) | 00:00:55 | Kris Pathinather |
| ElectroClean Amperage (A) | (blank) | 280 | Kris Pathinather |
| Surface Area (FT2) | (blank) | 7 | Kris Pathinather |
| ElectroClean (SP-1) Temperature (ºF) | (blank) | 170 | Kris Pathinather |
**Water Break Free Test** sub-table (test-style measurement):
| Name | Description | Value | Recorded By |
|---|---|---|---|
| Water Break Free Test | "Perform water break test on parts as per WI 10.08. Observe parts for 30 60 seconds after removing from the soap rinse and observe if the production parts exhibit a water break free surface. If test fails, repeat from step Soak Clean." | PASS | Kris Pathinather |
### Page 3 — Mid-job step transitions (HCl Activation, Rinses, Plating, Porosity)
Sample tables:
**Acid Dip** sub-table (HCl Activation step — the input description
includes the WI reference):
| Name | Description | Value | Recorded By |
|---|---|---|---|
| Acid Dip Time | "Immerse parts in HCl tank for 20-40 seconds as per WI 10.07." | 00:00:30 | Kris Pathinather |
**Electroless Nickel Plating (S-10)** sub-table — KEY step, multiple
measurements:
| Name | Description | Value | Recorded By |
|---|---|---|---|
| E-Nickel Plate Temp (187-193ºF) | (blank) | 190 | Kris Pathinather |
| Final Panel Thickness | (blank) | 0.0248 | Kris Pathinather |
| Actual Thickness (") | "Immerse parts with test panels in E-Nickel tank as per WI 10.07." | 0.0018 | Kris Pathinather |
**Porosity Test** — minimal heading-only entry with one inline
"Results: PASS" text under the step name (instead of a table).
### Pages 45 — More transitions (DeRacking, Ready for De-Masking, De-Masking, Ready for bake, Bake)
Heading-only step transitions (no captured data table):
- **DeRacking** — Moved By: Ryan Persaud, Time: Sep 18, 2025 02:16:45 PM
- **Ready for De-Masking** — Moved By: Ryan Persaud, Time: Sep 18, 2025 02:20 PM
- **De-Masking** — Moved By: Ryan Persaud, Time: Sep 18, 2025 02:21:05 PM
- **Ready for bake** — Moved By: Ryan Persaud, Time: Sep 18, 2025 02:44:52 PM
- **Bake** — Moved By: Ryan Persaud, Time: Sep 18, 2025 2:45:27 PM
**Pattern**: gating / no-input steps still render a heading + "Moved
By + Time" but no measurement table. They contribute to the
chain-of-custody trail without measurements.
### Page 6/7 — Bake captured data + Adhesion Test + EN Final Inspection (final pass/fail)
**Bake** sub-table:
| Name | Description | Value | Recorded By |
|---|---|---|---|
| Hydrogen Embrittlement Time | (blank) | 23:48:19 | Ryan Persaud |
| Hydrogen Embrittlement Relief Temp (ºF) | (blank) | 375 | Ryan Persaud |
**Post Plate Inspection** (heading-only, gating)
- Moved By: Brett Kinzett, Time: Sep 22, 2025 08:22:01 AM
**Final EN inspection** sub-table — pass/fail summary:
| Name | Description | Value | Recorded By |
|---|---|---|---|
| Adhesion Test | (blank) | PASS | Brett Kinzett |
| EN Final Inspection | (blank) | PASS | Brett Kinzett |
## What this PDF tells us (key insights)
1. **Steelhead's CoC traveller is a chronological audit log**, NOT a
recipe traveller. It walks the actual transition history (not the
authored recipe order) and prints captured data per step. Useful
for AS9100 / Nadcap audit because it shows EXACTLY what happened,
in time order, by whom.
2. **Every captured input has 4 attributes**:
- **Name** (e.g. "Soak Clean Time (5-10 min.)") — the input
definition's display name. The target range is **embedded in
the name** ("(5-10 min.)") rather than a separate column. This
is a Steelhead UX choice — we should do better and put min/max
in their own columns so the auditor can see authored vs actual
side-by-side.
- **Description** (long-form WI reference) — pulled from the
step's `description` (Html) field. Sometimes blank. Usually
populated for inspection-type steps (where the operator needs
the procedure text), not for routine measurements.
- **Value** (the recorded value) — type varies: time HH:MM:SS,
number (with implicit unit), PASS/FAIL, etc.
- **Recorded By** — operator's full name.
3. **Time values are always in HH:MM:SS** even for sub-minute
readings ("00:00:55"). Steelhead uses one consistent time
format. We should do the same on the report — it's noise for
the operator entering the data ("how do I type 55 seconds?")
but clean on the report.
4. **Step heading has the station baked in** — "Soak Clean (S-3)",
"Electroless Nickel Plating (S-10)". This means our report needs
to render the **station code** chosen at runtime, not just the
step name. Already in our model (`fp.job.step.tank_id.code`).
5. **Sub-tables only appear when there's captured data**. Heading-
only steps (Ready For X, gating nodes, racking moves) just show
the chain-of-custody line. Our report should follow the same
pattern — clean PDF with no empty tables.
6. **Same input name with different runtime captures** — "Rinse
(S-11 / S-13)" is a single transition heading covering BOTH a
rinse pass through tank S-11 AND tank S-13 (two physical rinse
tanks operators dipped through in sequence). Steelhead collapses
them into one heading. Our model has each as a separate step;
we'd render two headings unless we add a "merge consecutive
identical steps" report option. **Decision later** — for now,
keep them separate.
7. **Multi-day jobs are normal**. The bake spans 23h 48min, post-
plate inspection happens days later (Sep 19 / Sep 22). The
traveller knows because each transition has its own timestamp.
Our chain-of-custody log already captures this.
8. **Two operators on the same job** — Riya Bhatt does receiving,
Kris Pathinather does the wet-line, Ryan Persaud handles
masking/bake, Brett Kinzett does final inspection. The "Moved By"
field changes per step, reflecting hand-offs. Already supported
by our model.
9. **Footer branding** — "Nadcap Accredited (Administered by PRI)"
logo + ENTECH company logo. Per-page. Page numbering "n/total".
Plus "Cert Created At: <date>". Our existing `fp.certificate`
PDF flow can render this footer; we just need to add the Nadcap
logo asset + company logo configurable.
## What this means for our recipe editor + traveller report
### Step template input list — render targets in their OWN columns
Steelhead embeds "(5-10 min.)" in the input name. We should split
this into two authored fields:
| Field | Type | Notes |
|---|---|---|
| `target_min` | Float | Lower bound (e.g. 5) |
| `target_max` | Float | Upper bound (e.g. 10) |
| `target_unit` | Char | "min" / "ºF" / "A" / "FT2" / "in" / "%" / "(blank for pass-fail)" |
| `display_label` | Char (compute) | "Soak Clean Time" or "Soak Clean Temp" — clean name without the embedded range |
Report renders 5 columns per measurement table:
**Name** | **Description** | **Target** | **Actual** | **Recorded By**
The Target column shows "5-10 min" (or "165-195 ºF" or "30-90 sec"
auto-formatted from the three new fields).
### Job traveller report = chronological audit, NOT step-order
Build the report to walk `fp.job.step` records ordered by
`date_started` (or `date_finished`), not by `sequence`. This matches
Steelhead's chain-of-custody ordering and is what auditors expect.
### Heading format for each step transition
`<Step Name> (<Tank code>)` — already supported by our model. No
gap.
### Time format
Use HH:MM:SS for all duration values on the report, even sub-minute.
On entry (tablet input), accept "55 sec" or "00:00:55" — convert to
canonical HH:MM:SS for storage.
### Pass/Fail value rendering
For boolean inputs, render "PASS" / "FAIL" in caps. Already
straightforward.
### Step description field carries the WI reference
Already supported by our `fp.step.template.description` Html field.
The report just needs to render it stripped of HTML tags inline
(plain-text in the table cell).
### Each operator's name comes from `res.users.name`
Our existing pattern. No gap.
## Patterns updated after screens 1922
23. **The CoC PDF is a chronological audit, not a recipe-order
print.** Different report from the operator-facing traveller
(which prints in recipe order so the operator knows what's
next). We need both:
- **Operator traveller** (recipe-order, paper-style, A4
landscape, blank actual columns) — what Riya prints when
Carlos starts a job.
- **Customer CoC** (chronological, captured-data, formal,
portrait, branded) — what gets attached to the cert and sent
to the customer.
Today we ship the CoC via `fp.certificate` (S18/S19). Need to
extend it to walk the chain-of-custody and render measurement
sub-tables.
24. **Target ranges should be authored as min/max + unit, not
embedded in the name string.** Steelhead embeds them which is
a UX bug — auditors can't filter by "all jobs where Soak Clean
Temp was out of range" because the range only exists as text.
By splitting into structured fields, we get free queryability
AND can colour-code the value cell on the report
(green if in range, red if not).
25. **Step description vs input description**: Steelhead uses the
"Description" column on the input table to show the WI
reference. That's the **step-level** description repeated on
the input row. Our model already has `description` on the step
template — we just render it once at the input table header
instead of per-row. Saves report space.
26. **Inputs can be multi-valued per step** — ElectroClean has 4
inputs, E-Nickel Plate has 3, Soak Clean has 2. Our
`input_template_ids` One2many already handles this. Confirmed
by real data.
27. **"PASS" entries can be standalone (no table)** — Porosity Test
just prints "Results: PASS" inline, no input table. This is a
Steelhead simplification. We should normalize: every step with
a captured input renders a table, even if it's a single
pass/fail row. Cleaner audit trail.
28. **Move-By trail is the operator chain-of-custody. CRITICAL for
aerospace / Nadcap.** Our existing `fp.job.step.signoff_user_id`
+ `date_finished` already provides this. The report just walks
them in time order.
## Screenshot 23 — Page 7/7 of CoC: Final Inspection / Packaging + Ready For Shipping
Step transitions on this page:
- **Ready For Final Inspection / Packaging** — Moved By: Brett Kinzett, Sep 22, 2025 08:42:22 AM (heading-only, gating)
- **Final Inspection / Packaging** — Moved By: Brett Kinzett, Sep 23, 2025 11:39:47 AM (heading + measurement table)
- **Ready For Shipping** — Moved By: Brett Kinzett, Sep 24, 2025 01:45:49 PM (heading-only, gating)
- (unnamed transitions) — Sep 24, 2025 02:03:29 PM and 02:06:16 PM
**Final Inspection / Packaging** sub-table:
| Name | Description | Value | Recorded By |
|---|---|---|---|
| Outgoing Part Count | (blank) | PASS | Brett Kinzett |
| Thickness Test Pass / Fail | (blank) | PASS | Brett Kinzett |
| Qty Accepted | (blank) | 1 | Brett Kinzett |
| Qty Rejected | (blank) | 0 | Brett Kinzett |
| Actual Coating Thickness - Final | (blank) | 0.0018 | Brett Kinzett |
Notes:
- "Outgoing Part Count" with value "PASS" is odd — looks like Steelhead conflated a count check with a pass/fail input. Our model should keep them separate (`Qty Accepted` integer + `Outgoing Part Count Verified` boolean).
- "Qty Accepted" + "Qty Rejected" are the **final disposition split** at packaging time. Non-negotiable for AS9100 shipment audit.
- "Actual Coating Thickness - Final" duplicates the mid-job thickness reading captured at E-Nickel Plate. Steelhead re-takes it at final inspection as a sanity-check before sign-off.
- The two unnamed transition rows at the bottom are likely "Shipped" / "Closed" gating moves whose headings got cut off the screenshot.
## Screenshot 24 — Cert sign-off footer (last block on the CoC)
Two side-by-side panels:
**Left panel — "Certified By:"**
- Embedded signature image (handwritten "K Pathinathn" — Kris Pathinather signed)
- Printed name below: "Name: Kris Pathinather"
**Right panel — "Certification Statement: Ref. WO# 620"** (top half) + **"Other Comments:"** (bottom half), both blank-but-bordered for handwritten or template-rendered cert language.
This is the **certificate of conformance attestation block** — a
named individual takes legal responsibility for the cert. Required
for Nadcap, AS9100, CGP. Already supported by our existing
`fp.certificate.signoff_user_id` + signature image (S18). Just need
to surface the signed image + the printed name + the cert statement
in our PDF footer.
The "Certification Statement" body in our existing CoC is already
authored on the certificate template — Steelhead leaves it blank in
this screenshot suggesting the full statement was on a different
page. We DO render it (boilerplate "We certify that the parts
conform to specification…" language).
## Patterns updated after screens 2324
29. **Final inspection captures the disposition split** — Qty
Accepted / Qty Rejected as integer fields at the last QC step.
These should appear as `input_template_ids` on a "Final
Inspection" library step (sane defaults). Plus a redundant
"final coating thickness" reading. We model these as standard
operation-measurement inputs.
30. **Certificate sign-off block is a 2-column footer**:
left = signature image + name, right = cert statement + other
comments. Already supported by `fp.certificate` (S18). Just
confirm our PDF template arranges them this way.
31. **CoC renders gating "Shipped / Closed" transitions** as
chain-of-custody entries with no measurement tables — same
treatment as "Ready For X" gating nodes.
## Screenshot inventory closed (24 screenshots logged total)
All Steelhead screens captured. The data model + UI implications
have been folded into the Pending-Questions section below. We can
now resume the brainstorm with the full picture in hand.
## Pending questions to ask user once screenshots are done
- What other Transfer Type values exist? (Hold / Scrap / Rework / Return / Splits?)
- Does Billed Labor show up on every screen or only certain ones?
- Are From Station / To Station always editable, or read-only when single-station nodes?
- What does the camera icon save against — the part record, the transition log, or the job?
- Is "Number of Customer WOs" a free-text count or a multi-select of existing customer POs?
- Is there a way to **split** a move (e.g. send 40 to next step, hold 9)? Steelhead's Part Count selector hints at it.

View File

@@ -0,0 +1,827 @@
# Sub 12 — Simple Recipe Editor + Step Library + Tablet Move/Rack + Reports
**Status**: design ready for implementation planning
**Date**: 2026-04-27
**Sliced into 3 sequential sub-projects**: 12a → 12b → 12c
**Companion file**: [Steelhead screen inventory](2026-04-27-simple-recipe-editor-steelhead-screens.md) — 24 screenshots logged with field-by-field notes that drove these decisions.
---
## 1. Why this sub-project exists
Two facts forced the design:
1. **The customer has two operator personas with opposite preferences.** "Tree-loving" engineers like our existing OWL tree editor (`fusion.plating.process.node`, hierarchical recipe → sub-process → operation → step, drag-drop tree). "Simple-loving" foremen find the tree intimidating and want a flat ordered drag-drop list with a step library on the side. Forcing one persona to use the other's tool blocks adoption.
2. **The customer is migrating off Steelhead** and brought 24 screenshots showing what their team is used to: a flat step library + a 2-column drag-drop recipe builder + Move-Parts / Move-Rack / Stop-Timer dialogs at the tablet + a chronological CoC traveller PDF. Their authoring UX is simpler than ours; their runtime UX is roughly equivalent but has gaps we can improve on (every blocker should have an inline resolution button).
Plus: the existing tree editor + 205+ live `fp.job` records + 1800+ `fp.job.step` records + the entire shopfloor runtime + the S14 predecessor lock + the S19 Fischerscope merge + the Sub 11 MRP cutout — all already shipped, all working — must keep working unchanged.
The design satisfies both personas without forking the data model. **Same recipe data, two editor views, same tablet runtime + reports.**
---
## 2. Locked decisions (Q1Q8 from the brainstorming session)
| Q | Decision |
|---|---|
| Q1 — Editor strategy | **Hybrid.** Keep the existing OWL tree editor, build the simple editor alongside. Both edit the same recipes. |
| Q2 — Data model fork | **No fork.** Both editors operate on the same `fusion.plating.process.node` records. Edits in either editor update the same recipe. |
| Q3 — Step library | **New model `fp.step.template`** as a dedicated reusable step library. Surfaced under Plating → Configuration → Step Library. |
| Q4 — Library import semantics | **Snapshot copy.** Dragging a library step into a recipe creates a fresh `fusion.plating.process.node` with the template's fields copied in. Editing the library template later does **not** mutate recipes already built. `source_template_id` carries the trace. |
| Q5 — Recipe templates ("starter recipes") | **`is_template` Boolean on the existing recipe node.** A recipe flagged as a template appears in the simple editor's "Import starter from template" dropdown. Importing snapshot-copies all child nodes. |
| Q6 — What's on a step template | **Mirror Steelhead screen 1 + Advanced expander with high-value extras.** Visible by default: Title, Stations, Operation Measurements, Instructions, Require QA. Advanced: icon, time/temp targets, voltage, viscosity, material callout, predecessor lock, rack/transition flags, transition inputs. |
| Q7 — Editor toggle | **Per-recipe `preferred_editor` (tree/simple/auto) + company-level `default_recipe_editor` setting + header buttons "Open in Tree Editor" / "Open in Simple Editor".** Authoring lead can build in tree, hand off to a foreman who edits in simple. |
| Q8 — Slicing strategy | **Three sequential sub-projects (12a → 12b → 12c).** Each independently shippable, each closes with a smoke test on entech. |
---
## 3. Architecture overview
```
┌────────────────────┐
Tree Editor (existing OWL) ─────reads/writes──▶│ │
│ Recipe data: │
Simple Editor (12a, new OWL) ─────reads/writes──▶│ fusion.plating. │
│ process.node │
Step Library (12a, new model) ─────snapshots─────▶│ (hierarchical, │
│ _parent_store) │
│ │
Recipe Template (12a, is_template)─────snapshots─────▶│ + new fields: │
│ is_template │
│ source_template_id│
│ tank_ids │
│ target ranges, │
│ units, kind, │
│ transition flags │
└─────────┬──────────┘
Same job-creation flow
(no runtime change)
┌────────────────────┐
Tablet (existing OWL) ─────operates on───▶│ fp.job.step │
+ Move Parts / Move Rack (12b) │ + new fields: │
+ Rack Parts sub-dialog (12b) │ current_rack_id │
+ Stop Timer dialog (12b) │ is_racked │
+ Soft/Hard block UX (12b) │ qty_at_step_* │
└─────────┬──────────┘
┌────────────────────┐
│ fp.job.step.move │
│ (12b, NEW) │
│ fp.rack (12b, NEW)│
│ fp.labor.timer │
│ (12b, NEW) │
└─────────┬──────────┘
Operator Traveller PDF (12c) ◀─renders from ──── recipe-order step list
Customer CoC PDF (12c) ◀─renders from ──── chronological move log
Labor History screen (12c) ◀─lists ─────────── fp.labor.timer
```
**Hard rule preserved**: every change is additive at the data layer. No FK drops, no model deletions, no ACL relaxations. Tree editor + every existing battle-test scenario + the Sub 11 MRP cutout + the Sub 12 (Quality / RMA) work all keep working unchanged.
---
## 4. Sub 12a — Simple Recipe Editor + Step Library
### 4.1 Scope
Recipe authoring only. No runtime/tablet/report changes. **Estimated 4 days.**
Customer outcome: authors can build / edit / clone recipes via a flat drag-drop editor with a step library on the side. They can flag any recipe as a starter template and import its full step list into a new recipe (snapshot copy). Tree editor untouched.
### 4.2 Data model
**New model: `fp.step.template`** (the reusable step library)
| Field | Type | Notes |
|---|---|---|
| `name` | Char, required, translate | "Solvent Clean" |
| `code` | Char | optional short code, auto-uppercased |
| `description` | Html | rich-text instructions / WI reference |
| `icon` | Selection | reuses the 24-icon list from `fusion.plating.process.node` |
| `tank_ids` | M2M `fusion.plating.tank` | allowed stations ("Stations" column in screen 1) |
| `process_type_id` | M2O `fusion.plating.process.type` | bath / chemistry tie |
| `material_callout` | Char | "MID PHOS" — short string for traveller print; defaults to `process_type_id.name` |
| `time_min_target` | Float | lower bound (`time_unit`-aware) |
| `time_max_target` | Float | upper bound |
| `time_unit` | Selection (`sec` / `min` / `hr`) | default `min` |
| `temp_min_target` | Float | lower bound (`temp_unit`-aware) |
| `temp_max_target` | Float | upper bound |
| `temp_unit` | Selection (`F` / `C`) | default `F` |
| `voltage_target` | Float (optional) | electrolytic |
| `viscosity_target` | Float (optional) | bath quality |
| `requires_signoff` | Boolean | "Require QA" |
| `requires_predecessor_done` | Boolean | S14 lock support |
| `requires_rack_assignment` | Boolean | step-type flag → triggers Rack Parts sub-dialog at runtime |
| `requires_transition_form` | Boolean | step-type flag → opens transition form before Mark Done |
| `input_template_ids` | O2M `fp.step.template.input` | operation measurements |
| `transition_input_ids` | O2M `fp.step.template.transition.input` | compliance fields collected at move-time |
| `default_kind` | Selection | `cleaning / etch / rinse / plate / bake / inspect / racking / derack / mask / demask / dry / wbf_test / final_inspect / ship / gating` — drives sane-defaults seeding |
| `active`, `sequence`, `company_id` | (standard) | |
**New model: `fp.step.template.input`** — operation measurements (recorded *during* a step)
| Field | Type | Notes |
|---|---|---|
| `name` | Char, required | e.g. "Soak Clean Time" |
| `template_id` | M2O `fp.step.template` | parent |
| `input_type` | Selection | `text / number / boolean / selection / date / signature / time_hms / time_seconds / temperature / thickness / pass_fail` (typed inputs auto-format on the report) |
| `target_min` | Float | structured target lower bound |
| `target_max` | Float | structured target upper bound |
| `target_unit` | Char | "min" / "ºF" / "A" / "FT2" / "in" |
| `required` | Boolean | hard-block sign-off if blank |
| `hint` | Char | inline help |
| `selection_options` | Text | comma-separated when `input_type='selection'` |
| `sequence` | Integer | render order |
**New model: `fp.step.template.transition.input`** — compliance fields collected *when leaving* a step
| Field | Type | Notes |
|---|---|---|
| `name` | Char, required | "Customer WO #" / "Photo Evidence" / "Scrap Reason" |
| `template_id` | M2O `fp.step.template` | parent |
| `input_type` | Selection | `text / number / boolean / selection / date / signature / photo / location_picker / customer_wo` |
| `required` | Boolean | hard-block move if blank |
| `hint` | Char | |
| `selection_options` | Text | |
| `sequence` | Integer | |
| `compliance_tag` | Selection | `none / as9100 / nadcap / cgp / nuclear` — drives audit report filter |
**Changes to existing `fusion.plating.process.node`** (all additive — zero impact on tree editor / runtime)
| Field | Type | Notes |
|---|---|---|
| `is_template` | Boolean, default False | marks a recipe (when `node_type='recipe'`) as a starter template |
| `source_template_id` | M2O `fp.step.template`, optional, indexed | snapshot trace; set when a node is created by dragging a library step in |
| `tank_ids` | M2M `fusion.plating.tank` | mirrors what library steps carry |
| `material_callout` | Char | mirrors library |
| `time_min_target`, `time_max_target`, `time_unit` | (mirrors library) | |
| `temp_min_target`, `temp_max_target`, `temp_unit` | (mirrors library) | |
| `voltage_target`, `viscosity_target` | (mirrors library) | |
| `requires_rack_assignment`, `requires_transition_form` | Boolean | mirrors library |
| `default_kind` | Selection | mirrors library; auto-set when imported |
| `transition_input_ids` | O2M to existing `fusion.plating.process.node.input` (filtered by new `kind` field) | one model, two roles |
| `preferred_editor` | Selection (`tree` / `simple` / `auto`) | per-recipe editor choice (only meaningful when `node_type='recipe'`) |
**Changes to `fusion.plating.process.node.input`**:
- Add `kind` Selection (`step_input` / `transition_input`), default `step_input` (existing rows backfill via post_init_hook).
- Add the same target-range fields as `fp.step.template.input`.
**New: `res.config.settings.default_recipe_editor`** — Selection (`tree` / `simple`), default `tree`. Drives "New Recipe" button's editor choice.
### 4.3 Simple Recipe Editor UI (OWL client action)
Registered as `fp_simple_recipe_editor` in `web.client_actions`. Single-page, full-screen, tablet-friendly.
```
┌──────────────────────────────────────────────────────────────────────┐
│ ← Back Recipe: ENP-ALUM-BASIC [Tree Editor] [Save] [More ▾]│
├──────────────────────────────────────────────────────────────────────┤
│ Title [ ENP-ALUM-BASIC ] │
│ Code [ ENP_ALUM ] │
│ Coating Config [ Electroless Nickel Mid-Phos ▾ ] │
│ Part [ — none — ▾ ] │
│ ☐ Use as starter template │
│ │
│ Import starter from template: [ Select template ▾ ] [Import] │
├──────────────────────────────────────────────────────────────────────┤
│ ┌─ Selected (drag to reorder) ─────────┐ ┌─ Step Library ──────────┐ │
│ │ ⠿ 1. Part Verification [Edit ▾] │ │ 🔍 [Search…] │ │
│ │ ⠿ 2. Solvent Clean SP-1 [Edit ▾] │ │ Acid Dip │ │
│ │ ⠿ 3. Soak Clean SP-1 [Edit ▾] │ │ Bake │ │
│ │ ⠿ 4. Rinse SP-2 [Edit ▾] │ │ E-Nickel Plate │ │
│ │ ... │ │ ... │ │
│ │ [+ Add Inline Step] │ │ [+ New Library Step] │ │
│ └──────────────────────────────────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
**Behavior**:
- **Drag from Library → Selected**: snapshot-copies the library step into the recipe as a new `fusion.plating.process.node` (`node_type=step`, `source_template_id` set, all author-defined fields copied, sane-default `input_template_ids` copied, all `transition_input_ids` copied).
- **Drag within Selected**: reorders by updating `sequence`.
- **Drag out / X button**: removes the step from the recipe (unlinks the node).
- **Station picker** per Selected row: dropdown of the step's `tank_ids` M2M.
- **Edit ▾** per row: expands inline panel with Title, Stations, Operation Measurements, Instructions, Require QA + an **Advanced** expander (icon, time/temp targets, voltage, viscosity, material callout, requires_predecessor_done, requires_rack_assignment, requires_transition_form, transition inputs).
- **+ Add Inline Step**: creates a one-off step in the recipe without touching the library.
- **+ New Library Step**: side panel to author a new `fp.step.template`.
- **Import starter from template**: dropdown of `fusion.plating.process.node` where `is_template=True AND node_type='recipe'`. Snapshot-copies all child steps preserving `sequence`. Confirms before replacing existing steps.
- **[Tree Editor]** button: switches to the existing tree editor on the same recipe.
- **Auto-save** every 5s when dirty + explicit Save.
- **Mobile/tablet responsive**: <900px width stacks columns.
**Search/filter**: case-insensitive substring match against `name + code + description (plain-text)`.
**Drag-drop**: HTML5 native dragstart/dragend, reuses helpers from existing tree editor.
**Soft-validation** for predecessor lock: a Selected row with `requires_predecessor_done=True` placed before its predecessor highlights amber with a tooltip. Doesn't block save (S14 enforces at runtime), informs the author.
### 4.4 Backend controller endpoints
New file: `fusion_plating/controllers/simple_recipe_controller.py`. JSONRPC routes:
| Route | Purpose |
|---|---|
| `POST /fp/simple_recipe/load` | recipe header + ordered step list |
| `POST /fp/simple_recipe/library/list` | all `fp.step.template` (search-filtered, company-scoped) |
| `POST /fp/simple_recipe/library/create` | new template |
| `POST /fp/simple_recipe/library/write` | update |
| `POST /fp/simple_recipe/library/delete` | unlink (soft if any node references it via `source_template_id`, hard otherwise) |
| `POST /fp/simple_recipe/step/insert` | insert (from library or blank) at position N |
| `POST /fp/simple_recipe/step/write` | inline edit |
| `POST /fp/simple_recipe/step/remove` | unlink from recipe |
| `POST /fp/simple_recipe/step/reorder` | bulk sequence update after drag-drop |
| `POST /fp/simple_recipe/template/list` | recipes where `is_template=True` (for Import dropdown) |
| `POST /fp/simple_recipe/template/import` | snapshot-copy all child nodes of a template recipe |
All endpoints honor company multi-tenancy + ACL (`group_fusion_plating_supervisor` for write, `_operator` for read).
### 4.5 Recipe form integration
**On `fusion.plating.process.node` form view** (when `node_type='recipe'`):
- Header buttons: **Open in Simple Editor** + **Open in Tree Editor**.
- New "Editor Preference" Selection field (`tree` / `simple` / `auto`).
- Clicking a recipe in the menu list routes through `preferred_editor` (falls back to company `default_recipe_editor` if `auto`).
- "Use as starter template" checkbox surfaces `is_template` (visible to supervisors only).
**Menu integration**:
- Plating → Operations → Process Recipes — existing list view; clicks route through preferred editor.
- Plating → Configuration → **Step Library** — NEW; CRUD on `fp.step.template`.
### 4.6 Sane-default input seeding per `default_kind`
| `default_kind` | Suggested `input_template_ids` |
|---|---|
| `cleaning` | Actual Time (time_seconds, "sec") + Actual Temperature (temperature, "°F") |
| `etch` | Actual Time + Actual Temperature |
| `rinse` | (none — sign-off only) |
| `plate` | Actual Time (time_hms, "min") + Actual Temperature + Plating Thickness (thickness, "in") |
| `bake` | Time In (text "HH:MM") + Time Out (text "HH:MM") + Actual Temperature |
| `racking` | Actual Qty (number, "each") |
| `derack` | Actual Qty |
| `inspect` | PASS/FAIL (pass_fail) |
| `final_inspect` | Outgoing Part Count Verified (boolean) + Qty Accepted (number, "each") + Qty Rejected (number, "each") + Actual Coating Thickness (thickness, "in") + Pass/Fail |
| `wbf_test` | PASS/FAIL |
| `mask` | Actual Qty |
| `demask` | (none) |
| `dry` | (none) |
| `ship` | Outgoing Qty (number, "each") |
| `gating` | (none) |
Implemented via server method `fp.step.template._seed_default_inputs(self)`, idempotent, exposed as a "Seed Defaults" button on the library form.
### 4.7 Migration / install
**Module**: extend `fusion_plating` core (no new module). Bump to `19.0.10.0.0`.
`post_init_hook` for 12a:
- Backfills `kind='step_input'` on all existing `fusion.plating.process.node.input` rows.
- Seeds `fp.step.template` with **18 starter templates** copied from the existing `ENP-ALUM-BASIC` recipe's child steps (Soak Clean, Rinse, Etch, Desmut, Zincate, Strip Zincate, Electroclean, Acid Dip, Water Break Test, Issue Panels, Racking, E-Nickel Plate, Hot Rinse, Drying, De-rack, Inspection, Final Inspection, Shipping). Each gets `default_kind` set + sane-default inputs seeded. Idempotent — won't re-seed if any `fp.step.template` rows already exist.
### 4.8 Verification (smoke test on entech staging)
1. Install module → step library auto-seeds 18 templates.
2. Plating → Configuration → Step Library — confirm 18 entries with sane-default inputs.
3. Plating → Operations → Process Recipes → New Recipe → Simple Editor.
4. Drag 5 library steps in, reorder via drag-drop, pick stations.
5. Save, close, reopen — data persists, sequence correct.
6. Click [Tree Editor] — same recipe opens in tree editor with all 5 steps under root. Edit step 3's name in tree editor, save, return to Simple Editor — change visible.
7. Mark a recipe as template, build a new recipe, "Import starter from template" — all steps copy in, snapshot.
8. Edit the original library step's name → previously-imported recipe steps DO NOT change (snapshot decoupling).
9. Run `bt_s2_*` battle tests on a job built from a Simple-Editor recipe — confirm runtime unaffected.
---
## 5. Sub 12b — Move Parts / Move Rack Dialogs + Tablet Transition Capture
### 5.1 Scope
Tablet UX + transition-time data capture. Uses 12a's authored data; no new recipe-authoring UI. **Estimated 34 days.**
Customer outcome: operators on the tablet get the Steelhead-style Move Parts / Move Rack flow with author-defined compliance prompts, station picker, photo evidence, and the soft/hard block UX — but with our improvement: **every blocker has a clickable resolution button**.
### 5.2 Data model
**New: `fp.rack`** — physical rack registry
| Field | Type | Notes |
|---|---|---|
| `name` | Char, required | "Rack 3" |
| `code` | Char, required | "R-03" |
| `qr_code` | Char | scannable; defaults to `FP-RACK:<code>` |
| `state` | Selection | `empty / loading / loaded / in_use / awaiting_unrack / out_of_service` |
| `current_part_count` | Integer (compute) | sum of part-batches currently on rack |
| `current_job_step_id` | M2O `fp.job.step` (compute) | current location |
| `current_tank_id` | M2O `fusion.plating.tank` (compute) | derived from current step |
| `tag_ids` | M2M `fp.rack.tag` | rack labels |
| `facility_id`, `work_center_id` | M2O | location |
| `material` | Selection (`steel / titanium / polypro / pvc / plastic / other`) | construction |
| `capacity_count` | Integer | max parts (soft warn) |
| `notes` | Text | maintenance / damage notes |
| `active`, `company_id` | (standard) | |
**New: `fp.rack.tag`** — rack labels (M2M tag registry)
| Field | Type | Notes |
|---|---|---|
| `name` | Char, required | "Rush" / "Customer-Amphenol" / "Hold-for-QC" |
| `color` | Integer | kanban color |
**New: `fp.job.step.move`** — chain-of-custody transition log (one row per move)
| Field | Type | Notes |
|---|---|---|
| `name` | Char, sequence `FP/MOVE/YYYY/NNNN` | |
| `job_id` | M2O `fp.job` | |
| `from_step_id` | M2O `fp.job.step` | source |
| `to_step_id` | M2O `fp.job.step` | destination |
| `from_tank_id` | M2O `fusion.plating.tank` | derived |
| `to_tank_id` | M2O `fusion.plating.tank` | operator's choice on multi-station node |
| `transfer_type` | Selection | `step / hold / scrap / rework / split / return` |
| `qty_moved` | Integer | partial-qty supported |
| `qty_available_at_move` | Integer | snapshot |
| `to_location` | Selection (`global / quarantine / staging_a / staging_b / shipping_dock / scrap_bin`) | |
| `photo_evidence_id` | M2O `ir.attachment` | inline-captured photo |
| `customer_wo_count` | Integer | optional |
| `rack_id` | M2O `fp.rack` | populated on rack-aware moves |
| `unrack_after_move` | Boolean | for derack steps |
| `moved_by_user_id` | M2O `res.users` | |
| `move_datetime` | Datetime | |
| `transition_input_value_ids` | O2M `fp.job.step.move.input.value` | compliance values captured |
| `chatter` | mail.thread | yes |
**New: `fp.job.step.move.input.value`** — recorded transition-input values
| Field | Type | Notes |
|---|---|---|
| `move_id` | M2O `fp.job.step.move` | parent |
| `template_input_id` | M2O `fp.step.template.transition.input` | what was asked (template-level) |
| `node_input_id` | M2O `fusion.plating.process.node.input` | snapshot of the authored prompt at job-creation time |
| `value_text` | Char | for text/selection |
| `value_number` | Float | for number |
| `value_boolean` | Boolean | for boolean |
| `value_date` | Datetime | for date |
| `value_attachment_id` | M2O `ir.attachment` | for photo/signature |
**New: `fp.labor.timer`** — persistent labor timer (lifted from screens 9, 10)
| Field | Type | Notes |
|---|---|---|
| `name` | Char, sequence `FP/TIMER/YYYY/NNNN` | |
| `user_id` | M2O `res.users` | operator |
| `job_id` | M2O `fp.job` | |
| `step_id` | M2O `fp.job.step` | step at start |
| `state` | Selection | `running / paused / stopped / reconciled` |
| `started_at`, `last_paused_at`, `stopped_at` | Datetime | |
| `total_paused_duration` | Float (compute) | sum of pauses |
| `accrued_seconds` | Integer (compute) | live for `running`, frozen otherwise |
| `billed_hrs` / `billed_min` / `billed_sec` | Integer | reconciled, default = accrued, editable on stop |
| `billed_pct` | Float (compute) | billed / accrued |
| `product_id` | M2O `product.product` | optional split-target product |
| `notes` | Text | |
| `chatter` | mail.thread | yes |
Lifecycle: `running → paused → running → stopped → reconciled`. Stop Timer dialog (screen 10) opens on stop and lets the operator reconcile billed hrs/min/sec + optional product split (creating sibling timer rows).
**Changes to existing `fp.job.step`**:
| Field | Type | Notes |
|---|---|---|
| `move_ids` | O2M `fp.job.step.move` (inverse `from_step_id`) | history of moves out of this step |
| `current_rack_id` | M2O `fp.rack` | snapshot of rack on this step (when racked) |
| `is_racked` | Boolean (compute, stored) | `current_rack_id != False` |
| `qty_at_step_start` | Integer | sum of incoming move qty |
| `qty_at_step_finish` | Integer | sum of outgoing move qty |
**Changes to `fp.job`**:
| Field | Type | Notes |
|---|---|---|
| `qty_received` | Integer | from screen 16 traveller header |
| `qty_visual_inspection_rejects` | Integer | |
| `qty_rework` | Integer | |
| `special_requirements` | Text | from customer spec |
| `active_timer_ids` | O2M `fp.labor.timer` (inverse `job_id`, filtered by state) | for live displays |
### 5.3 Move Parts dialog
Trigger: operator taps `Move Parts` on a part-batch row in the tablet, OR scans a part QR while at a step.
```
┌────────────────────────────────────────────────────────┐
│ Move Parts │
├────────────────────────────────────────────────────────┤
│ Part Count [ 49 ] Available: 49 │
│ Part Number TEST225451 (link) │
│ From Node Bake (link) │
│ From Station Bake (link) ← shown if applicable│
│ Transfer Type [ Step ▾ ] │
│ To Node Adhesion Testing (link) │
│ To Station [ Adhesion Testing ▾ ] ← shown if multi │
│ To Location [ Global ▾ ] 📷 ← inline camera │
│ │
│ ── Compliance Prompts (author-defined) ── │
│ • Customer WO # [ 731830 ] │
│ • Photo Evidence [ Attach 📷 ] │
│ • Scrap Reason [ none ▾ ] │
│ │
│ ─── Blockers ────────────────────────────────── │
│ ⚠ Additional Spec Measurements required. │
│ [ RECORD MEASUREMENTS ] ← OUR IMPROVEMENT │
│ │
│ Billed Labor ⏱ [Reset All Edits] │
│ Kris Pathinather Timer: 7s (100% billed) │
│ WO #4521 PN: TEST225451 Qty: 49 0 hrs 0 min 7 sec │
│ │
│ [ Cancel ] [ MOVE (49) ] │
└────────────────────────────────────────────────────────┘
```
**Behavior**:
- **System-derived top section**: Part Count (editable, max=available), Part Number, From Node, From Station (only if source step has a tank), Transfer Type, To Node, To Station (only if destination step's `tank_ids` count > 1), To Location.
- **Camera button next to To Location**: launches `getUserMedia` on tablet → captures photo → uploads as `ir.attachment` → links to the move.
- **Compliance Prompts section**: renders the destination step's `transition_input_ids` (snapshot from authored template). `required=True` prompts hard-block MOVE.
- **Blockers section** (NEW pattern, our improvement over Steelhead): a list of resolvable issues, each with an inline action button:
- "Spec measurements required" → opens spec input dialog inline → re-evaluates → clears.
- "Parts not racked" → opens **Rack Parts** sub-dialog (5.4).
- "Predecessor not done" (S14) → opens predecessor step's checklist.
- "Operation measurements missing" → opens operation input form on source step.
- **MOVE button state**: enabled only when all hard-blockers cleared + all required transition inputs filled. Disabled state shows tooltip listing blockers (improvement over Steelhead's silent disabled state).
- **Billed Labor section**: surfaces the operator's active `fp.labor.timer` for this WO with editable hrs/min/sec.
- **MOVE click**: creates `fp.job.step.move`, copies transition-input values, advances the part-batch, stops the active timer, advances `qty_done` on source step + `qty_at_step_start` on dest step.
### 5.4 Rack Parts sub-dialog
Trigger: from Move Parts when destination has `requires_rack_assignment=True` and operator hasn't picked a rack → "RACK PARTS" button.
```
┌────────────────────────────────────────────┐
│ Rack Parts [QR scan]│
├────────────────────────────────────────────┤
│ To Rack [ Search Racks… ▾ ] │
│ │
│ Part Number Unit Amount │
│ TEST225451 [ Count ▾] [ 49 ] Count │
│ on WO 4521 (49) │
│ │
│ Billed Labor ⏱ │
│ │
│ [Cancel] [Save] [Save + Print] │
└────────────────────────────────────────────┘
```
- **Rack picker** filters to `state='empty'` by default; "show all" toggle bypasses.
- **QR scan button**: tablet camera + parses `FP-RACK:<code>` → auto-fills "To Rack".
- **Save**: marks rack `state='loaded'`, sets `current_job_step_id`, returns to Move Parts dialog with rack populated, blocker cleared.
- **Save + Print**: same + prints rack travel ticket.
- **Unit + Amount**: defaults `Count` + Move Parts' Part Count. Editable for partial racking.
### 5.5 Move Rack dialog
Trigger: operator taps `MOVE RACK` on a rack row in the tablet.
```
┌────────────────────────────────────────────────────────┐
│ Move Rack: tyut │
├────────────────────────────────────────────────────────┤
│ Rack Labels [ Rush ✕ ] [ + ] │
│ Parts │
│ • 49 TEST225451 Parts on WO 4521 │
│ • 1 TEST225451 Parts on WO 4521 │
│ │
│ Type [ Step ▾ ] │
│ To Node [ Soak Clean (SP-1) ▾ greyed ] │
│ To Station [ Soak Clean (SP-1) ▾ ] │
│ │
│ Billed Labor ⏱ [Reset All Edits] │
│ Kris Pathinather Timer: 6s (100% billed) │
│ WO #4521 PN: TEST225451 Qty: 49 0 hrs 0 min 6 sec │
│ WO #4521 PN: TEST225451 Qty: 1 0 hrs 0 min 0 sec │
│ │
│ [Cancel] [Save] │
└────────────────────────────────────────────────────────┘
```
- **Rack name in title** from `fp.rack.name`.
- **Parts list** read-only.
- **To Node** auto-derived (greyed); To Station shown only if multi-station.
- **Per-batch billed-labor split** for each batch.
- **Save** atomically creates one `fp.job.step.move` per batch, all linked by the same `rack_id`.
### 5.6 Stop User Labor Timer dialog
Trigger: operator taps the timer pause icon on the tablet without moving parts.
Mirror screen 10. Reconcile billed hrs/min/sec + optional product split. Footer: Cancel / **Save** / **Save & Start New Timer**.
### 5.7 Tablet runtime guards
**Per-batch row in tablet station view**:
- `current_rack_id` set → MOVE PARTS button **disabled / greyed** with tooltip "Racked — use Move Rack instead."
- `current_rack_id` empty → MOVE PARTS enabled.
**Plant overview gets two panes** (mirror screen 12):
- Top: **Racks** with `MOVE RACK` per row + bulk **UNRACK MULTIPLE**.
- Bottom: **Parts** with `MOVE PARTS` per row (greyed when racked) + **+ ADD NEW PARTS** + filters + search.
**Soft/hard block protocol** (our protocol — improves on Steelhead):
- Amber banner + button enabled = soft (proceed with audit).
- Amber banner + button disabled + tooltip listing blockers = hard.
- Every blocker carries an **inline resolution button** that opens a form to resolve without leaving the dialog.
- Dialog re-evaluates blockers reactively after each resolution.
### 5.8 Backend controller endpoints
Extend `fusion_plating_jobs/controllers/tablet_controller.py`:
| Route | Purpose |
|---|---|
| `POST /fp/tablet/move_parts/preview` | dialog payload (system fields + author prompts + blockers) |
| `POST /fp/tablet/move_parts/commit` | creates `fp.job.step.move`, advances qty, handles timer |
| `POST /fp/tablet/move_rack/preview` | multi-batch dialog payload |
| `POST /fp/tablet/move_rack/commit` | atomic multi-batch move tied to a rack |
| `POST /fp/tablet/rack_parts/commit` | assigns parts to a rack |
| `POST /fp/tablet/rack_parts/print` | prints rack travel ticket |
| `POST /fp/tablet/labor_timer/start` | start |
| `POST /fp/tablet/labor_timer/pause` | pause |
| `POST /fp/tablet/labor_timer/resume` | resume |
| `POST /fp/tablet/labor_timer/stop` | stop and open reconciliation |
| `POST /fp/tablet/labor_timer/reconcile` | save reconciled billed time + optional product split |
ACL: `group_fusion_plating_operator` minimum.
### 5.9 Migration / install
Same module: extend `fusion_plating` core. Bump to `19.0.10.1.0`.
`post_init_hook` for 12b:
- Seeds `fp.rack.tag` with 4 starter tags: "Rush", "Hold for QC", "Damaged", "Customer Sample".
- Backfills `fp.job.step.qty_at_step_start` from existing `qty_done` chain (idempotent).
No data destruction. No FK drops.
### 5.10 Verification (smoke test on entech staging)
1. Open tablet, scan a part QR → tablet shows the part-batch row at its current step.
2. Tap `Move Parts` → dialog opens with system fields populated, dest step's authored transition prompts rendered.
3. Try MOVE with required prompt blank → button disabled, tooltip lists the blank prompt.
4. Fill prompt → MOVE re-enables.
5. Move to a step with `requires_rack_assignment=True` → amber blocker + RACK PARTS button.
6. Click RACK PARTS → sub-dialog → pick rack → save → blocker clears, MOVE re-enables.
7. Complete MOVE → `fp.job.step.move` row created, part-batch advanced, rack state updated.
8. Confirm tablet now shows MOVE PARTS button greyed out + MOVE RACK shown on the rack row.
9. Tap MOVE RACK → dialog with all batches → save → all advance atomically.
10. Tap timer pause → Stop Timer dialog → reconcile billed time → save → state moves to `reconciled`.
11. Run `bt_s1_*` through `bt_s17_*` battle tests — confirm no regressions on existing flows.
---
## 6. Sub 12c — Reports + Persistent Labor Audit
### 6.1 Scope
Two PDF templates + a labor history screen. No new model surface — uses 12a + 12b data. **Estimated 34 days.**
Customer outcome:
- **Operator Traveller PDF**: recipe-order, paper-style A4 landscape. Equivalent to Amphenol paper sheets (screens 1618).
- **Customer CoC Traveller PDF**: chronological audit, branded, Nadcap stamped. Equivalent to Steelhead's CoC output (screens 1924).
- **Labor History screen**: surfaces `fp.labor.timer` for billing audit, payroll reconciliation, and "who-was-on-what-when" forensics.
### 6.2 Operator Traveller Report
**File**: `fusion_plating_jobs/report/report_fp_job_traveller_v2.xml`
**Layout**: A4 landscape, multi-page, table-driven.
**Replaces**: existing `report_fp_job_traveller.xml` (S5/S18 minimal portrait — kept as fallback).
Page structure mirrors Amphenol paper sheets:
- Header (every page): logo, WO# + barcode (Code 128), Date In, Due Date, Type, Order #, P.O. #, Customer + address.
- Item Information (page 1): Part #, Rev., Mat., Catg., S/N, Item-Name / Process Description, Qty Rec., Vis Insp, Rework, Special Requirements, Stamp/Date.
- Process-Sheet Header (page 1): recipe name, sub-process name, category, special req.
- Step Table (continues page-to-page): Step | Tank | Operation + Actual | Instruction | Unit | Material | Voltage | Viscosity | Time(min) | Temp | Stamp | Date.
- Footer (every page): WO# + Page n of total.
**Data source**: walks `fp.job.step` ordered by `sequence` (recipe order). Each row pulls from the step's authored fields (target ranges, units, material callout) + leaves blank lines for the operator to pencil in actuals.
**ir.actions.report**: `action_report_fp_job_traveller_v2`, paperformat A4 landscape. Smart button on `fp.job` form: **"Print Operator Traveller"**.
### 6.3 Customer CoC Traveller Report
**File**: `fusion_plating_certificates/report/report_fp_certificate_coc_v2.xml`
**Layout**: A4 portrait, multi-page, table-driven.
**Extends**: existing `fp.certificate` flow (S18/S19) — replaces minimal CoC body with chronological audit body.
Page structure mirrors Steelhead's CoC (screens 1924):
- Header (page 1): Part Number, Description, Quantity, WO#, PO#, Packing List, Date, Specification(s).
- Body: chronological list of step transitions, each rendered as:
- Step heading: `<step.name> (<tank.code>)`.
- "Part Number / Moved By / Time" line.
- If the step has captured input values, a 5-column table: **Name | Description | Target | Actual | Recorded By**.
- Last page: 2-column sign-off block (left = signed image + name, right = cert statement + comments). Footer: Nadcap logo + ENTECH logo + "Cert Created At: <date>" + page n/total.
**Critical improvements over Steelhead**:
1. **Target column is its own column** (Steelhead embeds "(5-10 min.)" in the input name).
2. **Out-of-range Actual values colour-coded red**, in-range green.
3. **Heading-only steps** (Ready For X, gating) render as compact 1-line transitions — no empty tables.
4. **Multi-day jobs**: each transition carries its own datetime; the report walks chronologically.
5. **Signature image** from `fp.certificate.signoff_user_id.x_fc_signature`.
6. **Configurable per-company**: Nadcap logo + brand logo as `res.company.x_fc_nadcap_logo` + `x_fc_company_brand_logo` (fall back to Fusion Plating logo).
**Data source**: walks `fp.job.step.move` records ordered by `move_datetime`, NOT `fp.job.step` records ordered by `sequence`. Chain-of-custody view auditors expect.
For each move:
- Render heading: `<from_step.name> (<from_tank.code>)` or `<to_step.name> (<to_tank.code>)`.
- Render "Part Number / Moved By / Time".
- If destination step had `input_template_ids` filled at move time, render the measurement table.
**ir.actions.report**: `action_report_fp_certificate_coc_v2`, paperformat A4 portrait. Smart button on `fp.certificate` form: **"Generate Customer CoC PDF"**.
Existing CoC merge with Fischerscope thickness PDF (S19) is preserved — `_fp_render_and_attach_pdf` keeps appending the Fischerscope page.
### 6.4 Labor History Screen
**Menu**: Plating → Operations → **Labor History**.
**View**: list view on `fp.labor.timer`, grouped by `user_id` then `job_id`.
**Columns**: Operator | Job | Step | State (badge) | Started | Stopped | Accrued (HH:MM:SS) | Billed | Billed % (bar) | Product.
**Filters**: My timers / Today / This week / Reconciled / Pending reconciliation / By operator / By customer.
**Group-by**: Operator / Job / Customer / Date.
**Form view**: read-only timer history with chatter for operator notes. Manager-only fields:
- `billed_hrs / billed_min / billed_sec` editable (audit-logged).
- "Re-open for re-reconciliation" button — moves a `reconciled` timer back to `stopped`.
**ACL**:
- Operator: read own timers, write own running/paused/stopped timers.
- Supervisor: read all team timers, write reconciliations.
- Manager: full edit including re-open.
### 6.5 Backend support
Extend `fusion_plating_certificates/models/fp_certificate.py`:
- New method `_fp_build_chronological_payload(self)` returns the ordered list of moves + measurement values for the QWeb template.
- Existing `_fp_render_and_attach_pdf` calls it and assembles the multi-page PDF.
- Existing Fischerscope merge logic (S19) untouched.
Extend `fusion_plating_jobs/models/fp_job.py`:
- Property `traveller_v2_step_payload` returns ordered step list with target ranges + author-defined inputs for the operator traveller QWeb template.
No new endpoints.
### 6.6 Migration / install
Bumps:
- `fusion_plating``19.0.10.2.0`
- `fusion_plating_jobs``19.0.7.0.0`
- `fusion_plating_certificates``19.0.6.0.0`
`post_init_hook` for 12c:
- Creates `paperformat.fp_a4_landscape_traveller`.
- Sets `fp.certificate.report_template_id = action_report_fp_certificate_coc_v2` so existing certs auto-use the new layout. Old `report_fp_certificate_coc.xml` stays in place as fallback.
- Bumps `fp.certificate.version` field on existing rows (drives "regenerate PDF" prompt).
- Adds `res.company.x_fc_nadcap_logo` + `x_fc_company_brand_logo` placeholders.
### 6.7 Verification (smoke test on entech staging)
1. Open an in-flight job. Click **Print Operator Traveller** → A4 landscape PDF renders with header, item info, process-sheet header, step table with target ranges + blank actual lines + tank codes.
2. Verify Code 128 barcode on header reads correctly with a phone scanner.
3. Take a completed job (ENP-ALUM-BASIC, ~25 transitions). Open its `fp.certificate`. Click **Generate Customer CoC PDF** → portrait PDF renders chronologically with WO header, transitions in time order, per-step measurement tables with Target + Actual columns, sign-off block at end with signature + Nadcap + ENTECH logos.
4. For a step where actual was out-of-spec (soak clean ran 12 min, target 4-6) → confirm Actual cell renders red.
5. Run `bt_s19_fischer_merge.py` → confirm Fischerscope PDF appends as page N+1.
6. Plating → Operations → Labor History → see all timers from smoke-test jobs. Group by Operator → expand → see hrs/min/sec breakdown. Verify reconciliation form opens for a `stopped` timer.
7. Run all existing battle tests (`bt_s1` through `bt_s17`) — no regressions.
### 6.8 Things to NOT do in 12c
- Don't replace `report_fp_certificate_coc.xml` (legacy) — keep as fallback.
- Don't touch `fp.certificate.action_issue` flow — only the rendering layer changes.
- Don't add new model fields — 12a + 12b shipped everything we need.
- Don't try to merge Operator Traveller and Customer CoC into one report — different audiences, different layouts.
- Don't bake the cert statement into the CoC template — read from `fp.certificate.cert_statement` so it stays per-customer configurable.
---
## 7. Cross-cutting concerns
### 7.1 Manager-bypass context flags (preserved)
The existing manager-bypass context-flag protocol stays as-is. New flags added by 12b:
| Flag | Skips |
|------|-------|
| `fp_skip_transition_form=True` | required transition input check on Move Parts |
| `fp_skip_rack_assignment=True` | rack-assignment check on `requires_rack_assignment` steps |
All bypasses post to chatter with user name for audit (consistent with existing flags).
### 7.2 ACL changes
- `group_fusion_plating_supervisor` gets write on `fp.step.template` + `fp.rack` + `fp.rack.tag`.
- `group_fusion_plating_operator` gets read on `fp.step.template` + `fp.rack`, write on `fp.job.step.move` + `fp.labor.timer` (own only).
- `group_fusion_plating_manager` gets full access on all new models, plus the Re-open Reconciled Timer action.
### 7.3 Multi-company
All new models carry `company_id`. All new controllers honor `request.env.company`. Step library is **company-scoped** by default — a multi-shop customer authoring a recipe in Shop A doesn't see Shop B's library. A "shared library" cross-company toggle is **out of scope** (YAGNI).
### 7.4 Performance
- `fp.step.template` is a small table (< 1000 rows expected).
- `fp.rack` is small (< 100 rows expected).
- `fp.job.step.move` will be the biggest new table (~510 rows per job × 1000s of jobs/year = 50k rows/year). All key queries index on `(job_id, move_datetime)`.
- `fp.labor.timer` similar volume. Index on `(user_id, state, started_at)`.
- Simple Editor's `library/list` endpoint paginates server-side at 50 rows + supports search (no client-side filter on full library).
### 7.5 Backwards compatibility
- Tree editor: 100% unchanged.
- Existing `ENP-ALUM-BASIC` recipe: keeps working, retroactively gets `is_template=True` if customer wants (via post-install patch).
- Existing battle tests S1S17 + S18/S19 cert flow: all keep working.
- Existing CoC report: stays as fallback. New CoC report is opt-in per customer.
- Existing tablet flows: keep working. New Move dialogs are opt-in via `requires_transition_form` flag on step templates (default False = legacy one-tap behavior).
---
## 8. Build order (single-session executable checklist)
1. Read this design + the [screen inventory](2026-04-27-simple-recipe-editor-steelhead-screens.md).
2. Confirm Sub 11 (MRP cutout) + Sub 12 quality-native work + Subs 110 fine-tuning all shipped. Check `fusion_plating` version ≥ `19.0.9.3.0` (after the tank state-control work shipped this session).
3. **Sub 12a** (recipe authoring):
1. Bump `fusion_plating/__manifest__.py` to `19.0.10.0.0`.
2. Add models: `fp.step.template` + `fp.step.template.input` + `fp.step.template.transition.input`.
3. Extend `fusion.plating.process.node` with the additive fields.
4. Extend `fusion.plating.process.node.input` with `kind` + target-range fields.
5. Build OWL `fp_simple_recipe_editor` client action + SCSS + XML template.
6. Build `simple_recipe_controller.py` JSONRPC endpoints.
7. Recipe form: header buttons + `preferred_editor` field + `is_template` checkbox.
8. Menu: Plating → Configuration → Step Library.
9. `res.config.settings.default_recipe_editor` field.
10. `post_init_hook`: backfill `kind='step_input'` + seed 18 starter library templates from ENP-ALUM-BASIC.
11. Smoke test → deploy → verify on entech.
4. **Sub 12b** (tablet move/rack/timer):
1. Bump `fusion_plating/__manifest__.py` to `19.0.10.1.0`.
2. Add models: `fp.rack` + `fp.rack.tag` + `fp.job.step.move` + `fp.job.step.move.input.value` + `fp.labor.timer`.
3. Extend `fp.job.step` + `fp.job` with the new fields.
4. Build OWL Move Parts dialog + Move Rack dialog + Rack Parts sub-dialog + Stop Timer dialog (extend tablet OWL).
5. Extend tablet plant-overview pane: Racks section + Parts section.
6. Build `tablet_controller.py` extension: 11 new endpoints.
7. Implement runtime guards (rack-vs-parts duality, soft/hard block).
8. `post_init_hook`: seed 4 starter rack tags + backfill `qty_at_step_start`.
9. Smoke test → deploy → verify on entech.
10. Run `bt_s1_*` through `bt_s17_*` — confirm no regressions.
5. **Sub 12c** (reports + labor history):
1. Bump `fusion_plating``19.0.10.2.0`, `fusion_plating_jobs``19.0.7.0.0`, `fusion_plating_certificates``19.0.6.0.0`.
2. Build `report_fp_job_traveller_v2.xml` + `ir.actions.report` + `paperformat.fp_a4_landscape_traveller` + smart button on `fp.job`.
3. Build `report_fp_certificate_coc_v2.xml` + extend `_fp_render_and_attach_pdf` to use chronological payload + extend `_fp_build_chronological_payload`.
4. Build Labor History screen: list + form + filters + group-by + ACL.
5. Add `res.company.x_fc_nadcap_logo` + `x_fc_company_brand_logo` placeholders.
6. `post_init_hook`: paperformat creation + cert template wiring.
7. Smoke test → deploy → verify on entech.
8. Run `bt_s19_fischer_merge.py` — confirm Fischerscope PDF appends.
Each sub-project deploys independently with its own version bump and `-u` command. If 12b reveals issues, 12a stays shipped. If 12c reveals issues, 12a and 12b stay shipped.
---
## 9. Things to NOT do (cross-cutting)
- **Don't touch the existing tree editor**, its OWL file, or its 7 endpoints. Sub 12a's simple editor lives alongside, not on top.
- **Don't introduce a new module.** All work extends existing modules: `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_certificates`.
- **Don't fork the recipe data model.** Both editors operate on the same `fusion.plating.process.node` records.
- **Don't make library imports live references.** Every drag-drop creates an independent snapshot. Editing the library never mutates an in-flight recipe.
- **Don't break the S14 predecessor lock, S15 bake gate, S17 scrap auto-hold, S18 cert flow, or S19 Fischerscope merge.** All of these continue to fire on jobs created from Simple Editor recipes.
- **Don't auto-install `requires_rack_assignment=True` or `requires_transition_form=True` on existing step templates.** New flags default to False so existing tablet flows are unaffected. Customer opts in step-by-step.
- **Don't hardcode any company branding** in PDFs. Logos read from `res.company` configurable fields with sensible fallbacks.
- **Don't introduce `'mrp'` or `'quality_control'` as a manifest dep.** Sub 11 and Sub 12-quality removed them; this work doesn't bring them back.
---
## 10. Open items deferred to later sub-projects
- **Spec measurements as a standalone authoring surface** (per part × step rule library) — surfaced by screen 15's hard-block warning. Belongs to a future "QC Spec Library" sub-project. For now, hard-block resolution opens whatever the existing spec input form is.
- **Multi-day audit chain on multi-shift jobs** — chain-of-custody report renders correctly today. Future work: shift-aware grouping.
- **Bulk operations** in Step Library (batch-edit station list across multiple steps) — out of scope for 12a. Customer can edit one at a time.
- **Step library import/export as YAML/JSON** — useful for cross-environment promotion but out of scope. Customer copies templates by re-creating in target env.
- **First-off / last-off QC** — already deferred from earlier sub-projects (S28-style); still deferred.
- **VEC machine auto-ingest** — already deferred; still deferred.
- **Spec measurement dialog UI** for hard-block resolution — built in a future sub-project. For now the resolution button opens the existing spec form.
---
## 11. References
- [Steelhead screen inventory](2026-04-27-simple-recipe-editor-steelhead-screens.md) — 24 screenshots, field-by-field
- Existing tree editor: `fusion_plating/static/src/js/recipe_tree_editor.js` (649 lines), `recipe_controller.py` (367 lines)
- Existing process node model: `fusion_plating/models/fp_process_node.py` (531 lines)
- Battle test scenarios driving constraints: S5, S6, S7, S14, S15, S17, S18, S19, S20 in `CLAUDE.md`
- Sub 11 cutout (MRP removal) decisions in `CLAUDE.md` § Sub 11
- Sub 12 native quality work decisions in `CLAUDE.md` § Sub 12

View File

@@ -12,14 +12,50 @@ _logger = logging.getLogger(__name__)
def post_init_hook(env):
"""Auto-detect a sensible default timezone on first install.
"""Run on first install / module upgrade. Idempotent.
Sets ``res.company.x_fc_default_tz`` to the admin user's timezone
(Odoo populates that from the browser on first login), falling back
to the host server's timezone, then to ``America/Toronto`` as a
last resort. Only writes when the field is still empty so re-installs
never clobber a user's choice.
Does several things, each guarded by an "is this already done?"
check so re-running the hook doesn't clobber state:
1. Auto-detect a sensible default timezone (original behavior).
2. Sub 12a — backfill `kind='step_input'` on existing
fusion.plating.process.node.input rows that pre-date the
`kind` field.
3. Sub 12a — seed fp.step.template with starter library entries
derived from ENP-ALUM-BASIC if the library is currently empty.
4. Sub 12b — seed 4 starter rack tags if the registry is empty.
"""
_seed_default_timezone(env)
_backfill_node_input_kind(env)
_seed_step_library_if_empty(env)
_backfill_contract_review_template(env)
_seed_rack_tags_if_empty(env)
_migrate_legacy_uom_columns(env)
def _backfill_contract_review_template(env):
"""Idempotent — ensure the Contract Review library template exists.
`_seed_step_library_if_empty` only fires on a fresh DB; existing DBs
upgraded from pre-Policy-B versions still have a populated library
minus the Contract Review entry. This function fills that hole.
Re-running it is a no-op once the template exists.
"""
Tpl = env['fp.step.template']
if Tpl.search([('default_kind', '=', 'contract_review')], limit=1):
return # already there
tpl = Tpl.create({
'name': 'Contract Review',
'default_kind': 'contract_review',
})
tpl.action_seed_default_inputs()
_logger.info(
"Fusion Plating: backfilled Contract Review library template "
"(id=%s, %s default inputs).",
tpl.id, len(tpl.input_template_ids),
)
def _seed_default_timezone(env):
from .models.fp_tz import detect_default_tz
detected = detect_default_tz(env)
@@ -30,3 +66,268 @@ def post_init_hook(env):
'Fusion Plating: set default timezone for company %s -> %s',
company.name, detected,
)
def _backfill_node_input_kind(env):
"""Sub 12a — set kind='step_input' on rows that have NULL kind."""
cr = env.cr
cr.execute(
"UPDATE fusion_plating_process_node_input "
"SET kind = 'step_input' WHERE kind IS NULL"
)
if cr.rowcount:
_logger.info(
"Fusion Plating: backfilled kind='step_input' on %s "
"fusion.plating.process.node.input rows", cr.rowcount,
)
# Mapping of recipe-step name → default_kind. Drives sane-default
# input seeding on the starter library entries.
_STARTER_KIND_BY_NAME = {
# Policy B (2026-04-28) — recipe-side Contract Review step.
# When an author drops this template into a recipe, fp.job.step.button_*
# hooks in fusion_plating_jobs detect the kind=='contract_review' and
# auto-open / gate the QA-005 audit form (fp.contract.review).
'contract review': 'contract_review',
'qa-005': 'contract_review',
'soak clean': 'cleaning',
'electroclean': 'cleaning',
'solvent clean': 'cleaning',
'rinse': 'rinse',
'primary rinse': 'rinse',
'secondary rinse': 'rinse',
'hot rinse': 'rinse',
'final rinse': 'rinse',
'etch': 'etch',
'desmut': 'etch',
'zincate': 'etch',
'strip zincate': 'etch',
'acid dip': 'etch',
'hcl activation': 'etch',
'water break test': 'wbf_test',
'water break free test': 'wbf_test',
'issue panels': 'mask',
'masking': 'mask',
'mask': 'mask',
'racking': 'racking',
'rack': 'racking',
'e-nickel plate': 'plate',
'e-nickel plating': 'plate',
'electroless nickel plate': 'plate',
'electroless nickel plating': 'plate',
'enp': 'plate',
'plate': 'plate',
'plating': 'plate',
'drying': 'dry',
'dry': 'dry',
'bake': 'bake',
'oven baking': 'bake',
'oven bake': 'bake',
'baking': 'bake',
'hydrogen embrittlement bake': 'bake',
'he bake': 'bake',
'de-rack': 'derack',
'de-racking': 'derack',
'deracking': 'derack',
'derack': 'derack',
'demask': 'demask',
'de-mask': 'demask',
'de-masking': 'demask',
'demasking': 'demask',
'inspection': 'inspect',
'incoming inspection': 'inspect',
'post-plate inspection': 'inspect',
'post plate inspection': 'inspect',
'visual inspection': 'inspect',
'porosity test': 'inspect',
'adhesion test': 'inspect',
'final inspection': 'final_inspect',
'final inspection / packaging': 'final_inspect',
'shipping': 'ship',
'pack': 'ship',
'packaging': 'ship',
# Gating steps (Steelhead-style "Ready for X" intermediate states).
'ready for incoming inspection': 'gating',
'ready for plating': 'gating',
'ready for racking': 'gating',
'ready for de-masking': 'gating',
'ready for demasking': 'gating',
'ready for masking': 'gating',
'ready for bake': 'gating',
'ready for deracking': 'gating',
'ready for de-racking': 'gating',
'ready for post plate inspection': 'gating',
'ready for post-plate inspection': 'gating',
'ready for final inspection': 'gating',
'ready for shipping': 'gating',
}
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.
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]
# 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
def _seed_step_library_if_empty(env):
"""Sub 12a — seed fp.step.template starter library.
Source priority:
1. ENP-ALUM-BASIC recipe's child nodes (best — reuses the
author-curated step set).
2. Hard-coded minimal list (fallback for fresh DBs).
"""
Tpl = env['fp.step.template']
if Tpl.search_count([]):
_logger.info(
'Fusion Plating: step library already populated, skip seed',
)
return
Node = env['fusion.plating.process.node']
src = Node.search([
('node_type', '=', 'recipe'),
'|', ('code', '=', 'ENP-ALUM-BASIC'),
('name', 'ilike', 'ENP-ALUM-BASIC'),
], limit=1)
if not src:
_seed_minimal_library(env)
return
seen = set()
for child in src.child_ids:
if child.node_type == 'step':
_create_template_from_node(env, child, seen)
else:
for grandchild in child.child_ids:
_create_template_from_node(env, grandchild, seen)
_logger.info(
"Fusion Plating: seeded step library with %s entries from %s",
len(seen), src.name,
)
def _create_template_from_node(env, node, seen):
if not node.name or node.name.lower() in seen:
return
seen.add(node.name.lower())
kind = fp_resolve_step_kind(node.name)
vals = {
'name': node.name,
'description': node.description or False,
'icon': node.icon or 'fa-cog',
'process_type_id': node.process_type_id.id,
'requires_signoff': node.requires_signoff,
'requires_predecessor_done': node.requires_predecessor_done,
'default_kind': kind,
}
# Snapshot tank_ids if the node has them (added by Sub 12a;
# existing nodes may not).
if 'tank_ids' in node._fields and node.tank_ids:
vals['tank_ids'] = [(6, 0, node.tank_ids.ids)]
# Snapshot any time/temp targets the node may already carry.
for f in ('time_min_target', 'time_max_target', 'time_unit',
'temp_min_target', 'temp_max_target', 'temp_unit'):
if f in node._fields:
vals[f] = node[f] or vals.get(f)
tpl = env['fp.step.template'].create(vals)
if kind:
tpl.action_seed_default_inputs()
def _seed_minimal_library(env):
"""Hard-coded minimal seed when ENP-ALUM-BASIC isn't on the target DB."""
Tpl = env['fp.step.template']
minimal = [
('Contract Review', 'contract_review'),
('Soak Clean', 'cleaning'),
('Electroclean', 'cleaning'),
('Rinse', 'rinse'),
('Etch', 'etch'),
('Desmut', 'etch'),
('Zincate', 'etch'),
('Acid Dip', 'etch'),
('Water Break Test', 'wbf_test'),
('Racking', 'racking'),
('De-Racking', 'derack'),
('E-Nickel Plate', 'plate'),
('Drying', 'dry'),
('Inspection', 'inspect'),
('Final Inspection', 'final_inspect'),
('Shipping', 'ship'),
]
for name, kind in minimal:
tpl = Tpl.create({'name': name, 'default_kind': kind})
tpl.action_seed_default_inputs()
_logger.info(
'Fusion Plating: seeded minimal step library (%s entries)',
len(minimal),
)
def _migrate_legacy_uom_columns(env):
"""Translate every free-text UoM column in the plating suite into the
new curated Selection keys.
Runs unconditionally on every fusion_plating upgrade so the day a
downstream module's migration converts a Char to Selection, the data
follows. Each call is a no-op when:
* the column already holds selection keys (identity mapping)
* the table doesn't exist (module not installed on this DB)
"""
from .models._fp_uom_selection import fp_migrate_uom_column
targets = [
# core
('fusion_plating_bath_parameter', 'uom', 'bath parameter'),
('fusion_plating_process_node_input', 'uom', 'process node input'),
('fusion_plating_process_node_input', 'target_unit', 'process node target'),
('fp_step_template_input', 'target_unit', 'step template input target'),
# compliance
('fusion_plating_discharge_limit', 'uom', 'discharge limit'),
('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'),
('fusion_plating_waste_manifest', 'uom', 'waste manifest'),
('fusion_plating_waste_stream', 'generation_uom', 'waste stream'),
('fusion_plating_spill_register', 'uom', 'spill register'),
# safety
('fusion_plating_chemical', 'container_uom', 'chemical container'),
('fusion_plating_exposure_monitoring', 'uom', 'exposure monitoring'),
]
for table, column, label in targets:
fp_migrate_uom_column(env, table, column, label)
def _seed_rack_tags_if_empty(env):
"""Sub 12b — seed 4 starter rack tags."""
Tag = env['fp.rack.tag']
if Tag.search_count([]):
return
starters = [
('Rush', 1),
('Hold for QC', 3),
('Damaged', 9),
('Customer Sample', 5),
]
for name, color in starters:
Tag.create({'name': name, 'color': color})
_logger.info(
'Fusion Plating: seeded %s starter rack tags', len(starters),
)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.9.2.0',
'version': '19.0.12.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -81,9 +81,16 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'data': [
'security/fp_security.xml',
'security/ir.model.access.csv',
'data/fp_landing_data.xml',
'data/fp_sequence_data.xml',
'data/fp_job_sequences.xml',
'data/fp_process_category_data.xml',
# fp_menu.xml MUST load early — defines menu_fp_root, menu_fp_config,
# menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder
# buckets. Every other view file (in this module and downstream)
# that creates a child menu under those buckets references them
# by xmlid, which has to already exist at parse time.
'views/fp_menu.xml',
'views/fp_process_type_views.xml',
'views/fp_work_center_views.xml',
'views/fp_tank_views.xml',
@@ -91,11 +98,15 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_facility_views.xml',
'views/fp_bath_views.xml',
'views/fp_process_node_views.xml',
'views/fp_step_template_views.xml',
'views/fp_rack_tag_views.xml',
'views/fp_job_step_move_views.xml',
'views/fp_job_step_timelog_views.xml',
'views/fp_rack_views.xml',
'views/fp_bath_replenishment_views.xml',
'views/fp_operator_certification_views.xml',
'views/res_config_settings_views.xml',
'views/fp_menu.xml',
'views/fp_landing_views.xml',
'views/fp_work_centre_views.xml',
'views/fp_job_views.xml',
'views/fp_job_step_views.xml',
@@ -115,8 +126,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating/static/src/scss/fusion_plating.scss',
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
'fusion_plating/static/src/scss/simple_recipe_editor.scss',
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
'fusion_plating/static/src/xml/simple_recipe_editor.xml',
'fusion_plating/static/src/js/recipe_tree_editor.js',
'fusion_plating/static/src/js/simple_recipe_editor.js',
],
},
'demo': [

View File

@@ -3,3 +3,4 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from . import recipe_controller
from . import simple_recipe_controller

View File

@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSONRPC endpoints for the Simple Recipe Editor.
All endpoints expect the user to be authenticated. Permissions are
enforced by the underlying ACL on fp.step.template + process.node:
operators get read; supervisors+ get write.
"""
from odoo import http
from odoo.http import request
# Field list copied from a library template into a new recipe step on
# drag-drop. Snapshot semantics (Q4 from the design doc — editing a
# library template later does NOT change recipes already built).
_SNAPSHOT_FIELDS = [
'name', 'code', 'description', 'icon',
'material_callout',
'time_min_target', 'time_max_target', 'time_unit',
'temp_min_target', 'temp_max_target', 'temp_unit',
'voltage_target', 'viscosity_target',
'requires_signoff', 'requires_predecessor_done',
'requires_rack_assignment', 'requires_transition_form',
'default_kind',
]
# Fields on fp.step.template.input that copy 1:1 into
# fusion.plating.process.node.input on snapshot.
_INPUT_SNAPSHOT_FIELDS = [
'name', 'input_type', 'target_min', 'target_max', 'target_unit',
'required', 'hint', 'selection_options', 'sequence',
]
class SimpleRecipeController(http.Controller):
# ------------------------------------------------------------------ load
@http.route('/fp/simple_recipe/load', type='jsonrpc', auth='user')
def load(self, recipe_id):
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
recipe.check_access('read')
steps = recipe.child_ids.sorted('sequence')
return {
'recipe': self._recipe_payload(recipe),
'steps': [self._step_payload(s) for s in steps],
}
def _recipe_payload(self, recipe):
return {
'id': recipe.id,
'name': recipe.name,
'code': recipe.code,
'is_template': recipe.is_template,
'preferred_editor': recipe.preferred_editor,
'process_type_id': (
[recipe.process_type_id.id, recipe.process_type_id.name]
if recipe.process_type_id else False
),
}
def _step_payload(self, step):
return {
'id': step.id,
'name': step.name,
'sequence': step.sequence,
'icon': step.icon,
'default_kind': step.default_kind,
'requires_signoff': step.requires_signoff,
'requires_rack_assignment': step.requires_rack_assignment,
'requires_transition_form': step.requires_transition_form,
'tank_ids': [
{'id': t.id, 'name': t.name, 'code': t.code}
for t in step.tank_ids
],
'work_center_id': step.work_center_id.id if step.work_center_id else False,
'source_template_id': step.source_template_id.id or False,
}
# --------------------------------------------------------------- library
@http.route('/fp/simple_recipe/library/list', type='jsonrpc', auth='user')
def library_list(self, query='', limit=200):
Tpl = request.env['fp.step.template']
domain = [('active', '=', True)]
if query:
domain += ['|', '|',
('name', 'ilike', query),
('code', 'ilike', query),
('description', 'ilike', query)]
records = Tpl.search(domain, limit=limit)
return {
'templates': [
{
'id': t.id,
'name': t.name,
'code': t.code,
'icon': t.icon,
'default_kind': t.default_kind,
'station_count': len(t.tank_ids),
}
for t in records
],
}
@http.route('/fp/simple_recipe/library/create', type='jsonrpc', auth='user')
def library_create(self, vals):
tpl = request.env['fp.step.template'].create(vals)
return {'id': tpl.id, 'name': tpl.name}
@http.route('/fp/simple_recipe/library/write', type='jsonrpc', auth='user')
def library_write(self, template_id, vals):
tpl = request.env['fp.step.template'].browse(template_id)
tpl.write(vals)
return {'ok': True}
@http.route('/fp/simple_recipe/library/delete', type='jsonrpc', auth='user')
def library_delete(self, template_id):
tpl = request.env['fp.step.template'].browse(template_id)
Node = request.env['fusion.plating.process.node']
used_count = Node.search_count([('source_template_id', '=', template_id)])
if used_count:
tpl.write({'active': False})
return {'ok': True, 'soft_deleted': True, 'used_in': used_count}
tpl.unlink()
return {'ok': True, 'soft_deleted': False}
# ------------------------------------------------------------------ step
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
def step_insert(self, recipe_id, template_id=False, position=99, vals=None):
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
target_seq = self._sequence_for_position(recipe, position)
new_vals = {
'parent_id': recipe.id,
'node_type': 'step',
'sequence': target_seq,
}
tpl = False
if template_id:
tpl = request.env['fp.step.template'].browse(template_id)
for f in _SNAPSHOT_FIELDS:
new_vals[f] = tpl[f]
if tpl.process_type_id:
new_vals['process_type_id'] = tpl.process_type_id.id
if tpl.tank_ids:
new_vals['tank_ids'] = [(6, 0, tpl.tank_ids.ids)]
new_vals['source_template_id'] = tpl.id
if vals:
new_vals.update(vals)
new_node = request.env['fusion.plating.process.node'].create(new_vals)
if tpl:
self._copy_inputs_from_template(tpl, new_node)
return {'id': new_node.id, 'sequence': new_node.sequence}
def _sequence_for_position(self, recipe, position):
siblings = recipe.child_ids.sorted('sequence')
if not siblings or position >= len(siblings):
return (siblings[-1].sequence + 10) if siblings else 10
if position <= 0:
return max(1, siblings[0].sequence - 10)
before = siblings[position - 1].sequence
after = siblings[position].sequence
return (before + after) // 2 if (after - before) > 1 else before + 1
def _copy_inputs_from_template(self, tpl, new_node):
NodeInput = request.env['fusion.plating.process.node.input']
for ti in tpl.input_template_ids:
payload = {f: ti[f] for f in _INPUT_SNAPSHOT_FIELDS}
payload['node_id'] = new_node.id
payload['kind'] = 'step_input'
NodeInput.create(payload)
for tt in tpl.transition_input_ids:
NodeInput.create({
'node_id': new_node.id,
'name': tt.name,
'input_type': tt.input_type,
'required': tt.required,
'hint': tt.hint,
'selection_options': tt.selection_options,
'sequence': tt.sequence,
'compliance_tag': tt.compliance_tag,
'kind': 'transition_input',
})
@http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user')
def step_write(self, node_id, vals):
node = request.env['fusion.plating.process.node'].browse(node_id)
node.write(vals)
return {'ok': True}
@http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user')
def step_remove(self, node_id):
node = request.env['fusion.plating.process.node'].browse(node_id)
node.unlink()
return {'ok': True}
@http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user')
def step_reorder(self, node_ids):
Node = request.env['fusion.plating.process.node']
for i, nid in enumerate(node_ids, start=1):
Node.browse(nid).write({'sequence': i * 10})
return {'ok': True}
# -------------------------------------------------------------- template
@http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user')
def template_list(self):
Node = request.env['fusion.plating.process.node']
recipes = Node.search([
('node_type', '=', 'recipe'),
('is_template', '=', True),
('active', '=', True),
], order='name')
return {
'templates': [
{'id': r.id, 'name': r.name, 'code': r.code,
'step_count': len(r.child_ids)}
for r in recipes
],
}
@http.route('/fp/simple_recipe/template/import', type='jsonrpc', auth='user')
def template_import(self, source_recipe_id, target_recipe_id):
Node = request.env['fusion.plating.process.node']
source = Node.browse(source_recipe_id)
target = Node.browse(target_recipe_id)
imported = 0
for child in source.child_ids.sorted('sequence'):
self._snapshot_step_into(child, target)
imported += 1
return {'ok': True, 'imported_count': imported}
def _snapshot_step_into(self, src_node, target_recipe):
Node = request.env['fusion.plating.process.node']
new_vals = {
'parent_id': target_recipe.id,
'node_type': 'step',
'sequence': src_node.sequence,
'source_template_id': src_node.source_template_id.id or False,
}
for f in _SNAPSHOT_FIELDS:
new_vals[f] = src_node[f]
if src_node.process_type_id:
new_vals['process_type_id'] = src_node.process_type_id.id
if src_node.tank_ids:
new_vals['tank_ids'] = [(6, 0, src_node.tank_ids.ids)]
new_node = Node.create(new_vals)
NodeInput = request.env['fusion.plating.process.node.input']
for src_in in src_node.input_ids:
NodeInput.create({
'node_id': new_node.id,
'name': src_in.name,
'input_type': src_in.input_type,
'required': src_in.required,
'hint': src_in.hint,
'selection_options': src_in.selection_options,
'sequence': src_in.sequence,
'kind': src_in.kind or 'step_input',
'target_min': src_in.target_min,
'target_max': src_in.target_max,
'target_unit': src_in.target_unit,
'compliance_tag': src_in.compliance_tag,
})

View File

@@ -0,0 +1,49 @@
<?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.
Phase 1 — Plating landing-page resolver.
The Plating app's root menu (menu_fp_root) calls this server action
on click. It resolves which window action to open in this priority
order:
1. user.x_fc_plating_landing_action_id (per-user override)
2. company.x_fc_default_landing_action_id (company default)
3. action_fp_sale_orders (hardcoded fallback)
Falls back to Sale Orders so that pre-Sub-12d users who haven't
set a preference still land on the Sale-Orders default we shipped
earlier in the session.
-->
<odoo noupdate="0">
<record id="action_fp_resolve_plating_landing" model="ir.actions.server">
<field name="name">Plating — Open Landing Page</field>
<field name="model_id" ref="base.model_res_users"/>
<field name="state">code</field>
<field name="code"><![CDATA[
# Resolve in priority order: user pref → company default → Sale Orders fallback.
user = env.user
target = False
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
target = user.x_fc_plating_landing_action_id.sudo()
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
target = env.company.x_fc_default_landing_action_id.sudo()
if not target:
target = env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False)
if target:
action = target.sudo().read()[0]
# Strip ids that confuse the act_window dispatcher.
action.pop('id', None)
else:
# Last-ditch — open the Plating app's process recipes if even
# the Sale Orders action is missing (e.g. configurator not installed).
action = env.ref('fusion_plating.action_fp_process_recipe').sudo().read()[0]
action.pop('id', None)
]]></field>
</record>
</odoo>

View File

@@ -22,5 +22,13 @@
<field name="company_id" eval="False"/>
</record>
<!-- Sub 12b — Move Parts / Move Rack chain-of-custody log -->
<record id="seq_fp_job_step_move" model="ir.sequence">
<field name="name">FP — Move Log</field>
<field name="code">fp.job.step.move</field>
<field name="prefix">FP/MOVE/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.1.0 — Convert every free-text UoM column to the curated
selection keys defined in models/_fp_uom_selection.py.
Runs after fusion_plating's tables have been re-described (so the
columns are now Selection-typed at the ORM level), but before users
hit the new views. Idempotent — re-running maps already-converted
values to themselves and leaves them in place.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating.models._fp_uom_selection import (
fp_migrate_uom_column,
)
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = Environment(cr, 1, {}) # SUPERUSER
targets = [
# core
('fusion_plating_bath_parameter', 'uom', 'bath parameter'),
('fusion_plating_process_node_input', 'uom', 'process node input'),
('fusion_plating_process_node_input', 'target_unit', 'process node target'),
('fp_step_template_input', 'target_unit', 'step template input target'),
# compliance (only migrated when the module is installed — the
# helper is no-op when the table doesn't exist)
('fusion_plating_discharge_limit', 'uom', 'discharge limit'),
('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'),
('fusion_plating_waste_manifest', 'uom', 'waste manifest'),
('fusion_plating_waste_stream', 'generation_uom', 'waste stream'),
('fusion_plating_spill_register', 'uom', 'spill register'),
# safety
('fusion_plating_chemical', 'container_uom', 'chemical container'),
('fusion_plating_exposure_monitoring', 'uom', 'exposure monitoring'),
]
total_rewritten = total_cleared = 0
for table, column, label in targets:
rewritten, cleared = fp_migrate_uom_column(env, table, column, label)
total_rewritten += rewritten
total_cleared += cleared
_logger.info(
'Fusion Plating 19.0.12.1.0 — UoM migration complete: '
'%s rewritten, %s cleared (across %s columns).',
total_rewritten, total_cleared, len(targets),
)

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.4.0 — Step-library polish + Policy B Contract Review backfill.
post_init_hook only fires on fresh install. Existing DBs upgrading
from pre-Policy-B versions need this migration to:
1. Add the missing 'Contract Review' library template (the
_seed_step_library_if_empty seeder skipped it because their
library was already populated when 19.0.12.3.0 landed).
2. Backfill default_kind on existing library entries that landed
without a kind because the original seeder used a brittle
case-sensitive lookup that missed common name variations
("E-Nickel Plating" vs "E-Nickel Plate", "DeRacking" vs
"De-Racking", "Ready for X" gating prefixes, etc.). The new
`fp_resolve_step_kind` helper is hyphen / case / -ing tolerant.
3. Add canonical missing entries (Soak Clean, Rinse, Etch, Acid Dip,
Drying, Inspection, Shipping, Water Break Test, Desmut, Zincate)
that ENP-ALUM-BASIC's seed didn't include — these are the names
a fresh estimator would expect to find when they open the library
from scratch. Without them, an empty recipe has no obvious starting
templates for cleaning / rinsing / standard inspection.
All three steps are idempotent — re-running on an already-fixed DB
is a no-op.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating import fp_resolve_step_kind
_logger = logging.getLogger(__name__)
CANONICAL_MISSING = [
('Soak Clean', 'cleaning'),
('Electroclean', 'cleaning'),
('Rinse', 'rinse'),
('Etch', 'etch'),
('Desmut', 'etch'),
('Zincate', 'etch'),
('Acid Dip', 'etch'),
('HCl Activation', 'etch'),
('Water Break Test', 'wbf_test'),
('Drying', 'dry'),
('Inspection', 'inspect'),
('Final Inspection', 'final_inspect'),
('Shipping', 'ship'),
('Contract Review', 'contract_review'),
]
def migrate(cr, version):
env = Environment(cr, 1, {}) # SUPERUSER
Tpl = env['fp.step.template']
# ---- 1. Backfill default_kind on existing library entries -----------
blank_kind = Tpl.search([('default_kind', '=', False)])
fixed = 0
for tpl in blank_kind:
kind = fp_resolve_step_kind(tpl.name)
if kind:
tpl.default_kind = kind
tpl.action_seed_default_inputs()
fixed += 1
_logger.info(
'Fusion Plating 19.0.12.4.0: backfilled default_kind on %s/%s '
'library entries via fp_resolve_step_kind.',
fixed, len(blank_kind),
)
# ---- 2. Add canonical missing entries -------------------------------
existing_names_lower = {
(n.strip().lower()) for n in Tpl.search([]).mapped('name') if n
}
added = 0
for name, kind in CANONICAL_MISSING:
if name.lower() in existing_names_lower:
continue
tpl = Tpl.create({
'name': name,
'default_kind': kind,
})
tpl.action_seed_default_inputs()
added += 1
_logger.info(
'Fusion Plating 19.0.12.4.0: added %s canonical missing library '
'entries (Soak Clean, Rinse, Etch, etc.).', added,
)

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.5.0 — Backfill default_kind on existing recipe nodes.
The Page-2 audit (2026-04-28) showed that pre-Sub-12a recipe nodes
have NULL `default_kind` because the field was added later. The
recipe-side soft-gates (Sub 8 racking, Policy B contract review) fall
back to name-matching when the kind is missing, which means a
renamed step ("Hang on Bar" instead of "Racking") silently bypasses
the gate.
This migration walks `fusion.plating.process.node` rows with NULL
default_kind, resolves a sensible kind via the central
`fp_resolve_step_kind()` helper, and sets it.
It also walks `fp.job.step` rows whose `kind` is the legacy 'other'
placeholder and re-derives `kind` from `recipe_node_id.default_kind`
(after the node-side backfill above sets it). Non-other kinds are
left alone — operator may have set them deliberately.
Idempotent.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating import fp_resolve_step_kind
_logger = logging.getLogger(__name__)
# Same mapping as in fp_job.py — keep them in sync.
_NODE_KIND_TO_STEP_KIND = {
'cleaning': 'wet',
'etch': 'wet',
'rinse': 'wet',
'plate': 'wet',
'dry': 'wet',
'wbf_test': 'wet',
'bake': 'bake',
'mask': 'mask',
'demask': 'mask',
'racking': 'rack',
'derack': 'rack',
'inspect': 'inspect',
'final_inspect': 'inspect',
'contract_review': 'other',
'gating': 'other',
'ship': 'other',
}
def migrate(cr, version):
env = Environment(cr, 1, {})
# ---- 1. Backfill default_kind on recipe nodes -----------------------
Node = env['fusion.plating.process.node']
blank = Node.search([
('default_kind', '=', False),
('node_type', 'in', ('operation', 'step')),
])
fixed = 0
for n in blank:
kind = fp_resolve_step_kind(n.name)
if kind:
n.default_kind = kind
fixed += 1
_logger.info(
'19.0.12.5.0: backfilled default_kind on %s/%s recipe nodes via '
'fp_resolve_step_kind.', fixed, len(blank),
)
# ---- 2. Re-derive fp.job.step.kind from recipe node default_kind ----
Step = env['fp.job.step']
other_steps = Step.search([
('kind', '=', 'other'),
('recipe_node_id', '!=', False),
('state', 'not in', ('done', 'cancelled')),
])
rederived = 0
for s in other_steps:
node_kind = (
s.recipe_node_id.default_kind
if 'default_kind' in s.recipe_node_id._fields else None
)
new_kind = _NODE_KIND_TO_STEP_KIND.get(node_kind) if node_kind else None
if new_kind and new_kind != 'other':
s.kind = new_kind
rederived += 1
_logger.info(
'19.0.12.5.0: re-derived kind on %s/%s in-flight job steps from '
'recipe node default_kind.', rederived, len(other_steps),
)

View File

@@ -8,7 +8,9 @@ from . import fp_process_type
from . import fp_facility
from . import fp_work_center
from . import fp_work_centre
from . import fp_tank_section
from . import fp_tank
from . import fp_tank_composition
from . import fp_bath
from . import fp_bath_log
from . import fp_bath_log_line
@@ -32,3 +34,15 @@ from . import fp_work_role
from . import fp_proficiency
from . import hr_employee
from . import fp_process_node_inherit
# Sub 12a — Simple Recipe Editor + Step Library
from . import fp_step_template
from . import fp_step_template_input
from . import fp_step_template_transition_input
# Sub 12b — Rack-aware moves + persistent labor reconciliation
from . import fp_rack_tag
from . import fp_job_step_move
# Phase 1 — Plating landing-page resolver
from . import fp_landing

View File

@@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Shared Unit-of-Measure selection list for plating chemistry, physical
quantities, and process inputs.
Free-text unit fields invite typos ("kgs", "Kg", "kilo", "KG") that
break filters, reports, and trend graphs. Every UoM in the plating
domain — chemistry, mass, volume, length, area, electrical, time,
pressure, dimensionless — lives here as a curated selection so users
pick from a known list instead of typing.
Re-use:
from .._fp_uom_selection import FP_UOM_SELECTION, FP_UOM_LEGACY_MAP
uom = fields.Selection(FP_UOM_SELECTION, string='Unit')
Migration:
Use FP_UOM_LEGACY_MAP to translate pre-existing free-text values
into selection keys during post_init / migration. Anything not in
the map gets cleared (NULL) so the user is forced to pick.
"""
# Single source of truth — keep alphabetised within each section.
FP_UOM_SELECTION = [
# --- Concentration / chemistry ---------------------------------------
('g_l', 'g/L'),
('mg_l', 'mg/L'),
('ug_l', 'µg/L'),
('kg_l', 'kg/L'),
('oz_gal', 'oz/gal (US)'),
('oz_gal_imp', 'oz/Imp gal'),
('ml_l', 'mL/L'),
('mol_l', 'mol/L'),
('n', 'N (Normality)'),
('ppm', 'ppm'),
('ppb', 'ppb'),
('pct', '%'),
('pct_w', '% (w/w)'),
('pct_v', '% (v/v)'),
('pct_vw', '% (v/w)'),
# --- Temperature -----------------------------------------------------
('c', '°C'),
('f', '°F'),
('k', 'K'),
# --- Dimensionless / pH / specific units -----------------------------
('ph', 'pH'),
('su', 'SU (Standard Units)'),
('ratio', 'Ratio (e.g. 5:1)'),
('none', '— (none)'),
# --- Conductivity / turbidity ----------------------------------------
('us_cm', 'µS/cm'),
('ms_cm', 'mS/cm'),
('ntu', 'NTU'),
# --- Time ------------------------------------------------------------
('s', 's (seconds)'),
('min', 'min'),
('h', 'h'),
('day', 'day'),
# --- Mass ------------------------------------------------------------
('mg', 'mg'),
('g', 'g'),
('kg', 'kg'),
('t', 't (tonne)'),
('oz', 'oz'),
('lb', 'lb'),
# --- Volume ----------------------------------------------------------
('ml', 'mL'),
('l', 'L'),
('m3', ''),
('gal_us', 'US gal'),
('gal_imp', 'Imp gal'),
('ft3', 'ft³'),
# --- Length / thickness ----------------------------------------------
('nm', 'nm'),
('um', 'µm'),
('mm', 'mm'),
('cm', 'cm'),
('m', 'm'),
('mil', 'mil (0.001 in)'),
('in', 'in'),
('ft', 'ft'),
# --- Area ------------------------------------------------------------
('cm2', 'cm²'),
('m2', ''),
('in2', 'in²'),
('ft2', 'ft²'),
('dm2', 'dm²'),
# --- Electrical / current density ------------------------------------
('a', 'A'),
('ma', 'mA'),
('v', 'V'),
('asd_a_dm2', 'A/dm² (ASD)'),
('asd_a_ft2', 'A/ft² (ASF)'),
('dm2_l', 'dm²/L (load)'),
# --- Pressure --------------------------------------------------------
('pa', 'Pa'),
('kpa', 'kPa'),
('bar', 'bar'),
('psi', 'psi'),
('mmhg', 'mmHg'),
# --- Rate / flow / generation ----------------------------------------
('kg_day', 'kg/day'),
('l_day', 'L/day'),
('kg_month', 'kg/month'),
('l_min', 'L/min'),
('gpm', 'gpm'),
('cfm', 'cfm'),
# --- Exposure / occupational hygiene ---------------------------------
('mg_m3', 'mg/m³'),
('ug_m3', 'µg/m³'),
('dba', 'dBA'),
('lux', 'lux'),
# --- Plating-specific counts -----------------------------------------
('mto', 'MTO (metal turnover)'),
('cycles', 'cycles'),
('count', 'count'),
('each', 'each'),
('rpm', 'rpm'),
]
# Map free-text values produced before this list existed → selection keys.
# Keep keys lower-cased + stripped during lookup.
FP_UOM_LEGACY_MAP = {
# Concentration
'g/l': 'g_l',
'gpl': 'g_l',
'grams/l': 'g_l',
'g per l': 'g_l',
'mg/l': 'mg_l',
'ug/l': 'ug_l',
'µg/l': 'ug_l',
'kg/l': 'kg_l',
'oz/gal': 'oz_gal',
'oz/g': 'oz_gal',
'oz/gallon': 'oz_gal',
'oz/imp gal': 'oz_gal_imp',
'ml/l': 'ml_l',
'mol/l': 'mol_l',
'molar': 'mol_l',
'm': 'mol_l',
'n': 'n',
'normal': 'n',
'normality': 'n',
'ppm': 'ppm',
'ppb': 'ppb',
'%': 'pct',
'percent': 'pct',
'pct': 'pct',
'% w/w': 'pct_w',
'%(w/w)': 'pct_w',
'%w/w': 'pct_w',
'% v/v': 'pct_v',
'%v/v': 'pct_v',
'% v/w': 'pct_vw',
# Temperature
'c': 'c',
'°c': 'c',
'celsius': 'c',
'deg c': 'c',
'degc': 'c',
'f': 'f',
'°f': 'f',
'fahrenheit': 'f',
'deg f': 'f',
'degf': 'f',
'k': 'k',
'kelvin': 'k',
# Dimensionless
'ph': 'ph',
'su': 'su',
'standard units': 'su',
'ratio': 'ratio',
'-': 'none',
'none': 'none',
# Conductivity / turbidity
'us/cm': 'us_cm',
'µs/cm': 'us_cm',
'ms/cm': 'ms_cm',
'ntu': 'ntu',
# Time
'second': 's',
'seconds': 's',
'sec': 's',
'secs': 's',
's': 's',
'minute': 'min',
'minutes': 'min',
'min': 'min',
'mins': 'min',
'hour': 'h',
'hours': 'h',
'hr': 'h',
'hrs': 'h',
'h': 'h',
'day': 'day',
'days': 'day',
'd': 'day',
# Mass
'mg': 'mg',
'g': 'g',
'gr': 'g',
'gram': 'g',
'grams': 'g',
'kg': 'kg',
'kgs': 'kg',
'kilogram': 'kg',
'kilograms': 'kg',
't': 't',
'tonne': 't',
'tonnes': 't',
'metric ton': 't',
'oz': 'oz',
'ounce': 'oz',
'ounces': 'oz',
'lb': 'lb',
'lbs': 'lb',
'pound': 'lb',
'pounds': 'lb',
# Volume
'ml': 'ml',
'l': 'l',
'liter': 'l',
'liters': 'l',
'litre': 'l',
'litres': 'l',
'm3': 'm3',
'': 'm3',
'cubic meter': 'm3',
'gal': 'gal_us',
'gal_us': 'gal_us',
'us gal': 'gal_us',
'gallon': 'gal_us',
'gallons': 'gal_us',
'imp gal': 'gal_imp',
'imperial gallon': 'gal_imp',
'ft3': 'ft3',
'ft³': 'ft3',
'cubic feet': 'ft3',
'cu ft': 'ft3',
# Length
'nm': 'nm',
'um': 'um',
'µm': 'um',
'micron': 'um',
'mm': 'mm',
'cm': 'cm',
'mil': 'mil',
'in': 'in',
'inch': 'in',
'inches': 'in',
'"': 'in',
'ft': 'ft',
'feet': 'ft',
'foot': 'ft',
# Area
'cm2': 'cm2',
'cm²': 'cm2',
'm2': 'm2',
'': 'm2',
'in2': 'in2',
'in²': 'in2',
'sq in': 'in2',
'ft2': 'ft2',
'ft²': 'ft2',
'sq ft': 'ft2',
'dm2': 'dm2',
'dm²': 'dm2',
# Electrical
'a': 'a',
'amp': 'a',
'amps': 'a',
'ampere': 'a',
'amperes': 'a',
'ma': 'ma',
'milliamp': 'ma',
'milliamps': 'ma',
'v': 'v',
'volt': 'v',
'volts': 'v',
'a/dm2': 'asd_a_dm2',
'a/dm²': 'asd_a_dm2',
'asd': 'asd_a_dm2',
'a/ft2': 'asd_a_ft2',
'a/ft²': 'asd_a_ft2',
'asf': 'asd_a_ft2',
'dm2/l': 'dm2_l',
'dm²/l': 'dm2_l',
# Pressure
'pa': 'pa',
'kpa': 'kpa',
'bar': 'bar',
'psi': 'psi',
'mmhg': 'mmhg',
# Rate
'kg/day': 'kg_day',
'l/day': 'l_day',
'kg/month': 'kg_month',
'l/min': 'l_min',
'lpm': 'l_min',
'gpm': 'gpm',
'cfm': 'cfm',
# Exposure
'mg/m3': 'mg_m3',
'mg/m³': 'mg_m3',
'ug/m3': 'ug_m3',
'µg/m³': 'ug_m3',
'dba': 'dba',
'db': 'dba',
'lux': 'lux',
# Plating counts
'mto': 'mto',
'cycle': 'cycles',
'cycles': 'cycles',
'count': 'count',
'each': 'each',
'ea': 'each',
'pcs': 'each',
'pieces': 'each',
'rpm': 'rpm',
}
def fp_normalize_legacy_uom(raw_value):
"""Translate a legacy free-text UoM string to a selection key.
Returns the selection key, or None if no match (caller decides whether
to NULL the column or leave it).
"""
if raw_value is None:
return None
key = (raw_value or '').strip().lower()
if not key:
return None
return FP_UOM_LEGACY_MAP.get(key)
def fp_migrate_uom_column(env, table, column, label_for_log=None):
"""Walk a table's free-text uom column and rewrite values into the
selection keys. Unmapped values are set to NULL so the user is forced
to pick a valid one.
Idempotent — running on a column that's already converted is a no-op
because all values will already be selection keys (which are a subset
of FP_UOM_LEGACY_MAP via identity mappings like 'g_l''g_l').
Args:
env: Odoo environment.
table: SQL table name (e.g. 'fusion_plating_bath_parameter').
column: SQL column name (e.g. 'uom').
label_for_log: human-readable name for the migration log line.
"""
cr = env.cr
cr.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = %s AND column_name = %s",
(table, column),
)
if not cr.fetchone():
return 0, 0 # table/column not present (module not installed)
cr.execute(f'SELECT id, "{column}" FROM "{table}" WHERE "{column}" IS NOT NULL')
rows = cr.fetchall()
valid_keys = {k for k, _ in FP_UOM_SELECTION}
cleared = 0
rewritten = 0
for row_id, raw in rows:
if raw in valid_keys:
continue # already a selection key
new_key = fp_normalize_legacy_uom(raw)
if new_key:
cr.execute(
f'UPDATE "{table}" SET "{column}" = %s WHERE id = %s',
(new_key, row_id),
)
rewritten += 1
else:
cr.execute(
f'UPDATE "{table}" SET "{column}" = NULL WHERE id = %s',
(row_id,),
)
cleared += 1
import logging
_logger = logging.getLogger(__name__)
_logger.info(
'Fusion Plating UoM migration — %s.%s%s: %s rewritten, %s cleared',
table, column, f' ({label_for_log})' if label_for_log else '',
rewritten, cleared,
)
return rewritten, cleared

View File

@@ -256,8 +256,9 @@ class FpBathTarget(models.Model):
target_min = fields.Float(string='Min')
target_max = fields.Float(string='Max')
uom = fields.Char(
related='parameter_id.uom',
related='parameter_id.uom_display',
readonly=True,
string='Unit',
)
_sql_constraints = [

View File

@@ -47,8 +47,9 @@ class FpBathLogLine(models.Model):
readonly=True,
)
uom = fields.Char(
related='parameter_id.uom',
related='parameter_id.uom_display',
readonly=True,
string='Unit',
)
value = fields.Float(
string='Value',
@@ -79,6 +80,28 @@ class FpBathLogLine(models.Model):
)
# ==========================================================================
@api.onchange('parameter_id')
def _onchange_parameter_prefill_value(self):
"""Pre-fill `value` from the tank's setpoint when the parameter is a
temperature reading.
This means the operator (or backend user) hits "add reading", picks
Temperature, and the tank's `default_temperature` lands in the value
column automatically — they confirm with one tap or nudge with
keyboard arrows. Avoids retyping the same number every shift.
Fires only when value is currently empty so the user's edits aren't
clobbered if they go back and pick a different parameter.
"""
for rec in self:
if not rec.parameter_id or rec.value:
continue
if rec.parameter_id.parameter_type != 'temperature':
continue
tank = rec.log_id.bath_id.tank_id
if tank and tank.default_temperature:
rec.value = tank.default_temperature
@api.depends('parameter_id', 'log_id.bath_id')
def _compute_targets(self):
"""Resolve target range: per-bath override first, parameter default second."""

View File

@@ -3,7 +3,9 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
from ._fp_uom_selection import FP_UOM_SELECTION
class FpBathParameter(models.Model):
@@ -49,23 +51,37 @@ class FpBathParameter(models.Model):
required=True,
default='concentration',
)
uom = fields.Char(
uom = fields.Selection(
FP_UOM_SELECTION,
string='Unit',
help='Display unit (e.g. "g/L", "°C", "pH", "MTO").',
help='Pick the unit this parameter is measured in. Drives the unit '
'shown on every reading, target, and replenishment suggestion '
'derived from this parameter.',
)
uom_display = fields.Char(
string='Unit (display)',
compute='_compute_uom_display',
help='Resolved display string for the chosen unit '
'(e.g. "g/L", "°C") — used by views that need plain text.',
)
target_min = fields.Float(
string='Default Target Min',
help='Default target minimum. Per-bath overrides are allowed.',
help='Smallest acceptable reading, expressed in the unit selected '
'above. Anything below this is flagged Out of Spec. '
'Per-bath overrides allowed.',
)
target_max = fields.Float(
string='Default Target Max',
help='Default target maximum. Per-bath overrides are allowed.',
help='Largest acceptable reading, expressed in the unit selected '
'above. Anything above this is flagged Out of Spec. '
'Per-bath overrides allowed.',
)
target_value = fields.Float(
string='Default Setpoint / Optimum',
help='The IDEAL operating value — what the heater/chiller controls '
'toward, what dashboards compare against. Sits between '
'target_min and target_max. Per-sensor override via '
help='The IDEAL operating value, expressed in the unit selected '
'above — what the heater/chiller controls toward, what '
'dashboards compare against. Sits between Target Min and '
'Target Max. Per-sensor override via '
'fp.tank.sensor.target_value_override.',
)
warning_tolerance = fields.Float(
@@ -86,6 +102,12 @@ class FpBathParameter(models.Model):
default=True,
)
@api.depends('uom')
def _compute_uom_display(self):
labels = dict(FP_UOM_SELECTION)
for rec in self:
rec.uom_display = labels.get(rec.uom, '') if rec.uom else ''
_sql_constraints = [
(
'fp_bath_parameter_code_uniq',

View File

@@ -186,6 +186,40 @@ class FpJob(models.Model):
'job_id',
string='Steps',
)
# ===== Sub 12b — traveller header + active timer ========================
# Header counters mirror the paper traveller's "Qty Rec." / "VIS INSP."
# / "Rework" columns (screens 16-18). Sub 12c's traveller report pulls
# these into the printed header.
qty_received = fields.Integer(
string='Qty Received',
help='Paper traveller "Qty Rec." column.',
)
qty_visual_inspection_rejects = fields.Integer(
string='Visual Insp Rejects',
help='Paper traveller "VIS INSP." column.',
)
qty_rework = fields.Integer(
string='Qty Sent to Rework',
help='Paper traveller "Rework" column.',
)
special_requirements = fields.Text(
string='Special Requirements',
help='Long free-form spec text from customer; printed on the '
'traveller header (Sub 12c).',
)
active_timer_ids = fields.One2many(
'fp.job.step.timelog',
'job_id',
string='Active Timers',
domain=[('state', 'in', ('running', 'paused'))],
help='Sub 12b — used by tablet for live timer badges. Filtered '
'on state by Task 7\'s state field.',
)
move_ids = fields.One2many(
'fp.job.step.move', 'job_id',
string='Move Log',
)
# step_count + step_done_count are stored (drive list views / stat
# buttons in Task 1.8). step_progress_pct stays non-stored — it's a
# cheap derivative. Odoo flags as inconsistent when stored and

View File

@@ -139,6 +139,41 @@ class FpJobStep(models.Model):
'step in this job is done/skipped/cancelled.',
)
# ===== Sub 12b — chain-of-custody + rack awareness =====================
# Note: rack_id (line 95 above) already exists — reused as the "current
# rack on this step" pointer. Sub 12b builds the runtime guards on top.
requires_rack_assignment = fields.Boolean(
related='recipe_node_id.requires_rack_assignment',
store=True,
help='If True, the Move Parts dialog requires a rack to be '
'assigned to the parts before the move commits. Snapshot '
'from the recipe step at job creation.',
)
requires_transition_form = fields.Boolean(
related='recipe_node_id.requires_transition_form',
store=True,
)
move_ids = fields.One2many(
'fp.job.step.move', 'from_step_id',
string='Outgoing Moves',
)
incoming_move_ids = fields.One2many(
'fp.job.step.move', 'to_step_id',
string='Incoming Moves',
)
is_racked = fields.Boolean(
string='Racked', compute='_compute_is_racked', store=True,
help='True when rack_id is set — drives the tablet rack-vs-parts '
'button-state guard (Move Parts greys out).',
)
qty_at_step_start = fields.Integer(string='Qty at Step Start')
qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
@api.depends('rack_id')
def _compute_is_racked(self):
for rec in self:
rec.is_racked = bool(rec.rack_id)
# ------------------------------------------------------------------
# Cost rollup (Task 1.6)
# cost_per_hour comes from fp.work.centre (Task 1.2 added it there).
@@ -180,23 +215,64 @@ class FpJobStep(models.Model):
# ------------------------------------------------------------------
def button_pause(self):
raise NotImplementedError(_(
"button_pause is not yet implemented (operator pause / break / "
"end-of-shift). Use button_finish to complete a step or set "
"state directly via privileged code."
))
"""Operator pause / break / end-of-shift. Closes the open timelog
without finishing the step, flips state to 'paused'. button_start
will reopen a fresh timelog when resuming.
"""
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can pause."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
step.state = 'paused'
step.message_post(body=_('Step paused by %s') % self.env.user.name)
return True
def button_resume(self):
"""Resume a paused step — thin alias over button_start so views
can show distinct labels (Resume vs Start) without duplicating
the state-machine logic."""
for step in self:
if step.state != 'paused':
raise UserError(_(
"Step '%s' is in state '%s' — only paused steps can resume."
) % (step.name, step.state))
return self.button_start()
def button_skip(self):
raise NotImplementedError(_(
"button_skip is not yet implemented (skip an opt-in step that "
"wasn't activated for this job)."
))
"""Skip an opt-in step that wasn't activated for this job. Allowed
from pending or ready only — a step that's already running shouldn't
be skipped without an audit narrative (use button_cancel for that).
"""
for step in self:
if step.state not in ('pending', 'ready'):
raise UserError(_(
"Step '%s' is in state '%s' — only pending/ready steps can skip."
) % (step.name, step.state))
step.state = 'skipped'
step.message_post(body=_('Step skipped by %s') % self.env.user.name)
return True
def button_cancel(self):
raise NotImplementedError(_(
"button_cancel is not yet implemented (cancelling a single step; "
"cancelling the whole job runs through fp.job.action_cancel)."
))
"""Cancel a single step. Used when an operator realises mid-stream
that a step doesn't apply to this job (e.g. a customer-specific
step that's not needed). Closes any open timelog so labour cost
already incurred is preserved.
"""
for step in self:
if step.state in ('done', 'cancelled'):
raise UserError(_(
"Step '%s' is in state '%s' — cannot cancel."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
step.state = 'cancelled'
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
return True
def button_start(self):
for step in self:

View File

@@ -0,0 +1,104 @@
# -*- 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 FpJobStepMove(models.Model):
"""Chain-of-custody log — one row per part-batch move.
Sub 12b: every Move Parts / Move Rack click commits one (or, for
rack moves, one-per-batch atomic) row here. Sub 12c walks these in
chronological order to render the customer CoC PDF.
"""
_name = 'fp.job.step.move'
_description = 'Fusion Plating — Job Step Move (Chain-of-Custody)'
_inherit = ['mail.thread']
_order = 'move_datetime desc, id desc'
name = fields.Char(
string='Move Reference',
default=lambda self: self.env['ir.sequence'].next_by_code(
'fp.job.step.move') or '/',
readonly=True, copy=False,
)
job_id = fields.Many2one('fp.job', string='Job',
required=True, ondelete='cascade', index=True)
from_step_id = fields.Many2one('fp.job.step', string='From Step',
ondelete='set null', index=True)
to_step_id = fields.Many2one('fp.job.step', string='To Step',
ondelete='restrict', index=True, required=True)
from_tank_id = fields.Many2one('fusion.plating.tank',
related='from_step_id.tank_id', store=True)
to_tank_id = fields.Many2one('fusion.plating.tank', string='To Tank',
ondelete='set null')
transfer_type = fields.Selection([
('step', 'Step'),
('hold', 'Hold'),
('scrap', 'Scrap'),
('rework', 'Rework'),
('split', 'Split'),
('return', 'Return'),
], string='Transfer Type', default='step', required=True)
qty_moved = fields.Integer(string='Qty Moved', required=True)
qty_available_at_move = fields.Integer(string='Qty Available')
to_location = fields.Selection([
('global', 'Global'),
('quarantine', 'Quarantine'),
('staging_a', 'Staging A'),
('staging_b', 'Staging B'),
('shipping_dock', 'Shipping Dock'),
('scrap_bin', 'Scrap Bin'),
], string='To Location', default='global')
photo_evidence_id = fields.Many2one('ir.attachment',
string='Photo Evidence', ondelete='set null')
customer_wo_count = fields.Integer(string='# Customer WOs')
rack_id = fields.Many2one('fusion.plating.rack',
string='Rack', ondelete='set null', index=True)
unrack_after_move = fields.Boolean(string='Unrack After Move')
moved_by_user_id = fields.Many2one('res.users', string='Moved By',
default=lambda self: self.env.user, required=True)
move_datetime = fields.Datetime(string='Move Time',
default=fields.Datetime.now, required=True, index=True)
transition_input_value_ids = fields.One2many(
'fp.job.step.move.input.value', 'move_id',
string='Transition Input Values',
)
class FpJobStepMoveInputValue(models.Model):
"""Captured value for one transition-input prompt.
Each row = one author-defined prompt × one move. Snapshot of what
the operator typed at move-time. Used by Sub 12c CoC report.
"""
_name = 'fp.job.step.move.input.value'
_description = 'Fusion Plating — Captured Transition Input Value'
_order = 'move_id, id'
move_id = fields.Many2one('fp.job.step.move', string='Move',
required=True, ondelete='cascade', index=True)
template_input_id = fields.Many2one(
'fp.step.template.transition.input',
string='Template Input', ondelete='set null',
help='What was originally asked (template-level reference).')
node_input_id = fields.Many2one(
'fusion.plating.process.node.input',
string='Node Input', ondelete='set null',
help='Snapshot of the authored prompt at job-creation time.')
value_text = fields.Char(string='Text Value')
value_number = fields.Float(string='Number Value')
value_boolean = fields.Boolean(string='Yes/No Value')
value_date = fields.Datetime(string='Date Value')
value_attachment_id = fields.Many2one('ir.attachment',
string='Attachment Value', ondelete='set null')

View File

@@ -52,3 +52,84 @@ class FpJobStepTimeLog(models.Model):
rec_bits.append(when)
rec_bits.append(mins)
log.display_name = ' · '.join(rec_bits)
# ===== Sub 12b — persistent timer state machine =========================
# Extends the existing timelog (used by S1/S2 battle tests) with a state
# field + reconciliation columns. Default state='running' → existing
# battle tests are unaffected. Stop Timer dialog (Task 13) flips to
# stopped → reconciled with operator-edited billed_*.
state = fields.Selection(
[
('running', 'Running'),
('paused', 'Paused'),
('stopped', 'Stopped'),
('reconciled', 'Reconciled'),
],
string='State', default='running', tracking=True,
)
job_id = fields.Many2one(
'fp.job', related='step_id.job_id',
store=True, string='Job', index=True,
)
last_paused_at = fields.Datetime(string='Last Paused')
total_paused_seconds = fields.Integer(
string='Total Paused (sec)', default=0,
help='Cumulative time spent in paused state since date_started.',
)
accrued_seconds = fields.Integer(
string='Accrued (sec)',
compute='_compute_accrued_seconds',
help='Live seconds since date_started, minus total_paused_seconds. '
'Frozen for stopped/reconciled rows.',
)
billed_hrs = fields.Integer(string='Billed Hours')
billed_min = fields.Integer(string='Billed Minutes')
billed_sec = fields.Integer(string='Billed Seconds')
billed_total_seconds = fields.Integer(
string='Billed Total (sec)',
compute='_compute_billed_total_seconds', store=True,
)
billed_pct = fields.Float(
string='% Billed',
compute='_compute_billed_pct',
help='billed_total / accrued × 100. Surfaces on Stop Timer dialog.',
)
product_id = fields.Many2one(
'product.product', string='Reconciled Product',
ondelete='set null',
help='When the operator splits a timer across multiple products, '
'this row carries the destination product (Steelhead screen 10).',
)
notes = fields.Text(string='Operator Notes')
@api.depends(
'state', 'date_started', 'date_finished',
'last_paused_at', 'total_paused_seconds',
)
def _compute_accrued_seconds(self):
now = fields.Datetime.now()
for rec in self:
if not rec.date_started:
rec.accrued_seconds = 0
continue
end = rec.date_finished or now
elapsed = (end - rec.date_started).total_seconds()
rec.accrued_seconds = max(0, int(elapsed) - (rec.total_paused_seconds or 0))
@api.depends('billed_hrs', 'billed_min', 'billed_sec')
def _compute_billed_total_seconds(self):
for rec in self:
rec.billed_total_seconds = (
(rec.billed_hrs or 0) * 3600
+ (rec.billed_min or 0) * 60
+ (rec.billed_sec or 0)
)
@api.depends('billed_total_seconds', 'accrued_seconds')
def _compute_billed_pct(self):
for rec in self:
if rec.accrued_seconds:
rec.billed_pct = 100.0 * rec.billed_total_seconds / rec.accrued_seconds
else:
rec.billed_pct = 0.0

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Phase 1 — Plating landing-page resolver fields.
Three pieces:
1. `ir.actions.act_window.x_fc_pickable_landing` — Boolean tag. Mark a
curated set of plating actions (Sale Orders, Plant Overview,
Quotations, Quality Dashboard, Manager Dashboard, Tablet Station,
Labor History) so the landing-page dropdown only offers sensible
options, not all 200 act_window records in the DB.
2. `res.company.x_fc_default_landing_action_id` — admin sets the
fallback for users who don't pick a preference.
3. `res.users.x_fc_plating_landing_action_id` — each user's own
override.
The resolver server action (data/fp_landing_data.xml) reads these.
"""
from odoo import fields, models
class IrActionsActWindow(models.Model):
_inherit = 'ir.actions.act_window'
x_fc_pickable_landing = fields.Boolean(
string='Pickable as Plating Landing',
default=False,
help='When True, this action appears in the Plating landing-'
'page dropdown on res.users and res.company. Tag a small '
'curated list (Sale Orders, Plant Overview, etc.) to keep '
'the picker manageable.',
)
class ResCompany(models.Model):
_inherit = 'res.company'
x_fc_default_landing_action_id = fields.Many2one(
'ir.actions.act_window',
string='Default Plating Landing Page',
domain=[('x_fc_pickable_landing', '=', True)],
help='Page that opens when a user clicks the Plating app, '
'unless the user has chosen their own override on their '
'preferences. Falls back to Sale Orders when blank.',
)
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_plating_landing_action_id = fields.Many2one(
'ir.actions.act_window',
string='My Plating Landing Page',
domain=[('x_fc_pickable_landing', '=', True)],
help='Personal override for the page that opens when you click '
'the Plating app. When blank, follows the company default.',
)

View File

@@ -7,6 +7,7 @@ from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from .fp_tz import fp_isoformat_utc
from ._fp_uom_selection import FP_UOM_SELECTION
class FpProcessNode(models.Model):
@@ -284,6 +285,89 @@ class FpProcessNode(models.Model):
string='Operator Inputs',
)
# ===== Sub 12a — Simple Editor + Step Library extensions =================
# All fields are additive; tree editor + runtime are unaffected. Drag-drop
# from the library snapshot-copies these into a new node (no live ref).
is_template = fields.Boolean(
string='Use as Starter Template',
help='When True (and node_type=recipe), this recipe appears in the '
'Simple Editor\'s "Import starter from template" dropdown.',
)
source_template_id = fields.Many2one(
'fp.step.template',
string='Source Library Template',
ondelete='set null',
index=True,
help='Snapshot trace — set when this node was created by dragging '
'a library step in. Editing the template later does not change '
'this node (snapshot semantics).',
)
tank_ids = fields.Many2many(
'fusion.plating.tank',
'fp_node_tank_rel', 'node_id', 'tank_id',
string='Allowed Stations',
help='Stations the operator may pick at runtime.',
)
material_callout = fields.Char(
string='Material Callout',
help='Short string for traveller "Material" column. Defaults to '
'process type name if blank.',
)
time_min_target = fields.Float(string='Time Min')
time_max_target = fields.Float(string='Time Max')
time_unit = fields.Selection(
[('sec', 'Seconds'), ('min', 'Minutes'), ('hr', 'Hours')],
string='Time Unit', default='min',
)
temp_min_target = fields.Float(string='Temp Min')
temp_max_target = fields.Float(string='Temp Max')
temp_unit = fields.Selection(
[('F', '°F'), ('C', '°C')],
string='Temp Unit', default='F',
)
voltage_target = fields.Float(string='Voltage Target')
viscosity_target = fields.Float(string='Viscosity Target')
requires_rack_assignment = fields.Boolean(
string='Requires Rack Assignment',
help='Sub 12b — triggers Rack Parts sub-dialog at runtime.',
)
requires_transition_form = fields.Boolean(
string='Requires Transition Form',
help='Sub 12b — opens the transition form before Mark Done.',
)
default_kind = fields.Selection(
[
('cleaning', 'Cleaning'),
('etch', 'Etch'),
('rinse', 'Rinse'),
('plate', 'Plating'),
('bake', 'Bake'),
('inspect', 'Inspection'),
('racking', 'Racking'),
('derack', 'De-Racking'),
('mask', 'Masking'),
('demask', 'De-Masking'),
('dry', 'Drying'),
('wbf_test', 'Water Break Free Test'),
('final_inspect', 'Final Inspection'),
('ship', 'Shipping'),
('gating', 'Gating'),
('contract_review', 'Contract Review (QA-005)'),
],
string='Step Kind',
)
preferred_editor = fields.Selection(
[
('tree', 'Tree Editor'),
('simple', 'Simple Editor'),
('auto', 'Use Company Default'),
],
string='Preferred Editor', default='auto',
help='Which editor opens when this recipe is selected from the '
'menu list. "Auto" follows the company-level default.',
)
# ---- SQL constraints -----------------------------------------------------
_sql_constraints = [
@@ -462,6 +546,41 @@ class FpProcessNode(models.Model):
'context': {'recipe_id': root.id},
}
def action_open_simple_editor(self):
"""Open the OWL Simple Recipe Editor for this recipe (Sub 12a)."""
self.ensure_one()
root = self if self.node_type == 'recipe' else self.recipe_root_id
return {
'type': 'ir.actions.client',
'tag': 'fp_simple_recipe_editor',
'name': f'Recipe — {root.name}',
'context': {'recipe_id': root.id},
}
def _resolve_preferred_editor(self):
"""Returns 'tree' or 'simple' for this recipe.
Per-recipe preferred_editor wins. 'auto' falls back to the
company-level default. 'tree' is the final fallback.
"""
self.ensure_one()
if self.preferred_editor in ('tree', 'simple'):
return self.preferred_editor
return self.env.company.x_fc_default_recipe_editor or 'tree'
def action_open_recipe_with_preferred_editor(self):
"""Routes to whichever editor the recipe (or company) prefers.
Used by menu actions / context-menu opens — gives the
simple-loving foreman a one-click path that respects their
preference without forcing a tree-loving engineer to pick
between two buttons every time.
"""
self.ensure_one()
if self._resolve_preferred_editor() == 'simple':
return self.action_open_simple_editor()
return self.action_open_tree_editor()
# ---- Copy (deep-duplicate) -----------------------------------------------
def copy(self, default=None):
@@ -504,6 +623,16 @@ class FpProcessNodeInput(models.Model):
('boolean', 'Yes / No'),
('selection', 'Selection'),
('photo', 'Photo'),
# Sub 12a — typed inputs the simple editor + traveller need
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
('date', 'Date / Time'),
('signature', 'Signature'),
('location_picker', 'Location Picker'),
('customer_wo', 'Customer WO #'),
],
string='Input Type',
required=True,
@@ -525,7 +654,44 @@ class FpProcessNodeInput(models.Model):
string='Sequence',
default=10,
)
uom = fields.Char(
uom = fields.Selection(
FP_UOM_SELECTION,
string='Unit',
help='Unit label (e.g. °C, min, psi).',
help='Unit the operator is recording in (pick from the curated list — '
'avoids "kg" vs "kgs" vs "kilo" inconsistencies).',
)
# ===== Sub 12a — kind + target ranges + compliance tag ==================
kind = fields.Selection(
[
('step_input', 'Step Measurement'),
('transition_input', 'Transition Form Field'),
],
string='Kind', default='step_input', index=True,
help='step_input = recorded during the step. transition_input = '
'recorded when leaving the step (Sub 12b uses these in the '
'Move Parts dialog).',
)
target_min = fields.Float(
string='Target Min',
help='Lower bound of the acceptable range, expressed in Target Unit.',
)
target_max = fields.Float(
string='Target Max',
help='Upper bound of the acceptable range, expressed in Target Unit.',
)
target_unit = fields.Selection(
FP_UOM_SELECTION,
string='Target Unit',
help='Unit Target Min / Target Max are measured in.',
)
compliance_tag = fields.Selection(
[
('none', 'None'),
('as9100', 'AS9100'),
('nadcap', 'Nadcap'),
('cgp', 'Controlled Goods'),
('nuclear', 'Nuclear'),
],
string='Compliance Tag', default='none',
)

View File

@@ -115,3 +115,67 @@ class FpRack(models.Model):
"""Add `delta` to the rack's MTO count. Called by the WO finish hook."""
for rec in self:
rec.mto_count = (rec.mto_count or 0.0) + delta
# ===== Sub 12b — racking lifecycle (orthogonal to wear-tracking state) =
racking_state = fields.Selection(
[
('empty', 'Empty'),
('loading', 'Loading'),
('loaded', 'Loaded'),
('in_use', 'In Use'),
('awaiting_unrack', 'Awaiting Unrack'),
('out_of_service', 'Out of Service'),
],
string='Racking State', default='empty', tracking=True,
help='Operational state in the rack→step→tank flow. Distinct '
'from the wear-tracking `state` (active/needs_strip/...).',
)
tag_ids = fields.Many2many(
'fp.rack.tag',
'fp_rack_tag_rel', 'rack_id', 'tag_id',
string='Tags',
)
capacity_count = fields.Integer(
string='Capacity (parts) — soft warn',
help='Soft warning threshold — runtime informs operator when '
'rack is loaded beyond this. Not enforced. Distinct from '
'`capacity` field (planning capacity).',
)
current_job_step_id = fields.Many2one(
'fp.job.step', string='Current Step',
compute='_compute_current_use', store=True,
)
current_tank_id = fields.Many2one(
'fusion.plating.tank', string='Current Tank',
compute='_compute_current_use', store=True,
)
current_part_count = fields.Integer(
string='Parts on Rack',
compute='_compute_current_use', store=True,
)
@api.depends('racking_state')
def _compute_current_use(self):
# Walks the most recent fp.job.step.move row per rack to derive
# current step + tank + part count. For racks not currently in
# use, all values are blank.
Move = self.env['fp.job.step.move']
for rack in self:
if rack.racking_state in ('empty', 'out_of_service'):
rack.current_job_step_id = False
rack.current_tank_id = False
rack.current_part_count = 0
continue
recent = Move.search(
[('rack_id', '=', rack.id)],
order='move_datetime desc',
limit=1,
)
rack.current_job_step_id = recent.to_step_id if recent else False
# current_tank_id pulls from the destination step's tank if set
rack.current_tank_id = (
recent.to_tank_id or
(recent.to_step_id.tank_id if recent and recent.to_step_id else False)
) if recent else False
rack.current_part_count = recent.qty_moved if recent else 0

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpRackTag(models.Model):
"""Operator-visible labels applied to physical racks.
"Rush" / "Hold for QC" / "Customer-Amphenol" / "Damaged" — the
coloured tag chips that appear in the Move Rack dialog and on the
plant-overview rack rows. M2M; one rack can carry many tags.
"""
_name = 'fp.rack.tag'
_description = 'Fusion Plating — Rack Tag'
_order = 'sequence, name'
name = fields.Char(string='Tag', required=True, translate=True)
color = fields.Integer(string='Color')
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
_sql_constraints = [
('fp_rack_tag_name_company_uniq',
'unique(name, company_id)',
'Rack tag name must be unique within a company.'),
]

View File

@@ -0,0 +1,233 @@
# -*- 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 FpStepTemplate(models.Model):
"""Reusable step template for the Simple Recipe Editor.
A library entry the recipe author can drag into a recipe. Snapshot-
copied at drag time — editing the template later does NOT change
recipes already built. Carries the same shape fields as the runtime
`fusion.plating.process.node` so a snapshot copy is a 1:1 field
transfer.
"""
_name = 'fp.step.template'
_description = 'Fusion Plating — Step Library Template'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'sequence, name'
name = fields.Char(string='Title', required=True, translate=True, tracking=True)
code = fields.Char(string='Code', tracking=True,
help='Optional short identifier. Auto-uppercased.')
description = fields.Html(string='Instructions',
help='Rich-text instructions / Work-Instruction reference.')
icon = fields.Selection(
selection='_get_icon_selection',
string='Icon',
default='fa-cog',
)
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
company_id = fields.Many2one(
'res.company', string='Company',
default=lambda self: self.env.company,
)
tank_ids = fields.Many2many(
'fusion.plating.tank', string='Allowed Stations',
help='Stations (tanks) this step can be performed at. The '
'operator picks one of these at runtime.',
)
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type',
ondelete='set null',
)
material_callout = fields.Char(string='Material Callout',
help='Short string printed in the traveller "Material" column. '
'e.g. "MID PHOS". Defaults to process type name if blank.')
time_min_target = fields.Float(string='Time Min')
time_max_target = fields.Float(string='Time Max')
time_unit = fields.Selection(
[('sec', 'Seconds'), ('min', 'Minutes'), ('hr', 'Hours')],
string='Time Unit', default='min',
)
temp_min_target = fields.Float(string='Temp Min')
temp_max_target = fields.Float(string='Temp Max')
temp_unit = fields.Selection(
[('F', '°F'), ('C', '°C')],
string='Temp Unit', default='F',
)
voltage_target = fields.Float(string='Voltage Target')
viscosity_target = fields.Float(string='Viscosity Target')
requires_signoff = fields.Boolean(string='Require QA Sign-off')
requires_predecessor_done = fields.Boolean(string='Require Predecessor Done',
help='S14 lock — operator cannot start this step until earlier '
'sequenced steps are done.')
requires_rack_assignment = fields.Boolean(string='Requires Rack Assignment',
help='Triggers Rack Parts sub-dialog at runtime (Sub 12b).')
requires_transition_form = fields.Boolean(string='Requires Transition Form',
help='Opens the transition form before Mark Done (Sub 12b).')
default_kind = fields.Selection([
('cleaning', 'Cleaning'),
('etch', 'Etch'),
('rinse', 'Rinse'),
('plate', 'Plating'),
('bake', 'Bake'),
('inspect', 'Inspection'),
('racking', 'Racking'),
('derack', 'De-Racking'),
('mask', 'Masking'),
('demask', 'De-Masking'),
('dry', 'Drying'),
('wbf_test', 'Water Break Free Test'),
('final_inspect', 'Final Inspection'),
('ship', 'Shipping'),
('gating', 'Gating'),
('contract_review', 'Contract Review (QA-005)'),
], string='Step Kind', help='Drives sane-default input seeding.')
input_template_ids = fields.One2many(
'fp.step.template.input', 'template_id',
string='Operation Measurements',
copy=True,
)
transition_input_ids = fields.One2many(
'fp.step.template.transition.input', 'template_id',
string='Transition Form Fields',
copy=True,
)
@api.model
def _get_icon_selection(self):
# Reuse the 24-icon list from fusion.plating.process.node so the
# library matches whatever the tree editor offers.
node = self.env['fusion.plating.process.node']
return node._fields['icon'].selection
_sql_constraints = [
('fp_step_template_code_company_uniq',
'unique(code, company_id)',
'Step template code must be unique within a company.'),
]
@api.model_create_multi
def create(self, vals_list):
for v in vals_list:
if v.get('code'):
v['code'] = v['code'].upper().strip()
return super().create(vals_list)
def write(self, vals):
if vals.get('code'):
vals['code'] = vals['code'].upper().strip()
return super().write(vals)
# ----- Sane defaults seeding ---------------------------------------------
# NB target_unit must be a valid FP_UOM_SELECTION key — it became a
# Selection in 19.0.12.1.0 (uom cleanup). Free-text values like
# 'HH:MM', '°F', 'sec', 'in', 'each' raise ValueError on create.
# Mapping cheatsheet: sec → 's', °F → 'f', °C → 'c', in → 'in',
# each → 'each', min → 'min'. Format-only strings ('HH:MM') get
# left blank since they're not units.
DEFAULT_INPUTS_BY_KIND = {
'cleaning': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 20},
],
'etch': [
{'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 20},
],
'rinse': [],
'plate': [
{'name': 'Actual Time', 'input_type': 'time_hms',
'target_unit': 'min', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 20},
{'name': 'Plating Thickness', 'input_type': 'thickness',
'target_unit': 'in', 'sequence': 30},
],
'bake': [
{'name': 'Time In', 'input_type': 'text', 'sequence': 10},
{'name': 'Time Out', 'input_type': 'text', 'sequence': 20},
{'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': 'f', 'sequence': 30},
],
'racking': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'derack': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'inspect': [
{'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10},
],
'final_inspect': [
{'name': 'Outgoing Part Count Verified',
'input_type': 'boolean', 'sequence': 10},
{'name': 'Qty Accepted', 'input_type': 'number',
'target_unit': 'each', 'sequence': 20},
{'name': 'Qty Rejected', 'input_type': 'number',
'target_unit': 'each', 'sequence': 30},
{'name': 'Actual Coating Thickness',
'input_type': 'thickness', 'target_unit': 'in', 'sequence': 40},
{'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50},
],
'wbf_test': [
{'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10},
],
'mask': [
{'name': 'Actual Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'demask': [],
'dry': [],
'ship': [
{'name': 'Outgoing Qty', 'input_type': 'number',
'target_unit': 'each', 'sequence': 10},
],
'gating': [],
# Sub 4 + 12c follow-up — Contract Review step (Policy B).
# The shop-floor step itself is a tickbox; the heavy QA-005 form
# is opened via fp.contract.review (separate model). These
# inputs capture summary fields for the chronological CoC.
'contract_review': [
{'name': 'Reviewer Initials', 'input_type': 'text', 'sequence': 10},
{'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20},
{'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30},
],
}
def action_seed_default_inputs(self):
"""Seed input_template_ids based on default_kind. Idempotent —
only adds inputs whose names don't already exist on this template.
Public method (Odoo 19 requires non-underscore-prefixed names
for methods called from a view button).
"""
Input = self.env['fp.step.template.input']
for tpl in self:
if not tpl.default_kind:
continue
existing_names = set(tpl.input_template_ids.mapped('name'))
for spec in self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []):
if spec['name'] in existing_names:
continue
Input.create({
'template_id': tpl.id,
**spec,
})

View File

@@ -0,0 +1,52 @@
# -*- 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 fields, models
from ._fp_uom_selection import FP_UOM_SELECTION
class FpStepTemplateInput(models.Model):
"""Operation measurement definition on a step library template.
Recorded *during* a step (e.g. "Actual Time", "Plating Thickness").
Distinct from transition_input_ids which fire when leaving the
step.
"""
_name = 'fp.step.template.input'
_description = 'Fusion Plating — Step Template Input'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
template_id = fields.Many2one(
'fp.step.template', string='Template',
required=True, ondelete='cascade', index=True,
)
input_type = fields.Selection([
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
], string='Input Type', required=True, default='text')
target_min = fields.Float(string='Target Min',
help='Lower bound of the acceptable range, expressed in Target Unit.')
target_max = fields.Float(string='Target Max',
help='Upper bound of the acceptable range, expressed in Target Unit.')
target_unit = fields.Selection(FP_UOM_SELECTION, string='Target Unit',
help='Unit Target Min / Target Max are measured in. Pick from the '
'curated list to keep readings consistent across templates.')
required = fields.Boolean(string='Required', default=False,
help='If True, sign-off is hard-blocked while this input is blank.')
hint = fields.Char(string='Hint')
selection_options = fields.Text(string='Selection Options',
help='Comma-separated when input_type is "selection".')
sequence = fields.Integer(string='Sequence', default=10)

View File

@@ -0,0 +1,50 @@
# -*- 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 fields, models
class FpStepTemplateTransitionInput(models.Model):
"""Transition-time compliance field definition.
Fires when leaving a step (e.g. "Customer WO #", "Photo Evidence",
"Scrap Reason"). Authored on `fp.step.template`, snapshot-copied
onto `fusion.plating.process.node` when the library step is dragged
into a recipe. Sub 12b uses these to render the Move Parts dialog.
"""
_name = 'fp.step.template.transition.input'
_description = 'Fusion Plating — Step Template Transition Input'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True)
template_id = fields.Many2one(
'fp.step.template', string='Template',
required=True, ondelete='cascade', index=True,
)
input_type = fields.Selection([
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('photo', 'Photo'),
('location_picker', 'Location Picker'),
('customer_wo', 'Customer WO #'),
], string='Input Type', required=True, default='text')
required = fields.Boolean(string='Required', default=False,
help='If True, the move is hard-blocked while this input is blank.')
hint = fields.Char(string='Hint')
selection_options = fields.Text(string='Selection Options',
help='Comma-separated when input_type is "selection".')
sequence = fields.Integer(string='Sequence', default=10)
compliance_tag = fields.Selection([
('none', 'None'),
('as9100', 'AS9100'),
('nadcap', 'Nadcap'),
('cgp', 'Controlled Goods'),
('nuclear', 'Nuclear'),
], string='Compliance Tag', default='none',
help='Drives audit-report inclusion / filtering.')

View File

@@ -19,15 +19,15 @@ class FpTank(models.Model):
_name = 'fusion.plating.tank'
_description = 'Fusion Plating — Tank'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, work_center_id, sequence, code'
_order = 'facility_id, section_id, sequence, code'
name = fields.Char(
string='Tank',
string='Tank Name',
required=True,
tracking=True,
)
code = fields.Char(
string='Code',
string='Tank Number',
required=True,
tracking=True,
help='Short unique tank identifier (e.g. "T-01", "EN-A1").',
@@ -51,9 +51,16 @@ class FpTank(models.Model):
ondelete='restrict',
tracking=True,
)
section_id = fields.Many2one(
'fusion.plating.tank.section',
string='Section',
ondelete='set null',
tracking=True,
help='Free-form grouping (e.g. Steel Line, Aluminum Line, Specialty Line).',
)
work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='Work Center',
string='Production Line',
domain="[('facility_id','=',facility_id)]",
ondelete='restrict',
tracking=True,
@@ -126,6 +133,22 @@ class FpTank(models.Model):
tracking=True,
)
# ----- Default temperature (used as pre-fill on bath log lines) -------
default_temperature = fields.Float(
string='Default Temperature',
digits=(6, 2),
tracking=True,
help='Operating temperature setpoint. Pre-fills the temperature '
'reading on new chemistry logs so the operator can confirm with '
'one tap. Use the up/down arrows on the input to nudge by 1 unit.',
)
default_temperature_uom = fields.Selection(
[('c', '°C'), ('f', '°F')],
string='Temperature Unit',
default='c',
tracking=True,
)
# ----- Relations ------------------------------------------------------
bath_ids = fields.One2many(
'fusion.plating.bath',
@@ -138,16 +161,45 @@ class FpTank(models.Model):
compute='_compute_current_bath',
store=True,
)
current_bath_process_id = fields.Many2one(
'fusion.plating.process.type',
string='Current Bath Process',
related='current_bath_id.process_type_id',
store=True,
help='Process derived from the active bath. The editable "Current '
'Process" overrides this when the operator needs to flag a '
'different process (e.g. between bath swaps).',
)
current_process_id = fields.Many2one(
'fusion.plating.process.type',
string='Current Process',
related='current_bath_id.process_type_id',
store=True,
ondelete='restrict',
tracking=True,
help='User-settable process flag. Defaults to the active bath\'s '
'process; can be overridden when operating off-recipe.',
)
bath_count = fields.Integer(
compute='_compute_bath_count',
)
# ----- Compositions ---------------------------------------------------
composition_ids = fields.One2many(
'fusion.plating.tank.composition',
'tank_id',
string='Compositions',
)
active_composition_id = fields.Many2one(
'fusion.plating.tank.composition',
string='Active Composition',
domain="[('tank_id', '=', id)]",
tracking=True,
help='The composition currently in service. Switching is logged in '
'the chatter for full audit history.',
)
composition_count = fields.Integer(
compute='_compute_composition_count',
)
_sql_constraints = [
(
'fp_tank_code_facility_uniq',
@@ -168,9 +220,50 @@ class FpTank(models.Model):
for rec in self:
rec.bath_count = len(rec.bath_ids)
@api.depends('composition_ids')
def _compute_composition_count(self):
for rec in self:
rec.composition_count = len(rec.composition_ids)
@api.onchange('current_bath_process_id')
def _onchange_seed_current_process(self):
"""Pre-fill the editable Current Process from the active bath when
the operator hasn't already set one — keeps the field useful out of
the box while still allowing manual override."""
for rec in self:
if not rec.current_process_id and rec.current_bath_process_id:
rec.current_process_id = rec.current_bath_process_id
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('qr_code') and vals.get('code'):
vals['qr_code'] = f"FP-TANK:{vals['code']}"
return super().create(vals_list)
# ----- State transition actions ---------------------------------------
def _set_state(self, new_state, message):
for rec in self:
old = dict(rec._fields['state'].selection).get(rec.state, rec.state)
new = dict(rec._fields['state'].selection).get(new_state, new_state)
rec.state = new_state
rec.message_post(body=f"{message} ({old}{new}) by {self.env.user.name}")
return True
def action_set_empty(self):
return self._set_state('empty', 'Tank marked Empty')
def action_set_filled(self):
return self._set_state('filled', 'Tank marked Filled')
def action_set_in_use(self):
return self._set_state('in_use', 'Tank marked In Use')
def action_set_draining(self):
return self._set_state('draining', 'Tank marked Draining')
def action_set_maintenance(self):
return self._set_state('maintenance', 'Tank marked for Maintenance')
def action_set_out_of_service(self):
return self._set_state('out_of_service', 'Tank marked Out of Service')

View File

@@ -0,0 +1,216 @@
# -*- 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 FpTankComposition(models.Model):
"""A defined chemistry composition for a tank (e.g. "Composition A",
"High-P Mix", "Strike Solution").
A tank can carry multiple authored compositions; one is "active" at any
given time. Switching compositions is a tracked, audit-logged event so
the shop has a chronological record of "what did this tank actually
contain on Tuesday afternoon?"
Each composition has its own ingredient list with per-chemical
percentages. Changes to ingredients are also chatter-tracked.
"""
_name = 'fusion.plating.tank.composition'
_description = 'Fusion Plating — Tank Composition'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'tank_id, sequence, name'
name = fields.Char(
string='Composition',
required=True,
tracking=True,
)
code = fields.Char(
string='Code',
tracking=True,
help='Short identifier — "A", "B", "C".',
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
tank_id = fields.Many2one(
'fusion.plating.tank',
string='Tank',
required=True,
ondelete='cascade',
tracking=True,
)
facility_id = fields.Many2one(
related='tank_id.facility_id',
store=True,
readonly=True,
)
is_active = fields.Boolean(
string='Currently Active',
compute='_compute_is_active',
help='True when this composition is the tank\'s active composition.',
)
description = fields.Text(
string='Description',
)
notes = fields.Html(
string='Notes',
)
ingredient_ids = fields.One2many(
'fusion.plating.tank.composition.ingredient',
'composition_id',
string='Ingredients',
copy=True,
tracking=True,
)
total_percentage = fields.Float(
string='Total %',
compute='_compute_total_percentage',
store=True,
)
active = fields.Boolean(default=True)
@api.depends('ingredient_ids', 'ingredient_ids.percentage')
def _compute_total_percentage(self):
for rec in self:
rec.total_percentage = sum(rec.ingredient_ids.mapped('percentage'))
@api.depends('tank_id', 'tank_id.active_composition_id')
def _compute_is_active(self):
for rec in self:
rec.is_active = rec.tank_id.active_composition_id.id == rec.id
def action_set_active(self):
"""Mark this composition as the tank's active composition. Logs to
both this composition's chatter and the tank's chatter so the audit
trail captures who flipped the switch and when.
"""
self.ensure_one()
old = self.tank_id.active_composition_id
if old.id == self.id:
return True
self.tank_id.active_composition_id = self.id
msg_tank = _(
'Active composition changed: %(old)s%(new)s by %(user)s'
) % {
'old': old.display_name or _('(none)'),
'new': self.display_name,
'user': self.env.user.name,
}
self.tank_id.message_post(body=msg_tank)
self.message_post(body=_('Activated by %s') % self.env.user.name)
return True
class FpTankCompositionIngredient(models.Model):
"""A single chemical entry in a tank composition.
Free-form chemical name (no FK to fusion.plating.chemical because core
must not depend on the safety module). Percentage is the share of the
composition; total % roll-up lives on the parent composition.
"""
_name = 'fusion.plating.tank.composition.ingredient'
_description = 'Fusion Plating — Tank Composition Ingredient'
_order = 'composition_id, sequence, id'
composition_id = fields.Many2one(
'fusion.plating.tank.composition',
string='Composition',
required=True,
ondelete='cascade',
index=True,
)
tank_id = fields.Many2one(
related='composition_id.tank_id',
store=True,
readonly=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
name = fields.Char(
string='Chemical',
required=True,
)
percentage = fields.Float(
string='Percentage',
digits=(6, 3),
required=True,
help='Share of this composition, in percent.',
)
uom = fields.Selection(
[
('pct', '% by Volume'),
('pct_w', '% by Weight'),
('g_l', 'g/L'),
('ml_l', 'mL/L'),
('oz_gal', 'oz/gal'),
],
string='Unit',
default='pct',
)
notes = fields.Char(
string='Notes',
)
# Mirror create/write/unlink to the parent composition's chatter so
# ingredient changes show up in the audit log even though this row
# doesn't carry mail.thread itself (kept lean for repeater UX).
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
rec.composition_id.message_post(body=_(
'Ingredient added: %(name)s%(pct)s %(uom)s'
) % {
'name': rec.name,
'pct': rec.percentage,
'uom': dict(rec._fields['uom'].selection).get(rec.uom, rec.uom),
})
return records
def write(self, vals):
# Capture before-state per-record so we can describe the diff.
snapshots = {
rec.id: {
'name': rec.name,
'percentage': rec.percentage,
'uom': rec.uom,
}
for rec in self
}
result = super().write(vals)
for rec in self:
before = snapshots.get(rec.id) or {}
changed = []
if 'name' in vals and before.get('name') != rec.name:
changed.append(_('name: %s%s') % (before.get('name'), rec.name))
if 'percentage' in vals and before.get('percentage') != rec.percentage:
changed.append(_('percentage: %s%s') % (
before.get('percentage'), rec.percentage,
))
if 'uom' in vals and before.get('uom') != rec.uom:
changed.append(_('unit: %s%s') % (before.get('uom'), rec.uom))
if changed:
rec.composition_id.message_post(body=_(
'Ingredient %(name)s updated — %(changes)s'
) % {
'name': rec.name,
'changes': '; '.join(changed),
})
return result
def unlink(self):
for rec in self:
rec.composition_id.message_post(body=_(
'Ingredient removed: %(name)s%(pct)s'
) % {
'name': rec.name,
'pct': rec.percentage,
})
return super().unlink()

View File

@@ -0,0 +1,69 @@
# -*- 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 FpTankSection(models.Model):
"""A user-defined grouping of tanks (e.g. "Steel Line", "Aluminum Line",
"Specialty Line").
Sections give the shop a familiar way to slice the tank list: every shop
organises its tanks differently — by metal, by chemistry family, by
physical aisle, or by customer programme — and a fixed taxonomy never
fits. Sections are free-form, renameable, and per-facility.
"""
_name = 'fusion.plating.tank.section'
_description = 'Fusion Plating — Tank Section'
_order = 'sequence, name'
name = fields.Char(
string='Section',
required=True,
translate=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
ondelete='restrict',
)
color = fields.Integer(
string='Color',
default=0,
)
description = fields.Text(
string='Description',
translate=True,
)
active = fields.Boolean(default=True)
tank_ids = fields.One2many(
'fusion.plating.tank',
'section_id',
string='Tanks',
)
tank_count = fields.Integer(
compute='_compute_tank_count',
)
@api.depends('tank_ids')
def _compute_tank_count(self):
for rec in self:
rec.tank_count = len(rec.tank_ids)
def action_view_tanks(self):
self.ensure_one()
return {
'name': self.name,
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.tank',
'view_mode': 'list,kanban,form',
'domain': [('section_id', '=', self.id)],
'context': {'default_section_id': self.id},
}

View File

@@ -7,18 +7,20 @@ from odoo import fields, models
class FpWorkCenter(models.Model):
"""A production line or station inside a facility.
"""A physical production line inside a facility.
Examples: "Line 1 - EN", "Anodize Line", "Prep Bay", "Bake Station",
"Inspection Booth", "Shipping Dock". Work centers group tanks and
provide scheduling capacity.
Examples: "Line 1 EN", "Anodize Line", "Prep Bay", "Bake Station",
"Inspection Booth", "Shipping Dock". Production lines group tanks
and provide daily-capacity scheduling. This is the SHOP-LAYOUT
entity — distinct from `fp.work.centre` which is the per-job-step
routing station with cost-per-hour rollup.
"""
_name = 'fusion.plating.work.center'
_description = 'Fusion Plating — Work Center'
_description = 'Fusion Plating — Production Line'
_order = 'facility_id, sequence, name'
name = fields.Char(
string='Work Center',
string='Production Line',
required=True,
)
code = fields.Char(

View File

@@ -13,11 +13,23 @@ from odoo import fields, models
class FpWorkCentre(models.Model):
"""Routing station for a job step — replaces mrp.workcenter for
plating after the Sub 11 MRP cutout.
Each routing station has a `kind` (wet_line / bake / mask / rack /
inspect / other) that drives release-ready validation on
`fp.job.step` (e.g. wet_line requires bath+tank to be set before
the step can start). Costable via `cost_per_hour`.
Distinct from `fusion.plating.work.center` (Production Line),
which is the physical shop-layout grouping that owns tanks.
A Production Line typically contains many Routing Stations.
"""
_name = 'fp.work.centre'
_description = 'Plating Work Centre'
_description = 'Plating Routing Station'
_order = 'sequence, code, name'
name = fields.Char(required=True)
name = fields.Char(string='Routing Station', required=True)
code = fields.Char(required=True, help='Short code used on stickers and reports.')
sequence = fields.Integer(default=10)
facility_id = fields.Many2one(

View File

@@ -161,3 +161,27 @@ class ResCompany(models.Model):
string='CGP Registered',
help='Show the Controlled Goods Program logo on certificates.',
)
# =====================================================================
# Sub 12a — Default recipe editor
# =====================================================================
x_fc_default_recipe_editor = fields.Selection(
[('tree', 'Tree Editor'), ('simple', 'Simple Editor')],
string='Default Recipe Editor',
default='tree',
help='Which editor opens when a new recipe is created OR when a '
'recipe with preferred_editor=auto is selected. Per-recipe '
'preferred_editor (tree/simple) overrides this.',
)
# =====================================================================
# Sub 12c+ — Default Certification Statement
# =====================================================================
x_fc_default_cert_statement = fields.Text(
string='Default Cert Statement',
help='Boilerplate text printed in the Certificate of Conformance '
'"Certification Statement" block. Per-customer override on '
'res.partner.x_fc_cert_statement takes precedence when set. '
'When BOTH are blank the report falls back to a hardcoded '
'AS9100/ISO 9001 statement.',
)

View File

@@ -55,3 +55,18 @@ class ResConfigSettings(models.TransientModel):
related='company_id.x_fc_default_area_uom',
readonly=False, string='Area Unit',
)
# ----- Sub 12a — recipe editor default ------------------------------
x_fc_default_recipe_editor = fields.Selection(
related='company_id.x_fc_default_recipe_editor',
readonly=False,
string='Default Recipe Editor',
)
# ----- Phase 1 — Plating landing page default -----------------------
x_fc_default_landing_action_id = fields.Many2one(
'ir.actions.act_window',
related='company_id.x_fc_default_landing_action_id',
readonly=False,
string='Default Plating Landing Page',
)

View File

@@ -14,6 +14,15 @@ access_fp_work_center_manager,fp.work.center.manager,model_fusion_plating_work_c
access_fp_tank_operator,fp.tank.operator,model_fusion_plating_tank,group_fusion_plating_operator,1,0,0,0
access_fp_tank_supervisor,fp.tank.supervisor,model_fusion_plating_tank,group_fusion_plating_supervisor,1,1,0,0
access_fp_tank_manager,fp.tank.manager,model_fusion_plating_tank,group_fusion_plating_manager,1,1,1,1
access_fp_tank_section_operator,fp.tank.section.operator,model_fusion_plating_tank_section,group_fusion_plating_operator,1,0,0,0
access_fp_tank_section_supervisor,fp.tank.section.supervisor,model_fusion_plating_tank_section,group_fusion_plating_supervisor,1,1,0,0
access_fp_tank_section_manager,fp.tank.section.manager,model_fusion_plating_tank_section,group_fusion_plating_manager,1,1,1,1
access_fp_tank_composition_operator,fp.tank.composition.operator,model_fusion_plating_tank_composition,group_fusion_plating_operator,1,0,0,0
access_fp_tank_composition_supervisor,fp.tank.composition.supervisor,model_fusion_plating_tank_composition,group_fusion_plating_supervisor,1,1,1,0
access_fp_tank_composition_manager,fp.tank.composition.manager,model_fusion_plating_tank_composition,group_fusion_plating_manager,1,1,1,1
access_fp_tank_comp_ing_operator,fp.tank.composition.ingredient.operator,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_operator,1,0,0,0
access_fp_tank_comp_ing_supervisor,fp.tank.composition.ingredient.supervisor,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_supervisor,1,1,1,1
access_fp_tank_comp_ing_manager,fp.tank.composition.ingredient.manager,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_manager,1,1,1,1
access_fp_bath_operator,fp.bath.operator,model_fusion_plating_bath,group_fusion_plating_operator,1,0,0,0
access_fp_bath_supervisor,fp.bath.supervisor,model_fusion_plating_bath,group_fusion_plating_supervisor,1,1,1,0
access_fp_bath_manager,fp.bath.manager,model_fusion_plating_bath,group_fusion_plating_manager,1,1,1,1
@@ -61,3 +70,21 @@ access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
access_fp_step_template_operator,fp.step.template.operator,model_fp_step_template,group_fusion_plating_operator,1,0,0,0
access_fp_step_template_supervisor,fp.step.template.supervisor,model_fp_step_template,group_fusion_plating_supervisor,1,1,1,0
access_fp_step_template_manager,fp.step.template.manager,model_fp_step_template,group_fusion_plating_manager,1,1,1,1
access_fp_step_template_input_operator,fp.step.template.input.operator,model_fp_step_template_input,group_fusion_plating_operator,1,0,0,0
access_fp_step_template_input_supervisor,fp.step.template.input.supervisor,model_fp_step_template_input,group_fusion_plating_supervisor,1,1,1,1
access_fp_step_template_input_manager,fp.step.template.input.manager,model_fp_step_template_input,group_fusion_plating_manager,1,1,1,1
access_fp_step_template_transition_input_operator,fp.step.template.transition.input.operator,model_fp_step_template_transition_input,group_fusion_plating_operator,1,0,0,0
access_fp_step_template_transition_input_supervisor,fp.step.template.transition.input.supervisor,model_fp_step_template_transition_input,group_fusion_plating_supervisor,1,1,1,1
access_fp_step_template_transition_input_manager,fp.step.template.transition.input.manager,model_fp_step_template_transition_input,group_fusion_plating_manager,1,1,1,1
access_fp_rack_tag_operator,fp.rack.tag.operator,model_fp_rack_tag,group_fusion_plating_operator,1,0,0,0
access_fp_rack_tag_supervisor,fp.rack.tag.supervisor,model_fp_rack_tag,group_fusion_plating_supervisor,1,1,1,1
access_fp_rack_tag_manager,fp.rack.tag.manager,model_fp_rack_tag,group_fusion_plating_manager,1,1,1,1
access_fp_job_step_move_operator,fp.job.step.move.operator,model_fp_job_step_move,group_fusion_plating_operator,1,1,1,0
access_fp_job_step_move_supervisor,fp.job.step.move.supervisor,model_fp_job_step_move,group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_move_manager,fp.job.step.move.manager,model_fp_job_step_move,group_fusion_plating_manager,1,1,1,1
access_fp_job_step_move_input_value_operator,fp.job.step.move.input.value.operator,model_fp_job_step_move_input_value,group_fusion_plating_operator,1,1,1,0
access_fp_job_step_move_input_value_supervisor,fp.job.step.move.input.value.supervisor,model_fp_job_step_move_input_value,group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_move_input_value_manager,fp.job.step.move.input.value.manager,model_fp_job_step_move_input_value,group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_tank_operator fp.tank.operator model_fusion_plating_tank group_fusion_plating_operator 1 0 0 0
15 access_fp_tank_supervisor fp.tank.supervisor model_fusion_plating_tank group_fusion_plating_supervisor 1 1 0 0
16 access_fp_tank_manager fp.tank.manager model_fusion_plating_tank group_fusion_plating_manager 1 1 1 1
17 access_fp_tank_section_operator fp.tank.section.operator model_fusion_plating_tank_section group_fusion_plating_operator 1 0 0 0
18 access_fp_tank_section_supervisor fp.tank.section.supervisor model_fusion_plating_tank_section group_fusion_plating_supervisor 1 1 0 0
19 access_fp_tank_section_manager fp.tank.section.manager model_fusion_plating_tank_section group_fusion_plating_manager 1 1 1 1
20 access_fp_tank_composition_operator fp.tank.composition.operator model_fusion_plating_tank_composition group_fusion_plating_operator 1 0 0 0
21 access_fp_tank_composition_supervisor fp.tank.composition.supervisor model_fusion_plating_tank_composition group_fusion_plating_supervisor 1 1 1 0
22 access_fp_tank_composition_manager fp.tank.composition.manager model_fusion_plating_tank_composition group_fusion_plating_manager 1 1 1 1
23 access_fp_tank_comp_ing_operator fp.tank.composition.ingredient.operator model_fusion_plating_tank_composition_ingredient group_fusion_plating_operator 1 0 0 0
24 access_fp_tank_comp_ing_supervisor fp.tank.composition.ingredient.supervisor model_fusion_plating_tank_composition_ingredient group_fusion_plating_supervisor 1 1 1 1
25 access_fp_tank_comp_ing_manager fp.tank.composition.ingredient.manager model_fusion_plating_tank_composition_ingredient group_fusion_plating_manager 1 1 1 1
26 access_fp_bath_operator fp.bath.operator model_fusion_plating_bath group_fusion_plating_operator 1 0 0 0
27 access_fp_bath_supervisor fp.bath.supervisor model_fusion_plating_bath group_fusion_plating_supervisor 1 1 1 0
28 access_fp_bath_manager fp.bath.manager model_fusion_plating_bath group_fusion_plating_manager 1 1 1 1
70 access_fp_proficiency_operator fp.operator.proficiency.operator model_fp_operator_proficiency group_fusion_plating_operator 1 0 0 0
71 access_fp_proficiency_supervisor fp.operator.proficiency.supervisor model_fp_operator_proficiency group_fusion_plating_supervisor 1 1 1 0
72 access_fp_proficiency_manager fp.operator.proficiency.manager model_fp_operator_proficiency group_fusion_plating_manager 1 1 1 1
73 access_fp_step_template_operator fp.step.template.operator model_fp_step_template group_fusion_plating_operator 1 0 0 0
74 access_fp_step_template_supervisor fp.step.template.supervisor model_fp_step_template group_fusion_plating_supervisor 1 1 1 0
75 access_fp_step_template_manager fp.step.template.manager model_fp_step_template group_fusion_plating_manager 1 1 1 1
76 access_fp_step_template_input_operator fp.step.template.input.operator model_fp_step_template_input group_fusion_plating_operator 1 0 0 0
77 access_fp_step_template_input_supervisor fp.step.template.input.supervisor model_fp_step_template_input group_fusion_plating_supervisor 1 1 1 1
78 access_fp_step_template_input_manager fp.step.template.input.manager model_fp_step_template_input group_fusion_plating_manager 1 1 1 1
79 access_fp_step_template_transition_input_operator fp.step.template.transition.input.operator model_fp_step_template_transition_input group_fusion_plating_operator 1 0 0 0
80 access_fp_step_template_transition_input_supervisor fp.step.template.transition.input.supervisor model_fp_step_template_transition_input group_fusion_plating_supervisor 1 1 1 1
81 access_fp_step_template_transition_input_manager fp.step.template.transition.input.manager model_fp_step_template_transition_input group_fusion_plating_manager 1 1 1 1
82 access_fp_rack_tag_operator fp.rack.tag.operator model_fp_rack_tag group_fusion_plating_operator 1 0 0 0
83 access_fp_rack_tag_supervisor fp.rack.tag.supervisor model_fp_rack_tag group_fusion_plating_supervisor 1 1 1 1
84 access_fp_rack_tag_manager fp.rack.tag.manager model_fp_rack_tag group_fusion_plating_manager 1 1 1 1
85 access_fp_job_step_move_operator fp.job.step.move.operator model_fp_job_step_move group_fusion_plating_operator 1 1 1 0
86 access_fp_job_step_move_supervisor fp.job.step.move.supervisor model_fp_job_step_move group_fusion_plating_supervisor 1 1 1 0
87 access_fp_job_step_move_manager fp.job.step.move.manager model_fp_job_step_move group_fusion_plating_manager 1 1 1 1
88 access_fp_job_step_move_input_value_operator fp.job.step.move.input.value.operator model_fp_job_step_move_input_value group_fusion_plating_operator 1 1 1 0
89 access_fp_job_step_move_input_value_supervisor fp.job.step.move.input.value.supervisor model_fp_job_step_move_input_value group_fusion_plating_supervisor 1 1 1 0
90 access_fp_job_step_move_input_value_manager fp.job.step.move.input.value.manager model_fp_job_step_move_input_value group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,256 @@
/** @odoo-module */
/*
* Sub 12a — Simple Recipe Editor (OWL client action).
*
* Flat drag-drop alternative to the tree editor. Library on the right,
* Selected (ordered steps) on the left. Drag from library → snapshot-
* copy via /fp/simple_recipe/step/insert. Drag-reorder within Selected
* → /fp/simple_recipe/step/reorder. Same recipe data either editor.
*/
import { Component, onMounted, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
export class FpSimpleRecipeEditor extends Component {
static template = "fusion_plating.FpSimpleRecipeEditor";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.dialog = useService("dialog");
this.state = useState({
loading: true,
recipe: null,
steps: [],
library: [],
librarySearch: "",
templateOptions: [],
selectedTemplate: "",
// Drop-position simulator (snaps to line above/below the
// hovered row based on cursor Y vs row midpoint).
dragOverIndex: null, // 0..N (insertion index)
dragPreviewLabel: "", // shown next to the indicator line
dragPreviewIcon: "fa-cog",
});
this._recipeId = null;
onMounted(async () => {
const ctx = this.props.action?.context || {};
this._recipeId = ctx.recipe_id || null;
if (this._recipeId) {
await this.loadAll();
} else {
this.state.loading = false;
this.notification.add(
_t("No recipe context provided. Open this editor from a recipe form."),
{ type: "warning" }
);
}
});
}
async loadAll() {
this.state.loading = true;
const [recipeData, libraryData, templateData] = await Promise.all([
rpc("/fp/simple_recipe/load", { recipe_id: this._recipeId }),
rpc("/fp/simple_recipe/library/list", { query: "" }),
rpc("/fp/simple_recipe/template/list", {}),
]);
this.state.recipe = recipeData.recipe;
this.state.steps = recipeData.steps;
this.state.library = libraryData.templates;
this.state.templateOptions = templateData.templates;
this.state.loading = false;
}
async onSearchLibrary(ev) {
const q = ev.target.value;
this.state.librarySearch = q;
const data = await rpc("/fp/simple_recipe/library/list", { query: q });
this.state.library = data.templates;
}
async insertFromLibrary(templateId, position) {
await rpc("/fp/simple_recipe/step/insert", {
recipe_id: this._recipeId,
template_id: templateId,
position: position,
});
await this.loadAll();
this.notification.add(_t("Step added"), { type: "success" });
}
async reorderStep(stepId, newIndex) {
const ids = this.state.steps.map((s) => s.id);
const oldIndex = ids.indexOf(stepId);
if (oldIndex < 0 || oldIndex === newIndex) {
return;
}
ids.splice(oldIndex, 1);
ids.splice(Math.min(newIndex, ids.length), 0, stepId);
await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
await this.loadAll();
}
async onRemoveStep(stepId) {
const proceed = await this._confirm(
_t("Remove this step from the recipe?")
);
if (!proceed) {
return;
}
await rpc("/fp/simple_recipe/step/remove", { node_id: stepId });
await this.loadAll();
}
async onAddInlineStep() {
await rpc("/fp/simple_recipe/step/insert", {
recipe_id: this._recipeId,
template_id: false,
position: 99,
vals: { name: "New Step" },
});
await this.loadAll();
}
async onImportTemplate() {
if (!this.state.selectedTemplate) {
return;
}
let proceed = true;
if (this.state.steps.length > 0) {
proceed = await this._confirm(
_t("This recipe already has steps. Import will append. Continue?")
);
}
if (!proceed) {
return;
}
const result = await rpc("/fp/simple_recipe/template/import", {
source_recipe_id: parseInt(this.state.selectedTemplate, 10),
target_recipe_id: this._recipeId,
});
this.notification.add(
_t("Imported %s steps", result.imported_count),
{ type: "success" }
);
await this.loadAll();
}
openInTreeEditor() {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_recipe_tree_editor",
name: this.state.recipe?.name || _t("Recipe"),
context: { recipe_id: this._recipeId },
});
}
// --------------------------------------------------------- drag & drop
onSelectedDragStart(stepId, ev) {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("application/x-fp-step", String(stepId));
ev.dataTransfer.setData("text/plain", String(stepId));
const step = this.state.steps.find((s) => s.id === stepId);
this.state.dragPreviewLabel = step ? step.name : "";
this.state.dragPreviewIcon = (step && step.icon) || "fa-cog";
}
onLibraryDragStart(templateId, ev) {
ev.dataTransfer.effectAllowed = "copy";
ev.dataTransfer.setData("application/x-fp-library", String(templateId));
ev.dataTransfer.setData("text/plain", "library");
const tpl = this.state.library.find((t) => t.id === templateId);
this.state.dragPreviewLabel = tpl ? tpl.name : "";
this.state.dragPreviewIcon = (tpl && tpl.icon) || "fa-cog";
}
/**
* Compute the insertion index from the cursor Y vs row midpoint:
* above midpoint → insert BEFORE this row (index = rowIndex)
* below midpoint → insert AFTER this row (index = rowIndex + 1)
*/
onRowDragOver(rowIndex, ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect =
ev.dataTransfer.types.includes("application/x-fp-library")
? "copy"
: "move";
const rect = ev.currentTarget.getBoundingClientRect();
const before = (ev.clientY - rect.top) < (rect.height / 2);
this.state.dragOverIndex = before ? rowIndex : rowIndex + 1;
}
/** Trailing dropzone — always inserts at the end. */
onTailDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect =
ev.dataTransfer.types.includes("application/x-fp-library")
? "copy"
: "move";
this.state.dragOverIndex = this.state.steps.length;
}
async onDrop(ev) {
ev.preventDefault();
const targetIndex = this.state.dragOverIndex !== null
? this.state.dragOverIndex
: this.state.steps.length;
const fromLibrary = ev.dataTransfer.getData("application/x-fp-library");
if (fromLibrary) {
await this.insertFromLibrary(parseInt(fromLibrary, 10), targetIndex);
} else {
const fromStep = ev.dataTransfer.getData("application/x-fp-step");
const draggedId = parseInt(fromStep, 10);
if (draggedId) {
await this.reorderStep(draggedId, targetIndex);
}
}
this._clearDragState();
}
onDragLeave(ev) {
// Only clear when leaving the panel entirely. Browser fires
// dragleave when crossing into a child element too — guard against
// that by checking relatedTarget.
if (!ev.currentTarget.contains(ev.relatedTarget)) {
this.state.dragOverIndex = null;
}
}
onDragEnd() {
this._clearDragState();
}
_clearDragState() {
this.state.dragOverIndex = null;
this.state.dragPreviewLabel = "";
this.state.dragPreviewIcon = "fa-cog";
}
// --------------------------------------------------------------- helpers
async _confirm(message) {
return await new Promise((resolve) => {
this.dialog.add(
"web.ConfirmationDialog",
{
body: message,
confirm: () => resolve(true),
cancel: () => resolve(false),
},
{ onClose: () => resolve(false) }
);
});
}
}
registry.category("actions").add("fp_simple_recipe_editor", FpSimpleRecipeEditor);

View File

@@ -0,0 +1,247 @@
// Sub 12a — Simple Recipe Editor styling.
//
// Tokens follow the existing fp_shopfloor pattern (CSS custom props
// with hex fallbacks; dark-mode aware via $o-webclient-color-scheme
// SCSS @if branch — see fusion_plating CLAUDE.md for the rule).
$o-webclient-color-scheme: bright !default;
$_fp_se_page_hex: #f3f4f6;
$_fp_se_card_hex: #ffffff;
$_fp_se_border_hex: #d8dadd;
$_fp_se_accent_hex: #2e7d6b;
$_fp_se_muted_hex: #6b7280;
$_fp_se_drop_hex: #e8f5f0;
@if $o-webclient-color-scheme == dark {
$_fp_se_page_hex: #1a1d21 !global;
$_fp_se_card_hex: #22262d !global;
$_fp_se_border_hex: #3a3f47 !global;
$_fp_se_drop_hex: #1f3a33 !global;
}
$fp-se-page: var(--fp-page-bg, #{$_fp_se_page_hex});
$fp-se-card: var(--fp-card-bg, #{$_fp_se_card_hex});
$fp-se-border: var(--fp-border-color, #{$_fp_se_border_hex});
$fp-se-accent: var(--fp-accent, #{$_fp_se_accent_hex});
$fp-se-muted: var(--fp-muted, #{$_fp_se_muted_hex});
$fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
.o_fp_simple_editor {
background: $fp-se-page;
height: 100%;
overflow: auto;
padding: 1rem;
.o_fp_simple_editor_header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
h2 {
margin: 0;
flex: 1;
color: $fp-se-accent;
}
.o_fp_simple_editor_actions {
display: flex;
gap: .5rem;
}
}
.o_fp_simple_editor_meta {
background: $fp-se-card;
border: 1px solid $fp-se-border;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
.o_fp_import_row {
display: flex;
align-items: center;
gap: .75rem;
label { font-weight: 500; margin: 0; min-width: 14rem; }
select { flex: 1; max-width: 30rem; }
}
}
.o_fp_simple_editor_body {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
}
.o_fp_selected_panel,
.o_fp_library_panel {
background: $fp-se-card;
border: 1px solid $fp-se-border;
border-radius: 4px;
padding: 1rem;
h3 {
margin: 0 0 .75rem 0;
font-size: 1rem;
color: $fp-se-accent;
}
}
// ===================================================== Drop simulator
//
// Thin reservation line between rows that activates only when the
// cursor crosses a row's vertical midpoint. The active indicator
// expands to show a ghost-preview chip with the dragged step's icon
// + name so the operator knows EXACTLY where the drop lands.
.o_fp_drop_indicator {
height: 0;
margin: 0;
border: 0;
transition: height .08s ease, margin .08s ease, background .08s;
background: transparent;
border-radius: 2px;
overflow: hidden;
&.o_fp_drop_indicator_active {
height: 2.25rem;
margin: .25rem 0;
background: $fp-se-drop;
border: 2px dashed $fp-se-accent;
display: flex;
align-items: center;
padding: 0 .75rem;
}
.o_fp_drop_label {
display: flex;
align-items: center;
gap: .5rem;
font-weight: 600;
color: $fp-se-accent;
font-size: .85rem;
&::before {
content: "↓ insert here →";
font-weight: 500;
color: $fp-se-muted;
font-size: .75rem;
margin-right: .5rem;
}
}
}
.o_fp_step_row {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem;
border: 1px solid $fp-se-border;
border-radius: 4px;
margin-bottom: .25rem;
background: $fp-se-card;
cursor: grab;
&.o_fp_drag_over {
background: $fp-se-drop;
border-color: $fp-se-accent;
}
.o_fp_drag_handle {
color: $fp-se-muted;
cursor: grab;
user-select: none;
}
.o_fp_step_position {
font-weight: 600;
min-width: 1.5rem;
}
.o_fp_step_name { flex: 1; }
.o_fp_station_badge {
font-size: .75rem;
color: $fp-se-muted;
background: $fp-se-page;
padding: .125rem .5rem;
border-radius: 999px;
}
.o_fp_step_remove {
background: none;
border: none;
color: $fp-se-muted;
font-size: 1.25rem;
cursor: pointer;
opacity: 0;
transition: opacity .1s;
padding: 0 .25rem;
}
&:hover .o_fp_step_remove {
opacity: 1;
}
}
.o_fp_step_dropzone {
border: 2px dashed $fp-se-border;
border-radius: 4px;
padding: 1rem;
text-align: center;
color: $fp-se-muted;
margin-top: .5rem;
&.o_fp_drag_over,
&:hover {
border-color: $fp-se-accent;
background: $fp-se-drop;
}
}
.o_fp_inline_add {
margin-top: .75rem;
}
.o_fp_library_list {
margin-top: .5rem;
max-height: 65vh;
overflow: auto;
}
.o_fp_library_item {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem;
border: 1px solid $fp-se-border;
border-radius: 4px;
margin-bottom: .25rem;
background: $fp-se-card;
cursor: grab;
user-select: none;
.o_fp_library_name { flex: 1; }
.o_fp_library_meta {
font-size: .75rem;
color: $fp-se-muted;
}
&:hover {
border-color: $fp-se-accent;
}
}
.o_fp_library_empty {
color: $fp-se-muted;
font-style: italic;
padding: 1rem;
text-align: center;
}
.o_fp_loading {
padding: 2rem;
text-align: center;
color: $fp-se-muted;
}

View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating.FpSimpleRecipeEditor">
<div class="o_fp_simple_editor">
<div class="o_fp_simple_editor_header">
<h2 t-if="state.recipe">
Recipe: <span t-esc="state.recipe.name"/>
</h2>
<div class="o_fp_simple_editor_actions">
<button class="btn btn-secondary" t-on-click="openInTreeEditor">
Open in Tree Editor
</button>
</div>
</div>
<div class="o_fp_simple_editor_meta" t-if="state.recipe">
<div class="o_fp_import_row">
<label>Import starter from template:</label>
<select t-model="state.selectedTemplate">
<option value="">— Select template —</option>
<t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id">
<t t-esc="tpl.name"/> (<t t-esc="tpl.step_count"/> steps)
</option>
</t>
</select>
<button class="btn btn-primary" t-on-click="onImportTemplate"
t-att-disabled="!state.selectedTemplate">
Import
</button>
</div>
</div>
<div class="o_fp_simple_editor_body" t-if="!state.loading">
<div class="o_fp_selected_panel"
t-on-dragleave="(ev) => this.onDragLeave(ev)"
t-on-dragend="() => this.onDragEnd()"
t-on-drop="(ev) => this.onDrop(ev)">
<h3>Selected (drag to reorder)</h3>
<div class="o_fp_steps_list">
<!-- Top drop indicator (insertion at index 0). Visible
only when dragOverIndex === 0 — i.e. cursor is
hovering above the first row's midpoint. -->
<div class="o_fp_drop_indicator"
t-att-class="state.dragOverIndex === 0 ? 'o_fp_drop_indicator_active' : ''">
<span class="o_fp_drop_label" t-if="state.dragOverIndex === 0">
<i t-att-class="'fa ' + (state.dragPreviewIcon || 'fa-cog')"/>
<span t-esc="state.dragPreviewLabel"/>
</span>
</div>
<t t-foreach="state.steps" t-as="step" t-key="step.id">
<div class="o_fp_step_row"
draggable="true"
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
<span class="o_fp_drag_handle"></span>
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
<span class="o_fp_step_name" t-esc="step.name"/>
<span class="o_fp_station_badge"
t-if="step.tank_ids and step.tank_ids.length">
<t t-esc="step.tank_ids.length"/> stations
</span>
<button class="o_fp_step_remove"
t-on-click="() => this.onRemoveStep(step.id)">
×
</button>
</div>
<!-- Indicator AFTER each row (insertion at index = step_index + 1) -->
<div class="o_fp_drop_indicator"
t-att-class="state.dragOverIndex === (step_index + 1) ? 'o_fp_drop_indicator_active' : ''">
<span class="o_fp_drop_label" t-if="state.dragOverIndex === (step_index + 1)">
<i t-att-class="'fa ' + (state.dragPreviewIcon || 'fa-cog')"/>
<span t-esc="state.dragPreviewLabel"/>
</span>
</div>
</t>
<div class="o_fp_step_dropzone"
t-att-class="state.dragOverIndex === state.steps.length ? 'o_fp_drag_over' : ''"
t-on-dragover="(ev) => this.onTailDragOver(ev)">
<t t-if="state.steps.length === 0">
Drag a library step here to start
</t>
<t t-else="">
Drop here to add at end
</t>
</div>
</div>
<button class="btn btn-secondary o_fp_inline_add"
t-on-click="onAddInlineStep">
+ Add Inline Step
</button>
</div>
<div class="o_fp_library_panel">
<h3>Step Library</h3>
<input type="text" class="form-control"
placeholder="Search…"
t-on-input="onSearchLibrary"
t-att-value="state.librarySearch"/>
<div class="o_fp_library_list">
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
<div class="o_fp_library_item"
draggable="true"
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
<span class="o_fp_library_name" t-esc="tpl.name"/>
<span class="o_fp_library_meta" t-if="tpl.station_count">
<t t-esc="tpl.station_count"/> st.
</span>
</div>
</t>
<div class="o_fp_library_empty" t-if="!state.library.length">
No library entries match your search.
</div>
</div>
</div>
</div>
<div t-if="state.loading" class="o_fp_loading">
Loading…
</div>
</div>
</t>
</templates>

View File

@@ -46,9 +46,8 @@
<field name="max_dose"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
<group>
<field name="active" widget="boolean_toggle"/>
</group>

View File

@@ -0,0 +1,128 @@
<?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_job_step_move_list" model="ir.ui.view">
<field name="name">fp.job.step.move.list</field>
<field name="model">fp.job.step.move</field>
<field name="arch" type="xml">
<list string="Move Log" default_order="move_datetime desc" create="false">
<field name="name"/>
<field name="move_datetime"/>
<field name="job_id"/>
<field name="from_step_id"/>
<field name="to_step_id"/>
<field name="from_tank_id" optional="show"/>
<field name="to_tank_id" optional="show"/>
<field name="qty_moved"/>
<field name="transfer_type" widget="badge"/>
<field name="rack_id" optional="show"/>
<field name="moved_by_user_id"/>
</list>
</field>
</record>
<record id="view_fp_job_step_move_form" model="ir.ui.view">
<field name="name">fp.job.step.move.form</field>
<field name="model">fp.job.step.move</field>
<field name="arch" type="xml">
<form string="Move" create="false">
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="job_id"/>
<field name="from_step_id"/>
<field name="to_step_id"/>
<field name="transfer_type"/>
<field name="qty_moved"/>
<field name="qty_available_at_move"/>
</group>
<group>
<field name="from_tank_id"/>
<field name="to_tank_id"/>
<field name="to_location"/>
<field name="rack_id"/>
<field name="customer_wo_count"/>
<field name="moved_by_user_id"/>
<field name="move_datetime"/>
</group>
</group>
<notebook>
<page string="Captured Inputs" name="captured_inputs">
<field name="transition_input_value_ids" readonly="1">
<list>
<field name="node_input_id"/>
<field name="value_text"/>
<field name="value_number"/>
<field name="value_boolean"/>
<field name="value_date"/>
<field name="value_attachment_id"/>
</list>
</field>
</page>
<page string="Photo Evidence" name="photo"
invisible="not photo_evidence_id">
<field name="photo_evidence_id" widget="image"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_job_step_move_search" model="ir.ui.view">
<field name="name">fp.job.step.move.search</field>
<field name="model">fp.job.step.move</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="job_id"/>
<field name="from_step_id"/>
<field name="to_step_id"/>
<field name="rack_id"/>
<field name="moved_by_user_id"/>
<separator/>
<filter string="Today" name="today"
domain="[('move_datetime','&gt;=', (context_today() ).strftime('%Y-%m-%d 00:00:00'))]"/>
<filter string="Scrap / Rework" name="scrap_rework"
domain="[('transfer_type','in',('scrap','rework'))]"/>
<filter string="Racked" name="racked"
domain="[('rack_id','!=',False)]"/>
<group>
<filter string="Job" name="group_job"
context="{'group_by':'job_id'}"/>
<filter string="Operator" name="group_user"
context="{'group_by':'moved_by_user_id'}"/>
<filter string="Transfer Type" name="group_type"
context="{'group_by':'transfer_type'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_job_step_move" model="ir.actions.act_window">
<field name="name">Move Log</field>
<field name="res_model">fp.job.step.move</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_job_step_move_search"/>
</record>
<!-- Phase 1 — under Operations.
Phase 3 — supervisor+ only. Operators see their own moves on
the tablet; this is an audit view of every move. -->
<menuitem id="menu_fp_job_step_move"
name="Move Log"
parent="menu_fp_operations"
action="action_fp_job_step_move"
sequence="90"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</odoo>

View File

@@ -0,0 +1,142 @@
<?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.
Sub 12c — Labor History views.
fp.job.step.timelog now has a state machine + reconciliation
columns (Sub 12b). This file surfaces the history under
Plating → Labor History for billing audit + payroll
reconciliation.
-->
<odoo>
<record id="view_fp_job_step_timelog_list" model="ir.ui.view">
<field name="name">fp.job.step.timelog.list</field>
<field name="model">fp.job.step.timelog</field>
<field name="arch" type="xml">
<list string="Labor History" default_order="date_started desc"
decoration-info="state == 'running'"
decoration-warning="state == 'paused'"
decoration-muted="state == 'reconciled'">
<field name="user_id"/>
<field name="job_id"/>
<field name="step_id"/>
<field name="state" widget="badge"
decoration-info="state == 'running'"
decoration-warning="state == 'paused'"
decoration-success="state == 'stopped'"
decoration-muted="state == 'reconciled'"/>
<field name="date_started"/>
<field name="date_finished" optional="show"/>
<field name="accrued_seconds" optional="show"/>
<field name="billed_hrs" optional="show"/>
<field name="billed_min" optional="show"/>
<field name="billed_sec" optional="show"/>
<field name="billed_pct" widget="progressbar" optional="show"/>
<field name="product_id" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_job_step_timelog_form" model="ir.ui.view">
<field name="name">fp.job.step.timelog.form</field>
<field name="model">fp.job.step.timelog</field>
<field name="arch" type="xml">
<form string="Labor Timer" create="false">
<header>
<field name="state" widget="statusbar"
statusbar_visible="running,paused,stopped,reconciled"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="display_name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="user_id" readonly="1"/>
<field name="job_id" readonly="1"/>
<field name="step_id" readonly="1"/>
<field name="date_started" readonly="1"/>
<field name="date_finished" readonly="1"/>
</group>
<group>
<field name="accrued_seconds" readonly="1"/>
<label for="billed_hrs" string="Billed Time"/>
<div>
<field name="billed_hrs" class="oe_inline"
readonly="state == 'reconciled'"
groups="fusion_plating.group_fusion_plating_supervisor"/>
hrs
<field name="billed_min" class="oe_inline"
readonly="state == 'reconciled'"
groups="fusion_plating.group_fusion_plating_supervisor"/>
min
<field name="billed_sec" class="oe_inline"
readonly="state == 'reconciled'"
groups="fusion_plating.group_fusion_plating_supervisor"/>
sec
</div>
<field name="billed_pct" widget="progressbar" readonly="1"/>
<field name="product_id"/>
</group>
</group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
</form>
</field>
</record>
<record id="view_fp_job_step_timelog_search" model="ir.ui.view">
<field name="name">fp.job.step.timelog.search</field>
<field name="model">fp.job.step.timelog</field>
<field name="arch" type="xml">
<search>
<field name="user_id"/>
<field name="job_id"/>
<field name="step_id"/>
<field name="product_id"/>
<separator/>
<filter string="My Timers" name="my_timers"
domain="[('user_id','=',uid)]"/>
<filter string="Today" name="today"
domain="[('date_started','&gt;=',(context_today() ).strftime('%Y-%m-%d 00:00:00'))]"/>
<separator/>
<filter string="Running" name="running"
domain="[('state','=','running')]"/>
<filter string="Paused" name="paused"
domain="[('state','=','paused')]"/>
<filter string="Pending Reconciliation" name="pending"
domain="[('state','=','stopped')]"/>
<filter string="Reconciled" name="reconciled"
domain="[('state','=','reconciled')]"/>
<group>
<filter string="Operator" name="group_user"
context="{'group_by':'user_id'}"/>
<filter string="Job" name="group_job"
context="{'group_by':'job_id'}"/>
<filter string="Date" name="group_date"
context="{'group_by':'date_started:day'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_labor_history" model="ir.actions.act_window">
<field name="name">Labor History</field>
<field name="res_model">fp.job.step.timelog</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_job_step_timelog_search"/>
<field name="context">{'search_default_my_timers': 1}</field>
</record>
<!-- Phase 1 — re-parented under Operations. -->
<menuitem id="menu_fp_labor_history"
name="Labor History"
parent="menu_fp_operations"
action="action_fp_labor_history"
sequence="95"/>
</odoo>

View File

@@ -5,12 +5,18 @@
"All Jobs" and "Steps" used to live under a separate "Jobs"
submenu but the user moved them under Shop Floor instead
(see fusion_plating_jobs/views/jobs_in_shopfloor_menu.xml).
Only Work Centres stays in core (under Configuration). -->
Routing Stations stays in core (under Configuration → Shop Setup).
Note: this is the per-step routing entity (fp.work.centre,
post-Sub-11 mrp.workcenter replacement) — distinct from the
shop-layout 'Production Lines' (fusion.plating.work.center)
that group tanks. Routing Stations are kind-aware (wet_line /
bake / mask / rack / inspect) and carry cost_per_hour. -->
<menuitem id="menu_fp_jobs_work_centres"
name="Work Centres"
parent="menu_fp_config"
name="Routing Stations"
parent="menu_fp_config_shop_setup"
action="action_fp_work_centre"
sequence="55"
sequence="25"
groups="fusion_plating.group_fusion_plating_manager"/>
</odoo>

View File

@@ -0,0 +1,82 @@
<?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.
Phase 1 — Plating landing-page resolver UI.
Two surfaces:
1. Settings → Fusion Plating → Recipe Editor block (extended into
a Landing Page block for the company-level default).
2. User profile / preferences form — adds a Fusion Plating tab
with the per-user override dropdown.
-->
<odoo>
<!-- ====== User profile (Preferences > Fusion Plating tab) ====== -->
<record id="view_users_form_fp_landing" model="ir.ui.view">
<field name="name">res.users.form.fp.landing</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Fusion Plating" name="fp_landing">
<p class="text-muted">
Choose which page opens when you click the Plating
app. When blank, you see the company default
(Sale Orders unless an admin has changed it).
</p>
<group>
<field name="x_fc_plating_landing_action_id"
options="{'no_create': True, 'no_open': True}"/>
</group>
</page>
</xpath>
</field>
</record>
<!-- Same field surfaced on the simplified preferences form (the one
users open from the avatar menu — uses a different XML id). -->
<record id="view_users_form_simple_fp_landing" model="ir.ui.view">
<field name="name">res.users.form.simple.fp.landing</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Fusion Plating" name="fp_landing_simple">
<p class="text-muted">
Page that opens when you click the Plating app.
</p>
<group>
<field name="x_fc_plating_landing_action_id"
options="{'no_create': True, 'no_open': True}"/>
</group>
</page>
</xpath>
</field>
</record>
<!-- ====== Settings → Fusion Plating → Landing Page block ====== -->
<record id="res_config_settings_view_form_fp_landing" model="ir.ui.view">
<field name="name">res.config.settings.form.fp.landing</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="fusion_plating.res_config_settings_view_form_fp_core"/>
<field name="arch" type="xml">
<xpath expr="//block[@name='fp_recipe_editor_settings']"
position="after">
<block title="Plating Landing Page"
name="fp_landing_settings"
help="Page that opens when a user clicks the Plating app.">
<setting id="fp_default_landing_action"
string="Default Landing Page"
help="Users without a personal preference see this page. Each user can override it under their Profile > Preferences > Fusion Plating tab.">
<field name="x_fc_default_landing_action_id"
options="{'no_create': True, 'no_open': True}"/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@@ -3,22 +3,85 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Top-level menu structure for the Plating app. Order in this file
matters — Odoo's data loader is strictly sequential, so every
parent menu must be defined BEFORE any child that references it
by xmlid. Sections in declaration order:
1. Root (menu_fp_root) + landing-action wiring
2. Configuration parent (menu_fp_config) + the 7 Phase-2 buckets
3. Compliance hub
4. Operations parent
5. All children of all three roots above (in any order)
-->
<odoo>
<!-- ===== ROOT APP MENU ===== -->
<!-- ===== 1. ROOT APP MENU ===== -->
<menuitem id="menu_fp_root"
name="Plating"
sequence="46"
web_icon="fusion_plating,static/description/icon.png"
action="action_fp_resolve_plating_landing"
groups="group_fusion_plating_operator"/>
<!-- ===== OPERATIONS ===== -->
<!-- ===== 2. CONFIGURATION + 7 Phase-2 buckets ===== -->
<menuitem id="menu_fp_config"
name="Configuration"
parent="menu_fp_root"
sequence="90"
groups="group_fusion_plating_manager"/>
<menuitem id="menu_fp_config_shop_setup"
name="Shop Setup"
parent="menu_fp_config"
sequence="10"/>
<menuitem id="menu_fp_config_recipes_steps"
name="Recipes &amp; Steps"
parent="menu_fp_config"
sequence="20"/>
<menuitem id="menu_fp_config_materials_tanks"
name="Materials &amp; Tanks"
parent="menu_fp_config"
sequence="30"/>
<menuitem id="menu_fp_config_workforce"
name="Workforce"
parent="menu_fp_config"
sequence="40"/>
<menuitem id="menu_fp_config_quality_docs"
name="Quality &amp; Documents"
parent="menu_fp_config"
sequence="50"/>
<menuitem id="menu_fp_config_pricing_billing"
name="Pricing &amp; Billing"
parent="menu_fp_config"
sequence="60"/>
<menuitem id="menu_fp_config_reference_data"
name="Reference Data"
parent="menu_fp_config"
sequence="70"/>
<!-- ===== 3. COMPLIANCE HUB (Phase 1) ===== -->
<menuitem id="menu_fp_compliance_hub"
name="Compliance"
parent="menu_fp_root"
sequence="50"
groups="group_fusion_plating_supervisor"/>
<!-- ===== 4. OPERATIONS ===== -->
<menuitem id="menu_fp_operations"
name="Operations"
parent="menu_fp_root"
sequence="18"/>
<!-- ===== 5. CHILD MENUS ===== -->
<!-- Operations children -->
<menuitem id="menu_fp_process_recipes"
name="Process Recipes"
parent="menu_fp_operations"
@@ -49,59 +112,61 @@
action="action_fp_rack"
sequence="35"/>
<!-- Phase 3 — supervisor+: replenishment is a purchasing decision. -->
<menuitem id="menu_fp_replenishment_suggestions"
name="Replenishment Suggestions"
parent="menu_fp_operations"
action="action_fp_replenishment_suggestion"
sequence="40"/>
sequence="40"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<!-- Configuration children (referencing the 7 buckets above) -->
<menuitem id="menu_fp_replenishment_rules"
name="Replenishment Rules"
parent="menu_fp_config"
parent="menu_fp_config_materials_tanks"
action="action_fp_replenishment_rule"
sequence="55"/>
sequence="20"/>
<menuitem id="menu_fp_operator_certifications"
name="Operator Certifications"
parent="menu_fp_config"
parent="menu_fp_config_workforce"
action="action_fp_operator_cert"
sequence="60"/>
<!-- ===== CONFIGURATION ===== -->
<menuitem id="menu_fp_config"
name="Configuration"
parent="menu_fp_root"
sequence="90"
groups="group_fusion_plating_manager"/>
sequence="20"/>
<menuitem id="menu_fp_facilities"
name="Facilities"
parent="menu_fp_config"
parent="menu_fp_config_shop_setup"
action="action_fp_facility"
sequence="10"/>
<menuitem id="menu_fp_work_centers"
name="Work Centers"
parent="menu_fp_config"
name="Production Lines"
parent="menu_fp_config_shop_setup"
action="action_fp_work_center"
sequence="20"/>
<menuitem id="menu_fp_process_categories"
name="Process Categories"
parent="menu_fp_config"
parent="menu_fp_config_shop_setup"
action="action_fp_process_category"
sequence="30"/>
<menuitem id="menu_fp_process_types"
name="Process Types"
parent="menu_fp_config"
parent="menu_fp_config_shop_setup"
action="action_fp_process_type"
sequence="40"/>
<menuitem id="menu_fp_step_library"
name="Step Library"
parent="menu_fp_config_recipes_steps"
action="action_fp_step_template"
sequence="10"/>
<menuitem id="menu_fp_bath_parameters"
name="Bath Parameters"
parent="menu_fp_config"
parent="menu_fp_config_materials_tanks"
action="action_fp_bath_parameter"
sequence="50"/>
sequence="10"/>
</odoo>

View File

@@ -48,12 +48,10 @@
<field name="training_record_attachment_id"/>
</group>
</group>
<group string="Revocation" invisible="state != 'revoked'">
<field name="revoked_reason" nolabel="1" colspan="2"/>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<separator string="Revocation"/>
<field name="revoked_reason" colspan="2"/>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
</sheet>
<chatter/>
</form>

View File

@@ -40,6 +40,10 @@
string="Open Tree Editor" class="btn-primary"
icon="fa-sitemap"
invisible="node_type != 'recipe'"/>
<button name="action_open_simple_editor" type="object"
string="Open Simple Editor" class="btn-secondary"
icon="fa-list-ol"
invisible="node_type != 'recipe'"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
@@ -90,11 +94,14 @@
widget="float_time"/>
<field name="product_id"
options="{'no_create': True}"/>
<field name="preferred_editor"/>
</group>
<group>
<field name="contract_review_user_ids"
widget="many2many_tags"
options="{'no_create': True}"/>
<field name="is_template"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</group>
</group>
<group>
@@ -135,6 +142,38 @@
</list>
</field>
</page>
<page string="Step Authoring" name="step_authoring"
invisible="node_type not in ('step', 'operation')">
<group>
<group string="Stations">
<field name="tank_ids" widget="many2many_tags"/>
<field name="default_kind"/>
<field name="material_callout"/>
</group>
<group string="Flags">
<field name="requires_predecessor_done"/>
<field name="requires_rack_assignment"/>
<field name="requires_transition_form"/>
</group>
</group>
<group>
<group string="Time Target">
<field name="time_min_target"/>
<field name="time_max_target"/>
<field name="time_unit"/>
</group>
<group string="Temperature Target">
<field name="temp_min_target"/>
<field name="temp_max_target"/>
<field name="temp_unit"/>
</group>
</group>
<group>
<field name="voltage_target"/>
<field name="viscosity_target"/>
<field name="source_template_id" readonly="1"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes..."/>
</page>
@@ -188,6 +227,7 @@
<field name="domain">[('node_type', '=', 'recipe')]</field>
<field name="context">{'default_node_type': 'recipe', 'search_default_recipes_only': 1}</field>
<field name="search_view_id" ref="view_fp_process_node_search"/>
<field name="x_fc_pickable_landing" eval="True"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first process recipe

View File

@@ -211,21 +211,34 @@
</div>
</div>
<group>
<group>
<group string="Definition">
<field name="parameter_type"/>
<field name="uom"/>
<field name="decimals"/>
</group>
<group>
<field name="target_min"/>
<field name="target_max"/>
<group string="Default Targets (in selected unit)">
<label for="target_min"/>
<div class="o_row">
<field name="target_min" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<label for="target_max"/>
<div class="o_row">
<field name="target_max" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<label for="target_value"/>
<div class="o_row">
<field name="target_value" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<field name="warning_tolerance"/>
<field name="active" widget="boolean_toggle"/>
</group>
</group>
<group string="Description">
<field name="description" nolabel="1"/>
</group>
<separator string="Description"/>
<field name="description" nolabel="1"
placeholder="What is this parameter, how is it measured, why does it matter?"/>
</sheet>
</form>
</field>

View File

@@ -0,0 +1,34 @@
<?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_rack_tag_list" model="ir.ui.view">
<field name="name">fp.rack.tag.list</field>
<field name="model">fp.rack.tag</field>
<field name="arch" type="xml">
<list string="Rack Tags" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="color" widget="color_picker"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="action_fp_rack_tag" model="ir.actions.act_window">
<field name="name">Rack Tags</field>
<field name="res_model">fp.rack.tag</field>
<field name="view_mode">list</field>
</record>
<menuitem id="menu_fp_rack_tags"
name="Rack Tags"
parent="fusion_plating.menu_fp_config_materials_tanks"
action="action_fp_rack_tag"
sequence="30"/>
</odoo>

View File

@@ -68,9 +68,22 @@
<field name="strips_count"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
<group>
<group string="Tags + Capacity (Sub 12b)">
<field name="racking_state"/>
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}"/>
<field name="capacity_count"/>
</group>
<group string="Current Use"
invisible="racking_state in ('empty','out_of_service')">
<field name="current_job_step_id" readonly="1"/>
<field name="current_tank_id" readonly="1"/>
<field name="current_part_count" readonly="1"/>
</group>
</group>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
</sheet>
<chatter/>
</form>

View File

@@ -0,0 +1,144 @@
<?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_step_template_list" model="ir.ui.view">
<field name="name">fp.step.template.list</field>
<field name="model">fp.step.template</field>
<field name="arch" type="xml">
<list string="Step Library">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="default_kind"/>
<field name="tank_ids" widget="many2many_tags" optional="show"/>
<field name="requires_signoff" optional="hide"/>
<field name="requires_rack_assignment" optional="hide"/>
<field name="requires_transition_form" optional="hide"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_step_template_form" model="ir.ui.view">
<field name="name">fp.step.template.form</field>
<field name="model">fp.step.template</field>
<field name="arch" type="xml">
<form string="Step Library Template">
<header>
<button name="action_seed_default_inputs" type="object"
string="Seed Default Inputs" class="btn-secondary"
invisible="not default_kind"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" string="Title"/>
<h1><field name="name" placeholder="e.g. Soak Clean"/></h1>
<div class="text-muted">
<field name="code" placeholder="SOAK_CLEAN"/>
</div>
</div>
<group>
<group string="Classification">
<field name="default_kind"/>
<field name="icon"/>
<field name="process_type_id"/>
<field name="material_callout"/>
</group>
<group string="Stations + Flags">
<field name="tank_ids" widget="many2many_tags"/>
<field name="requires_signoff"/>
<field name="requires_predecessor_done"/>
<field name="requires_rack_assignment"/>
<field name="requires_transition_form"/>
</group>
</group>
<notebook>
<page string="Instructions" name="instructions">
<field name="description"
placeholder="Rich-text instructions / WI reference."/>
</page>
<page string="Operation Measurements" name="op_measurements">
<field name="input_template_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="input_type"/>
<field name="target_min"/>
<field name="target_max"/>
<field name="target_unit"/>
<field name="required" widget="boolean_toggle"/>
<field name="hint"/>
</list>
</field>
</page>
<page string="Transition Form" name="transition_form">
<field name="transition_input_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="input_type"/>
<field name="required" widget="boolean_toggle"/>
<field name="compliance_tag"/>
<field name="hint"/>
</list>
</field>
</page>
<page string="Advanced" name="advanced">
<group>
<group string="Time Target">
<field name="time_min_target"/>
<field name="time_max_target"/>
<field name="time_unit"/>
</group>
<group string="Temperature Target">
<field name="temp_min_target"/>
<field name="temp_max_target"/>
<field name="temp_unit"/>
</group>
<group string="Other Targets">
<field name="voltage_target"/>
<field name="viscosity_target"/>
</group>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_step_template_search" model="ir.ui.view">
<field name="name">fp.step.template.search</field>
<field name="model">fp.step.template</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="code"/>
<field name="default_kind"/>
<field name="tank_ids"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Step Kind" name="group_kind"
context="{'group_by':'default_kind'}"/>
<filter string="Process Type" name="group_proc"
context="{'group_by':'process_type_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_step_template" model="ir.actions.act_window">
<field name="name">Step Library</field>
<field name="res_model">fp.step.template</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_step_template_search"/>
</record>
</odoo>

View File

@@ -10,20 +10,24 @@
<field name="name">fp.tank.list</field>
<field name="model">fusion.plating.tank</field>
<field name="arch" type="xml">
<list string="Tanks">
<field name="facility_id"/>
<field name="work_center_id"/>
<list string="Tanks" multi_edit="1" expand="1">
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="section_id" optional="show"/>
<field name="facility_id" optional="hide"/>
<field name="work_center_id" optional="hide"/>
<field name="current_process_id"/>
<field name="default_temperature" optional="show"/>
<field name="default_temperature_uom" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'in_use'"
decoration-info="state == 'filled'"
decoration-warning="state in ('draining', 'maintenance')"
decoration-muted="state in ('empty', 'out_of_service')"/>
<field name="material" optional="hide"/>
<field name="volume" optional="show"/>
<field name="volume_uom" optional="show"/>
<field name="volume" optional="hide"/>
<field name="volume_uom" optional="hide"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
@@ -35,6 +39,24 @@
<field name="arch" type="xml">
<form string="Tank">
<header>
<button name="action_set_empty" type="object"
string="Mark Empty" class="btn-secondary"
invisible="state == 'empty'"/>
<button name="action_set_filled" type="object"
string="Mark Filled" class="btn-primary"
invisible="state == 'filled'"/>
<button name="action_set_in_use" type="object"
string="Mark In Use" class="btn-success"
invisible="state == 'in_use'"/>
<button name="action_set_draining" type="object"
string="Mark Draining" class="btn-warning"
invisible="state == 'draining'"/>
<button name="action_set_maintenance" type="object"
string="Mark for Maintenance" class="btn-warning"
invisible="state == 'maintenance'"/>
<button name="action_set_out_of_service" type="object"
string="Mark Out of Service" class="btn-danger"
invisible="state == 'out_of_service'"/>
<field name="state" widget="statusbar"
statusbar_visible="empty,filled,in_use,draining,maintenance"/>
</header>
@@ -42,7 +64,7 @@
<widget name="web_ribbon" title="Out of Service" bg_color="text-bg-danger"
invisible="state != 'out_of_service'"/>
<div class="oe_title">
<label for="name"/>
<label for="name" string="Tank Name"/>
<h1><field name="name" placeholder="e.g. EN Plating Tank A1"/></h1>
<div class="text-muted">
<field name="code" placeholder="T-01"/>
@@ -51,12 +73,19 @@
<group>
<group string="Location">
<field name="facility_id"/>
<field name="section_id"
options="{'no_quick_create': False}"/>
<field name="work_center_id"/>
<field name="sequence"/>
</group>
<group string="Current Bath">
<field name="current_bath_id" readonly="1"/>
<field name="current_process_id" readonly="1"/>
<group string="Operating Setpoints">
<field name="current_process_id"
help="Editable. Defaults to the active bath's process."/>
<label for="default_temperature"/>
<div class="o_row">
<field name="default_temperature" nolabel="1" class="oe_inline"/>
<field name="default_temperature_uom" nolabel="1" class="oe_inline"/>
</div>
<field name="qr_code"/>
</group>
</group>
@@ -78,6 +107,62 @@
</group>
</group>
</page>
<page string="Compositions">
<group>
<field name="active_composition_id"
options="{'no_create_edit': True}"/>
</group>
<field name="composition_ids" context="{'default_tank_id': id}">
<list decoration-bf="is_active">
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="total_percentage"
decoration-warning="total_percentage != 100.0 and total_percentage > 0"/>
<field name="is_active" string="Active"/>
<button name="action_set_active" type="object"
string="Set Active" class="btn-link"
icon="fa-check-circle"
invisible="is_active"/>
</list>
<form string="Composition">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Composition A"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="sequence"/>
</group>
<group>
<field name="total_percentage"/>
<field name="is_active"/>
</group>
</group>
<field name="description" placeholder="Short description..."/>
<notebook>
<page string="Ingredients">
<field name="ingredient_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="percentage"/>
<field name="uom"/>
<field name="notes"/>
</list>
</field>
</page>
<page string="Notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</page>
<page string="Bath History">
<field name="bath_ids">
<list decoration-muted="state == 'dumped'">
@@ -108,6 +193,7 @@
<field name="state"/>
<field name="current_bath_id"/>
<field name="current_process_id"/>
<field name="section_id"/>
<field name="facility_id"/>
<field name="work_center_id"/>
<templates>
@@ -124,7 +210,7 @@
</div>
<div class="mt-2 small">
<div><i class="fa fa-flask me-1 text-muted"/><field name="current_process_id"/></div>
<div class="text-muted"><field name="work_center_id"/></div>
<div class="text-muted"><field name="section_id"/></div>
</div>
</div>
</t>
@@ -142,6 +228,7 @@
<field name="code"/>
<field name="qr_code"/>
<field name="facility_id"/>
<field name="section_id"/>
<field name="work_center_id"/>
<field name="current_process_id"/>
<separator/>
@@ -152,8 +239,9 @@
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Section" name="group_section" context="{'group_by':'section_id'}"/>
<filter string="Facility" name="group_facility" context="{'group_by':'facility_id'}"/>
<filter string="Work Center" name="group_wc" context="{'group_by':'work_center_id'}"/>
<filter string="Production Line" name="group_wc" context="{'group_by':'work_center_id'}"/>
<filter string="Process" name="group_process" context="{'group_by':'current_process_id'}"/>
<filter string="Status" name="group_state" context="{'group_by':'state'}"/>
</group>
@@ -164,8 +252,70 @@
<record id="action_fp_tank" model="ir.actions.act_window">
<field name="name">Tanks</field>
<field name="res_model">fusion.plating.tank</field>
<field name="view_mode">kanban,list,form</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_fp_tank_search"/>
<field name="context">{'search_default_group_section': 1}</field>
</record>
<!-- ==================================================================
Tank Sections — manageable from Configuration → Shop Setup
================================================================== -->
<record id="view_fp_tank_section_list" model="ir.ui.view">
<field name="name">fp.tank.section.list</field>
<field name="model">fusion.plating.tank.section</field>
<field name="arch" type="xml">
<list string="Tank Sections" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="facility_id"/>
<field name="tank_count"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_tank_section_form" model="ir.ui.view">
<field name="name">fp.tank.section.form</field>
<field name="model">fusion.plating.tank.section</field>
<field name="arch" type="xml">
<form string="Tank Section">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_tanks" type="object"
class="oe_stat_button" icon="fa-flask">
<field name="tank_count" widget="statinfo" string="Tanks"/>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Steel Line"/></h1>
</div>
<group>
<group>
<field name="facility_id"/>
<field name="sequence"/>
<field name="color" widget="color_picker"/>
</group>
<group>
<field name="active"/>
</group>
</group>
<field name="description" placeholder="What kinds of tanks belong in this section?"/>
</sheet>
</form>
</field>
</record>
<record id="action_fp_tank_section" model="ir.actions.act_window">
<field name="name">Tank Sections</field>
<field name="res_model">fusion.plating.tank.section</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fp_tank_sections"
name="Tank Sections"
parent="menu_fp_config_shop_setup"
action="action_fp_tank_section"
sequence="35"/>
</odoo>

View File

@@ -10,7 +10,7 @@
<field name="name">fp.work.center.list</field>
<field name="model">fusion.plating.work.center</field>
<field name="arch" type="xml">
<list string="Work Centers">
<list string="Production Lines">
<field name="facility_id"/>
<field name="sequence" widget="handle"/>
<field name="code"/>
@@ -26,7 +26,7 @@
<field name="name">fp.work.center.form</field>
<field name="model">fusion.plating.work.center</field>
<field name="arch" type="xml">
<form string="Work Center">
<form string="Production Line">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
@@ -70,7 +70,7 @@
<field name="name">fp.work.center.search</field>
<field name="model">fusion.plating.work.center</field>
<field name="arch" type="xml">
<search string="Work Centers">
<search string="Production Lines">
<field name="name"/>
<field name="code"/>
<field name="facility_id"/>
@@ -83,7 +83,7 @@
</record>
<record id="action_fp_work_center" model="ir.actions.act_window">
<field name="name">Work Centers</field>
<field name="name">Production Lines</field>
<field name="res_model">fusion.plating.work.center</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_work_center_search"/>

View File

@@ -43,7 +43,7 @@
</record>
<record id="action_fp_work_centre" model="ir.actions.act_window">
<field name="name">Work Centres</field>
<field name="name">Routing Stations</field>
<field name="res_model">fp.work.centre</field>
<field name="view_mode">list,form</field>
</record>

View File

@@ -42,10 +42,8 @@
<field name="mastery_required"/>
</group>
</group>
<group>
<field name="description"
<field name="description"
placeholder="Short operator-facing description of what this role covers."/>
</group>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-1"/>
<strong>Mastery Threshold</strong> controls auto-promotion: when an
@@ -77,9 +75,9 @@
<menuitem id="menu_fp_work_roles"
name="Shop Roles"
parent="fusion_plating.menu_fp_config"
parent="fusion_plating.menu_fp_config_workforce"
action="action_fp_work_role"
sequence="55"
sequence="10"
groups="fusion_plating.group_fusion_plating_manager"/>
<!-- Employee form — Shop Roles + Lead Hand For + Proficiency tracker -->

View File

@@ -38,6 +38,16 @@
</setting>
</block>
<block title="Recipe Editor"
name="fp_recipe_editor_settings"
help="Choose which editor opens when a recipe is created or when a recipe's editor preference is 'Auto'.">
<setting id="fp_default_recipe_editor"
string="Default Recipe Editor"
help="Tree Editor: hierarchical drag-drop tree with sub-processes (existing). Simple Editor: flat ordered list with a step library on the side (Steelhead-style). Per-recipe preferred_editor (tree/simple) overrides this default.">
<field name="x_fc_default_recipe_editor"/>
</setting>
</block>
<block title="Units of Measure"
name="fp_uom_settings"
help="Default units used wherever the shop records measurements. North-American aerospace shops typically pick °F + mils; metric shops pick °C + microns. Each new record (work order, oven, bath log, thickness reading) inherits these defaults; per-record overrides remain possible.">

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Aerospace (AS9100 + Nadcap)',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Aerospace industry pack: AS9100 Rev D clause library, Nadcap AC7108 '
'audits, counterfeit parts prevention, config management, risk register, '

View File

@@ -6,11 +6,11 @@
-->
<odoo>
<!-- ===== AEROSPACE (parent submenu under the Plating app) ===== -->
<!-- Phase 1 — re-parented under Plating → Compliance hub. -->
<menuitem id="menu_fp_aerospace"
name="Aerospace"
parent="fusion_plating.menu_fp_root"
sequence="60"
name="Aerospace (AS9100 / Nadcap)"
parent="fusion_plating.menu_fp_compliance_hub"
sequence="30"
groups="fusion_plating.group_fusion_plating_operator"/>
<menuitem id="menu_fp_aerospace_as9100"

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Plating — Maintenance Bridge',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge standard Odoo Maintenance with Fusion Plating equipment, '
'plans, checklists, and sensor integration.',

View File

@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===== Maintenance parent menu under Plating root ===== -->
<!-- Phase 1 — re-parented under Plating → Operations. Maintenance
is an Operations concern, not a separate top-level. -->
<menuitem id="menu_fp_maintenance"
name="Maintenance"
parent="fusion_plating.menu_fp_root"
sequence="22"
parent="fusion_plating.menu_fp_operations"
sequence="80"
groups="fusion_plating.group_fusion_plating_operator"/>
<menuitem id="menu_fp_maintenance_active"

View File

@@ -49,9 +49,8 @@
<field name="logged_by_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
</form>
</field>

View File

@@ -92,10 +92,9 @@
<field name="value_max"/>
<field name="value_uom"/>
</group>
<group string="Guidance">
<field name="description" nolabel="1"
<separator string="Guidance"/>
<field name="description"
placeholder="Inspection guidance shown to the operator on tap..."/>
</group>
</sheet>
</form>
</field>

View File

@@ -42,10 +42,8 @@
<field name="mastery_required"/>
</group>
</group>
<group>
<field name="description"
<field name="description"
placeholder="Short operator-facing description of what this role covers."/>
</group>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-1"/>
<strong>Mastery Threshold</strong> controls auto-promotion: when an

View File

@@ -57,9 +57,7 @@
<field name="x_fc_is_rework" readonly="1"/>
<field name="x_fc_original_production_id" readonly="1"/>
</group>
<group>
<field name="x_fc_rework_reason"/>
</group>
<field name="x_fc_rework_reason"/>
</group>
</xpath>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.5.2.0',
'version': '19.0.5.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -70,6 +70,18 @@ class FpCertificate(models.Model):
certified_by_id = fields.Many2one(
'res.users', string='Certified By', help='Signing authority (e.g. Quality Manager).',
)
# ===== Sub 12c — chronological CoC opt-in ===============================
body_style = fields.Selection(
[
('classic', 'Classic (recipe-order)'),
('chronological', 'Chronological (chain-of-custody)'),
],
string='CoC Body Style', default='classic',
help='Chronological walks fp.job.step.move records in time order '
'with measurement sub-tables per move, matching Steelhead\'s '
'CoC PDF layout. Classic uses the existing recipe-order body.',
)
issue_date = fields.Date(string='Issue Date', default=fields.Date.today, tracking=True)
attachment_id = fields.Many2one('ir.attachment', string='Certificate PDF')
thickness_reading_ids = fields.One2many(

View File

@@ -87,3 +87,14 @@ class ResPartner(models.Model):
'use: a primary account-manager contact who wants full '
'visibility into everything the shop sends out.',
)
# ---- Sub 12c+ — Per-customer cert statement override ----------------
x_fc_cert_statement = fields.Text(
string='Cert Statement Override',
help='Override boilerplate text printed in the Certificate of '
'Conformance "Certification Statement" block. When blank, '
'falls back to the company default (res.company.'
'x_fc_default_cert_statement) and finally to a hardcoded '
'AS9100/ISO 9001 boilerplate. Useful for aerospace customers '
'who require specific NIST or DFARS language.',
)

View File

@@ -93,6 +93,7 @@
<group>
<field name="issued_by_id"/>
<field name="certified_by_id"/>
<field name="body_style"/>
</group>
<group>
<field name="reading_count" readonly="1"/>
@@ -144,9 +145,7 @@
</page>
<page string="Void" name="void"
invisible="state != 'voided'">
<group>
<field name="void_reason"/>
</group>
<field name="void_reason"/>
</page>
<page string="Notes" name="notes">
<field name="notes"/>

View File

@@ -16,11 +16,12 @@
<field name="domain">[('certificate_type', '=', 'thickness_report')]</field>
</record>
<!-- Menu under Fusion Plating root -->
<!-- Phase 1 — re-parented under Plating → Quality. Certificates are
a quality output, not a separate top-level concern. -->
<menuitem id="menu_fp_certificates"
name="Certificates"
parent="fusion_plating.menu_fp_root"
sequence="25"
parent="fusion_plating_quality.menu_fp_quality"
sequence="80"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<menuitem id="menu_fp_certificates_all"

View File

@@ -32,6 +32,15 @@
<field name="x_fc_send_bol" widget="boolean_toggle"/>
</group>
</group>
<separator string="Cert Statement Override (Sub 12c+)"/>
<p class="text-muted">
Boilerplate text printed in the "Certification Statement"
block on this customer's CoC. Leave blank to use the
company default, then a hardcoded AS9100/ISO 9001
statement.
</p>
<field name="x_fc_cert_statement"
placeholder="e.g. We certify these parts conform to MIL-DTL-5541F Class 1A and have been processed in accordance with…"/>
</page>
</xpath>
</field>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Controlled Goods Program',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Canadian Controlled Goods Program (CGP) compliance for plating '
'shops handling defence work: registration, authorized individuals, '

View File

@@ -6,11 +6,11 @@
-->
<odoo>
<!-- ===== CGP (parent submenu under the Plating app) ===== -->
<!-- Phase 1 — re-parented under Plating → Compliance hub. -->
<menuitem id="menu_fp_cgp"
name="CGP"
parent="fusion_plating.menu_fp_root"
sequence="70"
name="Controlled Goods (CGP)"
parent="fusion_plating.menu_fp_compliance_hub"
sequence="50"
groups="group_fusion_plating_cgp_officer"/>
<menuitem id="menu_fp_cgp_registration"

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating - Compliance (Framework)',
'version': '19.0.1.1.0',
'version': '19.0.1.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',

View File

@@ -50,7 +50,7 @@
<field name="description">Filter-press cake from hexavalent chrome waste treatment system.</field>
<field name="physical_state">liquid</field>
<field name="generation_rate">45.0</field>
<field name="generation_uom">kg/day</field>
<field name="generation_uom">kg_day</field>
<field name="disposal_method">Licensed hazardous waste facility</field>
</record>
@@ -62,7 +62,7 @@
<field name="description">Spent sulphuric and hydrochloric acid from pickling tanks.</field>
<field name="physical_state">liquid</field>
<field name="generation_rate">120.0</field>
<field name="generation_uom">L/day</field>
<field name="generation_uom">l_day</field>
<field name="disposal_method">Acid reclamation</field>
</record>
@@ -74,7 +74,7 @@
<field name="description">Sludge from black oxide line waste treatment.</field>
<field name="physical_state">sludge</field>
<field name="generation_rate">10.0</field>
<field name="generation_uom">kg/day</field>
<field name="generation_uom">kg_day</field>
<field name="disposal_method">Stabilisation and secure landfill</field>
</record>
@@ -95,7 +95,7 @@
<field name="waste_stream_id" ref="demo_waste_stream_spent_acid"/>
<field name="ship_date" eval="(DateTime.today()).strftime('%Y-%m-%d')"/>
<field name="quantity">800.0</field>
<field name="uom">L</field>
<field name="uom">l</field>
<field name="state">draft</field>
<field name="notes" type="html"><p>Pending carrier assignment for spent acid pickup.</p></field>
</record>
@@ -107,7 +107,7 @@
<field name="spill_date" eval="(DateTime.today() - timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="substance">Chromic Acid</field>
<field name="quantity">5.0</field>
<field name="uom">L</field>
<field name="uom">l</field>
<field name="location">Chrome line — tank overflow berm</field>
<field name="containment_action">Spill contained within secondary containment berm. Absorbent pads deployed. Area neutralised with soda ash.</field>
<field name="regulator_notified" eval="True"/>
@@ -121,7 +121,7 @@
<field name="spill_date" eval="(DateTime.today() - timedelta(days=45)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="substance">Nickel Sulphate Solution</field>
<field name="quantity">2.0</field>
<field name="uom">L</field>
<field name="uom">l</field>
<field name="location">East Annex — nickel line transfer pump</field>
<field name="containment_action">Minor drip from pump seal. Caught by drip tray, cleaned immediately.</field>
<field name="regulator_notified" eval="False"/>

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpDischargeLimit(models.Model):
_name = 'fusion.plating.discharge.limit'
@@ -18,8 +20,11 @@ class FpDischargeLimit(models.Model):
('combined', 'Combined Sewer'), ('air', 'Air Emission'), ('other', 'Other')],
string='Discharge Point', default='sanitary', required=True,
)
limit_value = fields.Float(string='Limit', digits=(16, 4))
uom = fields.Char(string='UoM')
limit_value = fields.Float(string='Limit', digits=(16, 4),
help='Numerical limit, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM',
help='Unit the limit is enforced in (typical: mg/L for liquid '
'discharge, mg/m³ for air emissions).')
limit_type = fields.Selection(
[('max', 'Maximum'), ('min', 'Minimum'), ('range', 'Range'), ('ceiling', 'Hard Ceiling')],
string='Limit Type', default='max', required=True,

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpDischargeSampleLine(models.Model):
_name = 'fusion.plating.discharge.sample.line'
@@ -12,8 +14,11 @@ class FpDischargeSampleLine(models.Model):
sample_id = fields.Many2one('fusion.plating.discharge.sample', string='Sample', required=True, ondelete='cascade')
limit_id = fields.Many2one('fusion.plating.discharge.limit', string='Limit', ondelete='restrict')
parameter = fields.Char(string='Parameter', related='limit_id.parameter', store=True, readonly=False)
value = fields.Float(string='Result', digits=(16, 4))
uom = fields.Char(string='UoM')
value = fields.Float(string='Result', digits=(16, 4),
help='Measured value, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM',
help='Unit of the measured value. Defaults to the limit\'s unit; '
'override only when the lab reported in a different unit.')
status = fields.Selection(
[('ok', 'OK'), ('warning', 'Warning'), ('out_of_spec', 'Out of Spec'), ('pending', 'Pending')],
string='Status', compute='_compute_status', store=True,

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpSpillRegister(models.Model):
_name = 'fusion.plating.spill.register'
@@ -16,8 +18,10 @@ class FpSpillRegister(models.Model):
spill_date = fields.Datetime(string='Spill Date', required=True, default=fields.Datetime.now, tracking=True)
reported_by_id = fields.Many2one('res.users', string='Reported By', default=lambda s: s.env.user)
substance = fields.Char(string='Substance', tracking=True)
quantity = fields.Float(string='Quantity', digits=(16, 3))
uom = fields.Char(string='UoM', default='L')
quantity = fields.Float(string='Quantity', digits=(16, 3),
help='Quantity spilled, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM', default='l',
help='Unit of the spill quantity (L for liquids, kg for solids).')
location = fields.Char(string='Location')
containment_action = fields.Text(string='Containment Action')
regulator_notified = fields.Boolean(string='Regulator Notified', tracking=True)

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpWasteManifest(models.Model):
_name = 'fusion.plating.waste.manifest'
@@ -15,8 +17,10 @@ class FpWasteManifest(models.Model):
facility_id = fields.Many2one('fusion.plating.facility', related='waste_stream_id.facility_id', store=True, readonly=True)
company_id = fields.Many2one('res.company', related='facility_id.company_id', store=True, readonly=True)
ship_date = fields.Date(string='Ship Date', default=fields.Date.context_today, tracking=True)
quantity = fields.Float(string='Quantity', digits=(16, 3))
uom = fields.Char(string='UoM', default='kg')
quantity = fields.Float(string='Quantity', digits=(16, 3),
help='Quantity shipped, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM', default='kg',
help='Unit of the shipped quantity (kg, L, m³, etc.).')
carrier_id = fields.Many2one('res.partner', string='Carrier', domain=[('is_company', '=', True)], tracking=True)
receiver_id = fields.Many2one('res.partner', string='Receiver', domain=[('is_company', '=', True)], tracking=True)
manifest_number = fields.Char(string='Manifest #', tracking=True)

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpWasteStream(models.Model):
_name = 'fusion.plating.waste.stream'
@@ -19,8 +21,12 @@ class FpWasteStream(models.Model):
[('liquid', 'Liquid'), ('solid', 'Solid'), ('sludge', 'Sludge'), ('gas', 'Gas')],
string='Physical State', default='liquid',
)
generation_rate = fields.Float(string='Generation Rate')
generation_uom = fields.Char(string='Rate UoM', default='kg/day')
generation_rate = fields.Float(string='Generation Rate',
help='Average rate this stream is produced at, expressed in the '
'rate unit below (typical: kg/day, L/day).')
generation_uom = fields.Selection(FP_UOM_SELECTION, string='Rate UoM',
default='kg_day',
help='Unit of the generation rate (kg/day, L/day, kg/month, etc.).')
disposal_method = fields.Char(string='Disposal Method')
approved_carrier_id = fields.Many2one('res.partner', string='Approved Carrier', domain=[('is_company', '=', True)])
approved_facility_id = fields.Many2one('res.partner', string='Approved Receiving Facility', domain=[('is_company', '=', True)])

View File

@@ -43,7 +43,8 @@
<field name="regulator_id"/>
</group>
</group>
<group string="Notes"><field name="notes" nolabel="1"/></group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
<chatter/>
</form>

View File

@@ -45,7 +45,8 @@
</group>
</group>
<group><field name="reference_url" widget="url"/></group>
<group string="Notes"><field name="notes" nolabel="1"/></group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
</form>
</field>

View File

@@ -48,9 +48,8 @@
</list>
</field>
</group>
<group string="Compliance Notes">
<field name="x_fp_compliance_notes" nolabel="1"/>
</group>
<separator string="Compliance Notes"/>
<field name="x_fp_compliance_notes"/>
</page>
</xpath>
</field>

View File

@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_fp_compliance_root" name="Compliance" parent="fusion_plating.menu_fp_root" sequence="40"/>
<!-- Phase 1 — re-parented under fusion_plating.menu_fp_compliance_hub
and renamed to 'General' since the hub is now the top-level Compliance. -->
<menuitem id="menu_fp_compliance_root" name="General"
parent="fusion_plating.menu_fp_compliance_hub" sequence="10"/>
<menuitem id="menu_fp_compliance_permit" name="Permits" parent="menu_fp_compliance_root" action="action_fp_permit" sequence="10"/>
<menuitem id="menu_fp_compliance_discharge_sample" name="Discharge Samples" parent="menu_fp_compliance_root" action="action_fp_discharge_sample" sequence="20"/>

View File

@@ -72,7 +72,8 @@
<field name="owner_id"/>
<field name="status"/>
</group>
<group string="Description"><field name="description" nolabel="1"/></group>
<separator string="Description"/>
<field name="description"/>
</sheet>
</form>
</field>

View File

@@ -50,7 +50,8 @@
<field name="transferred_kg"/>
</group>
</group>
<group string="Notes"><field name="notes" nolabel="1"/></group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
</form>
</field>

View File

@@ -35,7 +35,8 @@
<field name="active" widget="boolean_toggle"/>
</group>
</group>
<group string="Contact"><field name="contact_info" nolabel="1"/></group>
<separator string="Contact"/>
<field name="contact_info"/>
</sheet>
</form>
</field>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.17.13.0',
'version': '19.0.18.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -50,11 +50,13 @@ Provides:
'views/sale_order_views.xml',
'views/res_partner_views.xml',
'views/fp_sale_description_template_views.xml',
'views/fp_serial_views.xml',
'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_add_from_so_wizard_views.xml',
'wizard/fp_add_from_quote_wizard_views.xml',
'wizard/fp_quote_promote_wizard_views.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
'wizard/fp_serial_bulk_add_wizard_views.xml',
'views/fp_configurator_menu.xml',
'data/fp_sale_description_template_data.xml',
],

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 1 multi-serial — backfill the new M2M relations from the
# pre-existing single-M2O column on sale.order.line and account.move.line.
#
# x_fc_serial_id was historically a stored Many2one. Phase 1 made it a
# computed alias of `x_fc_serial_ids` (the new M2M). Existing rows have
# the old FK column populated but no rows in the M2M relation table.
# This migration walks the legacy column and inserts one M2M row per
# (line, serial) pair so smart buttons / reverse links continue to find
# the linked records.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Backfill fp_sale_order_line_serial_rel + fp_account_move_line_serial_rel."""
backfill_table(cr, 'sale_order_line', 'fp_sale_order_line_serial_rel', 'line_id')
backfill_table(cr, 'account_move_line', 'fp_account_move_line_serial_rel', 'line_id')
def backfill_table(cr, source_table, m2m_table, line_col):
cr.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = %s AND column_name = 'x_fc_serial_id'",
(source_table,),
)
if not cr.fetchone():
_logger.info("Phase 1 multi-serial: %s has no x_fc_serial_id column, skip", source_table)
return
# Make sure the M2M table exists (Odoo creates it on registry load,
# but the migration runs BEFORE the registry comes up on upgrade —
# use IF NOT EXISTS to be safe).
cr.execute(
f"""
CREATE TABLE IF NOT EXISTS "{m2m_table}" (
"{line_col}" integer NOT NULL REFERENCES "{source_table}"(id) ON DELETE CASCADE,
"serial_id" integer NOT NULL REFERENCES "fp_serial"(id) ON DELETE CASCADE,
PRIMARY KEY ("{line_col}", "serial_id")
)
"""
)
cr.execute(
f"""
INSERT INTO "{m2m_table}" ("{line_col}", "serial_id")
SELECT id, x_fc_serial_id FROM "{source_table}"
WHERE x_fc_serial_id IS NOT NULL
ON CONFLICT DO NOTHING
"""
)
_logger.info(
"Phase 1 multi-serial: backfilled %s rows from %s.x_fc_serial_id into %s",
cr.rowcount, source_table, m2m_table,
)

View File

@@ -19,12 +19,23 @@ class AccountMoveLine(models.Model):
help="Copied from sale.order.line on invoice creation so customer-"
"facing invoice PDFs can render the customer's part number.",
)
# ---- Sub 5 ---------------------------------------------------------------
# ---- Sub 5 / Phase 1 multi-serial ---------------------------------------
x_fc_serial_ids = fields.Many2many(
'fp.serial',
relation='fp_account_move_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
help='Copied from sale.order.line for traceability. Multi-serial '
'support added 2026-04-28.',
)
x_fc_serial_id = fields.Many2one(
'fp.serial',
string='Serial Number',
index=True,
help='Copied from sale.order.line for traceability.',
help='Back-compat alias of the first serial in x_fc_serial_ids. '
'Kept so legacy invoice templates that read the singular '
'continue to render.',
)
x_fc_job_number = fields.Char(
string='Job #', index=True,

View File

@@ -444,6 +444,33 @@ class FpPartCatalog(models.Model):
'target': 'current',
}
def action_open_default_simple_editor(self):
"""Open the Simple Recipe Editor for this part's default variant.
One-click path that skips the Composer's variants list — useful
when the part only has one variant and the user wants to dive
straight into editing.
"""
self.ensure_one()
if not self.default_process_id:
from odoo.exceptions import UserError
raise UserError(_(
'No default process variant for %s yet. Click Compose to '
'create the first variant.'
) % (self.display_name or self.part_number))
return self.default_process_id.action_open_simple_editor()
def action_open_default_tree_editor(self):
"""Open the Tree Editor for this part's default variant."""
self.ensure_one()
if not self.default_process_id:
from odoo.exceptions import UserError
raise UserError(_(
'No default process variant for %s yet. Click Compose to '
'create the first variant.'
) % (self.display_name or self.part_number))
return self.default_process_id.action_open_tree_editor()
def action_set_default_variant(self, variant_id):
"""Flip the default variant for this part.

View File

@@ -58,6 +58,170 @@ class FpSerial(models.Model):
)
notes = fields.Text(string='Notes')
# ==================================================================
# Phase 2 (2026-04-28) — per-serial state machine
# ==================================================================
# Each physical part owns its own state independent of the parent
# job's qty roll-ups. When 30 parts arrive on one SO line, all 30
# serials are independently trackable through the shop. State
# auto-promotes from job-step transitions (see fp.job.button_*
# overrides in fusion_plating_jobs); operator can also flip a
# single serial manually (e.g. mark serial #5 scrapped after a
# plating defect).
state = fields.Selection(
[
('received', 'Received'),
('racked', 'Racked'),
('in_process', 'In Process'),
('inspected', 'Inspected'),
('packed', 'Packed'),
('shipped', 'Shipped'),
('returned', 'Returned'),
('scrapped', 'Scrapped'),
('on_hold', 'On Hold'),
],
string='Status',
default='received',
required=True,
tracking=True,
index=True,
help='Per-serial workflow state. Transitions auto-promote from '
'parent job step events; supervisors can also flip a single '
'serial manually (e.g. scrap one part out of a 30-part rack).',
)
state_color = fields.Integer(
string='Status Color',
compute='_compute_state_color',
help='Kanban / many2many_tags color index derived from state.',
)
last_state_change = fields.Datetime(
string='Last Status Change',
readonly=True,
help='Timestamp of the most recent state transition. Auto-stamped '
'by every state-changing action.',
)
scrap_reason = fields.Text(
string='Scrap / Return Reason',
help='Captured when state transitions to scrapped or returned. '
'Surfaces on per-serial CoC entries (Phase 4).',
)
# Reverse from move log — Phase 3 will populate this directly when
# operators record per-serial moves on the tablet. Defined here so
# views can already render the count column.
move_count = fields.Integer(
compute='_compute_move_count',
string='# Moves',
)
@api.depends('state')
def _compute_state_color(self):
# Odoo color-index mapping aligned with the standard kanban palette.
# 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
# 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
mapping = {
'received': 8, # blue — fresh
'racked': 7, # sky — staged
'in_process': 3, # yellow — running
'inspected': 11, # olive — passed QC, ready to ship
'packed': 4, # green — boxed
'shipped': 4, # green — out the door
'returned': 2, # orange — back from customer
'scrapped': 1, # red
'on_hold': 1, # red — quality issue
}
for rec in self:
rec.state_color = mapping.get(rec.state, 0)
@api.depends_context('uid')
def _compute_move_count(self):
# Phase 3 will replace this with a real reverse link via
# fp.job.step.move.serial_ids (M2M added next phase).
# Defined here as 0-stub so views don't break on upgrade.
for rec in self:
rec.move_count = 0
# ------------------------------------------------------------------
# State transitions — log each one to chatter and stamp last_state_change
# ------------------------------------------------------------------
def _set_state(self, new_state, message=None):
"""Internal helper. Validates the source state, flips, stamps,
chatters. Raises UserError on illegal transitions."""
labels = dict(self._fields['state'].selection)
for rec in self:
old = rec.state
if old == new_state:
continue
# Terminal states are write-protected (operator must explicitly
# un-set via action_reopen if they really need to).
if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
from odoo.exceptions import UserError
raise UserError(_(
'Serial %(name)s is %(old)s — cannot transition to '
'%(new)s. Use Reopen if this is a correction.'
) % {
'name': rec.name,
'old': labels.get(old, old),
'new': labels.get(new_state, new_state),
})
rec.state = new_state
rec.last_state_change = fields.Datetime.now()
body = message or _('Status %(old)s%(new)s by %(user)s') % {
'old': labels.get(old, old),
'new': labels.get(new_state, new_state),
'user': self.env.user.name,
}
rec.message_post(body=body)
return True
def action_mark_racked(self):
return self._set_state('racked')
def action_mark_in_process(self):
return self._set_state('in_process')
def action_mark_inspected(self):
return self._set_state('inspected')
def action_mark_packed(self):
return self._set_state('packed')
def action_mark_shipped(self):
return self._set_state('shipped')
def action_mark_returned(self):
return self._set_state('returned')
def action_mark_on_hold(self):
return self._set_state('on_hold')
def action_release_hold(self):
"""Lift on_hold and return the serial to in_process. Used when a
hold is resolved without scrap (e.g. visual blemish was actually
within tolerance after re-inspection)."""
return self._set_state('in_process')
def action_mark_scrapped(self):
"""Scrap a single serial. Operator should fill scrap_reason next
— view enforces it via a wizard form. Phase 3 hooks this into
the move log so the parent job's qty_scrapped auto-increments."""
return self._set_state('scrapped')
def action_reopen(self):
"""Manager-only override — un-pin a terminal state when a
correction is needed (e.g. wrong serial marked shipped). Audit
trail preserved via chatter; never silently rewrites history."""
for rec in self:
if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'):
from odoo.exceptions import UserError
raise UserError(_(
'Only the Plating Manager group can reopen a terminal '
'serial state. Contact your shop manager.'
))
return self._set_state('in_process', message=_(
'Serial reopened by %s — terminal state reverted for correction.'
) % self.env.user.name)
# Reverse link to invoice lines — safe here because account.move.line
# lives in this same module. Production (mrp) and delivery (logistics)
# reverse links are defined in their own modules' fp_serial inherits

View File

@@ -3,7 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class SaleOrderLine(models.Model):
@@ -60,18 +61,29 @@ class SaleOrderLine(models.Model):
string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.',
)
# Sub 9 — process variant override per line. NULL means "use the
# part's default variant". Domain restricts to root recipe nodes
# owned by the chosen part.
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
# now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template
# marked is_template. Cross-part picks auto-clone onto this part on
# save (see _onchange_process_variant_clone) so per-line edits never
# bleed across customers.
x_fc_process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('part_catalog_id', '=', x_fc_part_catalog_id), "
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
help='Pick a specific process variant for this order. Leave blank '
'to use the part\'s default variant. Variants are managed via '
'the Process Composer on the part form.',
help='Pick any recipe — the part\'s own variant, another part\'s '
'recipe, or a template from the library. If the chosen recipe '
'doesn\'t belong to this part, it will be cloned onto the part '
'when the order saves so per-line edits stay scoped. Use the '
'Customize button on the line to open the Process Composer.',
)
x_fc_save_as_default_process = fields.Boolean(
string='Save as Default for Part',
default=False,
help='When ticked, the chosen process variant becomes this part\'s '
'default on order save — future orders for the same part '
'pre-fill with this variant.',
)
x_fc_archived = fields.Boolean(
string='Archived',
@@ -84,15 +96,61 @@ class SaleOrderLine(models.Model):
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on
# inherited fields — Odoo emits a warning and ignores it. Audit trail
# for these values lives on fp.serial.mail.thread instead.
#
# 2026-04-28 Phase 1 — multi-serial support. Customer can ship 30 parts
# with 30 distinct serials on a single line. The M2M is the source of
# truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
# serial so existing reports / smart buttons / downstream code that
# still reads the singular keep working unchanged.
x_fc_serial_ids = fields.Many2many(
'fp.serial',
relation='fp_sale_order_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
copy=False,
help='Customer-supplied serial numbers for the parts on this line. '
'Use the Bulk Add Serials button to paste a list, range-fill '
'(SN-001..SN-030), or scan barcodes. Count must not exceed '
'the line quantity.',
)
x_fc_serial_id = fields.Many2one(
'fp.serial',
string='Serial Number',
ondelete='set null',
string='Primary Serial',
compute='_compute_primary_serial',
inverse='_inverse_primary_serial',
search='_search_primary_serial',
store=False,
copy=False,
help='Customer-supplied serial number for this line. Optional. '
'Typing a value offers to create a new fp.serial record on '
'the fly; use the Generate Serial button to auto-sequence.',
help='First of the line\'s serials — back-compat alias kept so '
'pre-Phase-1 code (reports, smart buttons, downstream M2M '
'reverse links) keeps working. Setting this prepends the '
'serial to the M2M.',
)
x_fc_serial_count = fields.Integer(
string='# Serials',
compute='_compute_serial_count',
)
@api.depends('x_fc_serial_ids')
def _compute_primary_serial(self):
for line in self:
line.x_fc_serial_id = line.x_fc_serial_ids[:1]
def _inverse_primary_serial(self):
for line in self:
if not line.x_fc_serial_id:
continue
if line.x_fc_serial_id not in line.x_fc_serial_ids:
line.x_fc_serial_ids = [(4, line.x_fc_serial_id.id)]
def _search_primary_serial(self, operator, value):
return [('x_fc_serial_ids', operator, value)]
@api.depends('x_fc_serial_ids')
def _compute_serial_count(self):
for line in self:
line.x_fc_serial_count = len(line.x_fc_serial_ids)
x_fc_job_number = fields.Char(
string='Job #',
copy=False,
@@ -140,6 +198,27 @@ class SaleOrderLine(models.Model):
if line.x_fc_revision_pick_id:
line.x_fc_part_catalog_id = line.x_fc_revision_pick_id
def _fp_apply_recipe_polish(self):
"""Post-write step: auto-clone any cross-part recipe pick and
honour the Save-as-Default toggle.
Called from create() and write() so the polish runs on every
save path — onchange alone doesn't cover programmatic creates
(the direct-order wizard, imports, the sale_mrp bridge, etc.).
"""
for line in self:
if not line.x_fc_part_catalog_id or not line.x_fc_process_variant_id:
continue
recipe = line.x_fc_process_variant_id
if (not recipe.part_catalog_id
or recipe.part_catalog_id.id != line.x_fc_part_catalog_id.id):
clone = line._fp_clone_recipe_to_part()
if clone and clone.id != recipe.id:
line.x_fc_process_variant_id = clone.id
recipe = clone
if line.x_fc_save_as_default_process and recipe.part_catalog_id:
line.x_fc_part_catalog_id.action_set_default_variant(recipe.id)
@api.model_create_multi
def create(self, vals_list):
"""Default `x_fc_internal_description` from `name` when a caller
@@ -175,7 +254,9 @@ class SaleOrderLine(models.Model):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part and part.revision:
vals['x_fc_revision_snapshot'] = part.revision
return super().create(vals_list)
lines = super().create(vals_list)
lines._fp_apply_recipe_polish()
return lines
def write(self, vals):
# Sub 5 — keep the revision snapshot in lockstep with the line's
@@ -190,7 +271,16 @@ class SaleOrderLine(models.Model):
for line in self:
if line.x_fc_part_catalog_id.id != new_part.id:
line.x_fc_revision_snapshot = new_part.revision
return super().write(vals)
result = super().write(vals)
# Only run the polish when something relevant actually changed —
# avoids re-running on every unrelated write (e.g. price updates).
if any(k in vals for k in (
'x_fc_process_variant_id',
'x_fc_part_catalog_id',
'x_fc_save_as_default_process',
)):
self._fp_apply_recipe_polish()
return result
@api.onchange('x_fc_description_template_id')
def _onchange_description_template(self):
@@ -229,7 +319,12 @@ class SaleOrderLine(models.Model):
vals = super()._prepare_invoice_line(**optional_values)
if self.x_fc_part_catalog_id:
vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id
if self.x_fc_serial_id:
if self.x_fc_serial_ids:
# Carry the full M2M to the invoice line. Back-compat alias
# x_fc_serial_id will still resolve to the first one if any
# downstream code only reads the singular.
vals['x_fc_serial_ids'] = [(6, 0, self.x_fc_serial_ids.ids)]
elif self.x_fc_serial_id:
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
if self.x_fc_job_number:
vals['x_fc_job_number'] = self.x_fc_job_number
@@ -241,13 +336,95 @@ class SaleOrderLine(models.Model):
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_variant(self):
"""Clear process variant when the part changes — domain would
otherwise leave a stale value pointing at the wrong part."""
"""When the part changes, pre-fill the variant from the part's
default_process_id (if set) so the line carries a sensible
starting point. The estimator can override after.
Previously cleared the variant entirely when the part changed
(because the variant picker was scoped to the part). Now that
the picker is system-wide, we instead pre-fill from the part's
default — much more useful.
"""
for line in self:
if (line.x_fc_process_variant_id
and line.x_fc_process_variant_id.part_catalog_id
!= line.x_fc_part_catalog_id):
line.x_fc_process_variant_id = False
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
def _fp_clone_recipe_to_part(self):
"""Deep-copy the picked recipe onto this line's part if it isn't
already scoped there. Returns the cloned (or unchanged) variant.
Edge cases handled:
* No recipe picked → no-op, return False.
* No part on the line → no-op (we need a part to scope the clone).
* Recipe already belongs to this part → no-op, return as-is.
* Recipe belongs to a different part / is a template / is unscoped
→ deep-copy via Odoo's standard recursive copy(), reparent the
clone onto this part, name-stamp it for traceability.
"""
self.ensure_one()
recipe = self.x_fc_process_variant_id
part = self.x_fc_part_catalog_id
if not recipe or not part:
return recipe
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
return recipe # already scoped — nothing to do
# Clone — Odoo's default copy() recurses through child_ids when the
# field has copy=True. fp.process.node sets that on its tree, so
# one call gets us a full sub-tree clone.
clone_name = recipe.name or _('Untitled Recipe')
# If the source carried a part scope, preface the clone name with
# the customer's part number for quick identification on the
# variant dropdown later.
if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
clone_name = '%s%s' % (clone_name, part.part_number or part.display_name)
clone = recipe.copy({
'name': clone_name,
'part_catalog_id': part.id,
'is_template': False, # never propagate template flag
'is_default_variant': False, # estimator opts in via toggle
})
return clone
def action_customize_process(self):
"""Open the Process Composer for this line's process variant.
Auto-clones first if the variant isn't yet scoped to this part —
the operator should never edit a recipe that's shared across
customers (their edits would bleed). After cloning, the line
ends up pointing at the fresh per-part copy.
"""
self.ensure_one()
if not self.x_fc_part_catalog_id:
from odoo.exceptions import UserError
raise UserError(_(
'Pick a part on this line before customizing the process — '
'the recipe needs a part to scope the variant.'
))
if not self.x_fc_process_variant_id:
from odoo.exceptions import UserError
raise UserError(_(
'Pick a process variant on this line first. To start from '
'scratch, use the part\'s Compose button instead.'
))
clone_or_existing = self._fp_clone_recipe_to_part()
if clone_or_existing.id != self.x_fc_process_variant_id.id:
self.x_fc_process_variant_id = clone_or_existing.id
return {
'type': 'ir.actions.client',
'tag': 'fp_part_process_composer',
'name': _('Customize Process — %s') % (
self.x_fc_part_catalog_id.display_name
or self.x_fc_part_catalog_id.part_number
or '?'
),
'params': {
'part_id': self.x_fc_part_catalog_id.id,
'part_display': self.x_fc_part_catalog_id.display_name
or self.x_fc_part_catalog_id.part_number,
'focus_variant_id': clone_or_existing.id,
},
'target': 'current',
}
@api.onchange('x_fc_coating_config_id')
def _onchange_coating_clears_thickness(self):
@@ -263,19 +440,55 @@ class SaleOrderLine(models.Model):
line.x_fc_thickness_id = False
def action_generate_serial(self):
"""Create a fresh fp.serial for this line using the shop sequence."""
"""Generate one new auto-sequenced serial and append it to the M2M.
Phase 1 polish: the legacy single-serial behaviour was "create one
serial and pin it to x_fc_serial_id". Now we append to the M2M so
repeated clicks add more serials (handy when the customer didn't
send any and the shop wants to assign N).
"""
self.ensure_one()
if self.x_fc_serial_id:
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.serial',
'res_id': self.x_fc_serial_id.id,
'view_mode': 'form',
}
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
serial = self.env['fp.serial'].create({
'name': seq,
'sale_order_line_id': self.id,
})
self.x_fc_serial_id = serial.id
self.x_fc_serial_ids = [(4, serial.id)]
return False
def action_open_serial_bulk_add(self):
"""Open the Bulk Add Serials wizard for this line."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.serial.bulk.add.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Bulk Add Serials'),
'context': {
'default_target_model': 'sale.order.line',
'default_target_id': self.id,
'default_qty_expected': int(self.product_uom_qty or 0),
},
}
@api.constrains('x_fc_serial_ids', 'product_uom_qty')
def _check_serial_count_against_qty(self):
"""Block save when the operator has attached more serials than
the line quantity. Under-count is allowed (some customers ship
with serials only on a subset of parts).
"""
for line in self:
if line.x_fc_serial_ids and line.product_uom_qty:
n = len(line.x_fc_serial_ids)
if n > int(line.product_uom_qty):
raise ValidationError(_(
'Line "%(part)s": %(n)s serials attached but only '
'%(qty)s parts ordered. Either reduce the serial '
'list, increase the quantity, or split the line.'
) % {
'part': (line.x_fc_part_catalog_id.display_name
or line.product_id.display_name or ''),
'n': n,
'qty': int(line.product_uom_qty),
})

View File

@@ -44,6 +44,8 @@ access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_
access_fp_serial_user,fp.serial.user,model_fp_serial,base.group_user,1,0,0,0
access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_fp_serial_user fp.serial.user model_fp_serial base.group_user 1 0 0 0
45 access_fp_serial_estimator fp.serial.estimator model_fp_serial fusion_plating_configurator.group_fp_estimator 1 1 1 0
46 access_fp_serial_manager fp.serial.manager model_fp_serial fusion_plating.group_fusion_plating_manager 1 1 1 1
47 access_fp_serial_bulk_add_estimator fp.serial.bulk.add.estimator model_fp_serial_bulk_add_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
48 access_fp_serial_bulk_add_manager fp.serial.bulk.add.manager model_fp_serial_bulk_add_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
49 access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
50 access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
51 access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -188,6 +188,7 @@ export class FpPartProcessComposer extends Component {
}
openRecipeEditor(rootId) {
// Tree editor — the original drag-and-drop hierarchy view.
const id = rootId || this.state.rootId;
if (!id) return;
this.action.doAction({
@@ -199,6 +200,22 @@ export class FpPartProcessComposer extends Component {
});
}
openRecipeSimpleEditor(rootId) {
// Simple Recipe Editor (Sub 12a) — flat 2-pane drag-drop layout.
// Lives alongside the tree editor; the user picks per-variant
// which one to open. Both edit the same underlying tree, so
// changes flow back-and-forth without conflict.
const id = rootId || this.state.rootId;
if (!id) return;
this.action.doAction({
type: "ir.actions.client",
tag: "fp_simple_recipe_editor",
name: `Process Editor (Simple) — ${(this.state.part && this.state.part.display) || ""}`,
context: { recipe_id: id, part_id: this.partId },
target: "current",
});
}
backToPart() {
this.action.doAction({
type: "ir.actions.act_window",

View File

@@ -83,8 +83,15 @@
<td class="text-end">
<button class="btn btn-sm btn-primary me-1"
t-att-disabled="state.busy"
t-on-click="() => this.openRecipeEditor(v.id)">
<i class="fa fa-pencil"/> Edit
t-on-click="() => this.openRecipeEditor(v.id)"
title="Open the tree editor (drag-and-drop hierarchy view)">
<i class="fa fa-pencil"/> Tree
</button>
<button class="btn btn-sm btn-info me-1"
t-att-disabled="state.busy"
t-on-click="() => this.openRecipeSimpleEditor(v.id)"
title="Open the Simple Recipe Editor (flat 2-pane drag-drop)">
<i class="fa fa-list-alt"/> Simple
</button>
<button class="btn btn-sm btn-secondary me-1"
t-att-disabled="state.busy"

Some files were not shown because too many files have changed in this diff Show More