Compare commits
50 Commits
66cfe5f97f
...
6ac6d24da6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ac6d24da6 | ||
|
|
2a9fd478f5 | ||
|
|
13e300d90e | ||
|
|
2d42b33d68 | ||
|
|
afcd128f83 | ||
|
|
5f6c7af2a7 | ||
|
|
46715410a9 | ||
|
|
62c1315997 | ||
|
|
3641b78a66 | ||
|
|
0ad382e1a6 | ||
|
|
3098fcfaf9 | ||
|
|
7d3b8f132a | ||
|
|
504c8f34db | ||
|
|
9d88c25136 | ||
|
|
12fcd11016 | ||
|
|
f55193fb1b | ||
|
|
34528a5d3d | ||
|
|
e718a47e3e | ||
|
|
11dbbf578e | ||
|
|
902f3e8398 | ||
|
|
11bc0ca742 | ||
|
|
270f427d7f | ||
|
|
48c06c40c9 | ||
|
|
6d046f2881 | ||
|
|
a521b7c37b | ||
|
|
3bed76aea4 | ||
|
|
dcd6df71c0 | ||
|
|
0794f7e3c9 | ||
|
|
4187842d30 | ||
|
|
d9ae45ce9b | ||
|
|
86c0e230a1 | ||
|
|
d78ef4228e | ||
|
|
25b429f253 | ||
|
|
5494684181 | ||
|
|
d6cdae30ec | ||
|
|
a892a7b20e | ||
|
|
194d5d96dd | ||
|
|
33ddec926c | ||
|
|
0862e55de6 | ||
|
|
738f3fcfd5 | ||
|
|
6fbb6f918b | ||
|
|
95debabc28 | ||
|
|
91681d722e | ||
|
|
7a0e74c456 | ||
|
|
8bcd537737 | ||
|
|
bef812616b | ||
|
|
7e98b48c01 | ||
|
|
cfe776be4c | ||
|
|
c75b22aaf7 | ||
|
|
4e4ca2c9da |
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 13–18 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
@@ -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"/>
|
||||
·
|
||||
<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">
|
||||
· <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','>=',(context_today() ).strftime('%Y-%m-%d 00:00:00'))]"/>
|
||||
<filter string="This Week" name="this_week"
|
||||
domain="[('date_started','>=',(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`).**
|
||||
@@ -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 7–8) |
|
||||
|
||||
**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 4–8
|
||||
|
||||
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 9–13
|
||||
|
||||
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 14–15
|
||||
|
||||
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 6–8 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 16–18
|
||||
|
||||
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 19–22 — 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 4–5 — 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 19–22
|
||||
|
||||
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 23–24
|
||||
|
||||
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.
|
||||
@@ -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 (Q1–Q8 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 3–4 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 3–4 days.**
|
||||
|
||||
Customer outcome:
|
||||
- **Operator Traveller PDF**: recipe-order, paper-style A4 landscape. Equivalent to Amphenol paper sheets (screens 16–18).
|
||||
- **Customer CoC Traveller PDF**: chronological audit, branded, Nadcap stamped. Equivalent to Steelhead's CoC output (screens 19–24).
|
||||
- **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 19–24):
|
||||
- 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 (~5–10 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 S1–S17 + 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 1–10 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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import recipe_controller
|
||||
from . import simple_recipe_controller
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
49
fusion_plating/fusion_plating/data/fp_landing_data.xml
Normal file
49
fusion_plating/fusion_plating/data/fp_landing_data.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
418
fusion_plating/fusion_plating/models/_fp_uom_selection.py
Normal file
418
fusion_plating/fusion_plating/models/_fp_uom_selection.py
Normal 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', 'm³'),
|
||||
('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', 'm²'),
|
||||
('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',
|
||||
'm³': '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',
|
||||
'm²': '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
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
104
fusion_plating/fusion_plating/models/fp_job_step_move.py
Normal file
104
fusion_plating/fusion_plating/models/fp_job_step_move.py
Normal 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')
|
||||
@@ -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
|
||||
|
||||
61
fusion_plating/fusion_plating/models/fp_landing.py
Normal file
61
fusion_plating/fusion_plating/models/fp_landing.py
Normal 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.',
|
||||
)
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
33
fusion_plating/fusion_plating/models/fp_rack_tag.py
Normal file
33
fusion_plating/fusion_plating/models/fp_rack_tag.py
Normal 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.'),
|
||||
]
|
||||
233
fusion_plating/fusion_plating/models/fp_step_template.py
Normal file
233
fusion_plating/fusion_plating/models/fp_step_template.py
Normal 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,
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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.')
|
||||
@@ -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')
|
||||
|
||||
216
fusion_plating/fusion_plating/models/fp_tank_composition.py
Normal file
216
fusion_plating/fusion_plating/models/fp_tank_composition.py
Normal 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()
|
||||
69
fusion_plating/fusion_plating/models/fp_tank_section.py
Normal file
69
fusion_plating/fusion_plating/models/fp_tank_section.py
Normal 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},
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
128
fusion_plating/fusion_plating/views/fp_job_step_move_views.xml
Normal file
128
fusion_plating/fusion_plating/views/fp_job_step_move_views.xml
Normal 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','>=', (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>
|
||||
@@ -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','>=',(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>
|
||||
@@ -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>
|
||||
|
||||
82
fusion_plating/fusion_plating/views/fp_landing_views.xml
Normal file
82
fusion_plating/fusion_plating/views/fp_landing_views.xml
Normal 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>
|
||||
@@ -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 & Steps"
|
||||
parent="menu_fp_config"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_fp_config_materials_tanks"
|
||||
name="Materials & 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 & Documents"
|
||||
parent="menu_fp_config"
|
||||
sequence="50"/>
|
||||
|
||||
<menuitem id="menu_fp_config_pricing_billing"
|
||||
name="Pricing & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
fusion_plating/fusion_plating/views/fp_rack_tag_views.xml
Normal file
34
fusion_plating/fusion_plating/views/fp_rack_tag_views.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
144
fusion_plating/fusion_plating/views/fp_step_template_views.xml
Normal file
144
fusion_plating/fusion_plating/views/fp_step_template_views.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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.">
|
||||
|
||||
@@ -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, '
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user