This commit is contained in:
gsinghpal
2026-04-26 15:05:17 -04:00
parent 160198edb1
commit d9f58b9851
110 changed files with 6210 additions and 1182 deletions

View File

@@ -374,8 +374,13 @@ rewrite code as new requirements surface. Each sub-project has its own design do
| 6 | Contact Profiles & Communication Routing (per-contact flags + per-location routing + global contact; single resolver helper) | **Shipped 2026-04-22** | client transcript A/B/C | | 6 | Contact Profiles & Communication Routing (per-contact flags + per-location routing + global contact; single resolver helper) | **Shipped 2026-04-22** | client transcript A/B/C |
| 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D | | 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D |
| 8 | Receiving / Inspection / QC flow restructure (fp.receiving = box count only; new fp.racking.inspection per MO; WO soft gate; delivery box-parity warning) | **Shipped 2026-04-22** | client transcript E | | 8 | Receiving / Inspection / QC flow restructure (fp.receiving = box count only; new fp.racking.inspection per MO; WO soft gate; delivery box-parity warning) | **Shipped 2026-04-22** | client transcript E |
| 9 | Process variants per part + persistent draft order wizard + tax per line + payment terms wired + chatter + nicer breadcrumbs across plating models | **Shipped 2026-04-26** | various wizard/UX |
| 10 | Quote → Direct Order promotion (won quotes consolidate onto a single PO instead of spawning standalone 1-line SOs) | **Shipped 2026-04-26** | redundancy concern |
| 11 | **MRP cutout — bridge_mrp deletion + MRP module uninstall** (7-phase migration: relocate models, swap inherits, drop legacy FK columns, uninstall mrp + 10 cascade modules) | **Shipped 2026-04-26** | bridge_mrp removal |
| 12 | **Native Quality — full Odoo `quality_control` replacement + RMA + integration polish** | **In flight** (planned) | quality dependency removal |
| ∞ | First-off / last-off QC | Deferred | client transcript F | | ∞ | First-off / last-off QC | Deferred | client transcript F |
| ∞ | VEC machine auto-ingest (Word-format thickness report from network-connected XRF; different machine from Fischerscope) | Deferred | client transcript G | | ∞ | VEC machine auto-ingest (Word-format thickness report from network-connected XRF; different machine from Fischerscope) | Deferred | client transcript G |
| ∞ | RMA customer portal submission | Deferred (Sub 12 phase 2) | follow-on to Sub 12 |
### Sub 2 Locked Decisions (2026-04-21) ### Sub 2 Locked Decisions (2026-04-21)
@@ -435,3 +440,274 @@ rewrite code as new requirements surface. Each sub-project has its own design do
3. Read the corresponding spec in `docs/superpowers/specs/YYYY-MM-DD-sub<N>-*-design.md`. 3. Read the corresponding spec in `docs/superpowers/specs/YYYY-MM-DD-sub<N>-*-design.md`.
4. Read the implementation plan if one exists. 4. Read the implementation plan if one exists.
5. Continue from the next un-checked step. 5. Continue from the next un-checked step.
---
## Sub 11 — MRP Cutout (shipped 2026-04-26)
The Odoo `mrp` module + 10 cascade dependents have been **uninstalled**. `fusion_plating_bridge_mrp` is gone. The plating shop runs entirely on `fp.job` / `fp.job.step`. Document this so a fresh session doesn't try to re-add MRP refs.
### Final state
- **0 rows** in `mrp_production`, `mrp_workorder`, `mrp_workcenter`
- **205+** `fp.job` rows, **1,800+** `fp.job.step` rows in production
- 0 custom-table FKs to MRP
- Modules uninstalled: `mrp`, `mrp_workorder`, `mrp_account`, `sale_mrp`, `purchase_mrp`, `quality_mrp`, `quality_mrp_workorder`, `project_mrp*`, `fusion_manufacturing`, `fusion_plating_bridge_mrp`
### Where things ended up after Sub 11
| Model / asset | Old home | New home |
|---|---|---|
| `fp.work.role`, `fp.operator.proficiency`, `hr.employee` shop-roles, `fusion.plating.process.node.x_fc_work_role_id` | `fusion_plating_bridge_mrp` | `fusion_plating` (core) |
| `fp.qc.checklist.template` (+line) | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
| `fusion.plating.quality.check` (+line) | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
| `fp.thickness.reading.quality_check_id` link + `auto_extracted` | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
| `res.partner.x_fc_requires_qc` + `x_fc_qc_template_id` | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
| `fp.job.consumption` | `fusion_plating_bridge_mrp` | `fusion_plating_jobs` |
| `sale.order.x_fc_workflow_stage` + `x_fc_assigned_manager_id` + workflow buttons | `fusion_plating_bridge_mrp` | `fusion_plating_jobs` |
| QC tablet OWL (`fp_qc_checklist.js/.xml/.scss`) + `/fp/qc/*` controller | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
| Production Priorities kanban | `fusion_plating_bridge_mrp` (mrp.workorder) | `fusion_plating_jobs` (fp.job.step) |
### Hard rules going forward
1. **Never re-introduce `'mrp'` as a manifest dep.** Use `fp.job` for jobs, `fp.job.step` for operations.
2. **`x_fc_job_id` is the canonical job link**, not `production_id`. Drop legacy MO refs as you find them.
3. **`fusion_plating_quality` depends on `fusion_plating_shopfloor`** for SCSS tokens (`$fp-page`, `$fp-card`, `$fp-accent`). Don't strip that dep — the QC tablet bundle breaks without it.
4. **The QC tablet OWL template namespace is `fusion_plating_quality.FpQcChecklist`** (was `fusion_plating_bridge_mrp.FpQcChecklist`). Don't rename back.
---
## Sub 12 — Native Quality Module (in flight, ~4 working days)
**Goal**: Build a complete native quality stack matching Odoo `quality_control` functionality plus plating-specific extensions (RMA, CAPA effectiveness, holds, 8D reports), with **zero dependency** on Odoo's `quality` / `quality_control`. After Sub 12 lands, those modules + `fusion_plating_bridge_quality` get uninstalled.
### Module choice
**Enrich `fusion_plating_quality`** — no new modules. Existing module already owns NCR / CAPA / Hold / Check / Calibration / AVL / FAIR / Audit / Doc Control / Customer Spec / Contract Review.
### Locked decisions (don't re-ask in fresh session)
| Q | Decision |
|---|---|
| RMA portal submission | **Deferred to phase 2.** Internal-only RMA in Sub 12. |
| 8D format | **Full 8D** (D1D8 sections in the combined NCR + CAPA PDF). |
| Quality Dashboard | **5 tabs** (Holds / Checks / NCRs / CAPAs / RMAs) in one client action with a summary header that totals open + overdue across all five. |
| Auto-NCR + auto-Hold on RMA receive | **Automatic**, with a manager-only "skip this RMA's auto-spawn" toggle on the RMA record. |
| Auto-CAPA on NCR closure | **Automatic when severity in (high, critical)**, with a manager-only override on the NCR. |
| Quality team model | Build a dedicated `fp.quality.team` rather than reusing `res.groups`. Teams need their own kanban grouping + per-team escalation chains, which groups don't model well. |
| Stage model vs. state field on NCR | **Both.** Keep the existing `state` Selection (used by code paths). Add a parallel `stage_id` Many2one to `fp.quality.alert.stage` for the kanban draggable view. Computed bidirectional sync (stage ↔ state). |
| Trigger-based quality.point | Build a new `fp.quality.point` model. Trigger types: `manual`, `receiving_done`, `job_step_done`, `job_done`. Existing `fp.qc.checklist.template` STAYS — it's the *template* a point fires; the point is the *trigger rule*. |
| RMA back-link to original SO line | Required field. Always carry the original SO line so cert / part / coating context follows the return. |
| Module choice (one or many) | **Single module** — enrich `fusion_plating_quality`. |
### Phase A — RMA model (~1 day)
**File**: `fusion_plating_quality/models/fp_rma.py`
#### Model: `fusion.plating.rma`
| Field | Type | Notes |
|---|---|---|
| `name` | Char | Sequence `RMA/YYYY/NNNN` |
| `partner_id` | M2O `res.partner` | Required |
| `sale_order_id` | M2O `sale.order` | The original order being returned |
| `sale_order_line_ids` | M2M `sale.order.line` | Specific lines being returned (subset of the SO) |
| `original_job_ids` | M2O `fp.job` (compute from SO lines) | For navigation only |
| `state` | Selection | `draft / authorised / shipped_to_us / received / triaged / resolving / resolved / closed / cancelled` |
| `trigger_source` | Selection | `customer_complaint / qc_fail_post_ship / inspection_post_delivery / other` |
| `severity` | Selection | `low / medium / high / critical` |
| `complaint_description` | Html | What the customer reported |
| `triage_findings` | Html | What we found on inspection |
| `resolution_type` | Selection | `replace / rework / refund / scrap` |
| `resolution_notes` | Html | Free-form notes on the chosen path |
| `replacement_job_id` | M2O `fp.job` | When replace/rework — the new job created |
| `refund_invoice_id` | M2O `account.move` | When refund — the credit note |
| `inbound_receiving_id` | M2O `fp.receiving` | The receiving record auto-created when carrier delivers |
| `inbound_picking_id` | M2O `stock.picking` | Optional — if a stock.picking is also created |
| `linked_ncr_ids` | O2M `fusion.plating.ncr` (inverse `rma_id`) | NCRs spawned from this RMA |
| `linked_capa_ids` | O2M `fusion.plating.capa` (related via NCRs) | Read-only roll-up |
| `linked_hold_ids` | O2M `fusion.plating.quality.hold` (inverse `rma_id`) | Holds placed on returned parts |
| `qty_returned` | Integer | Total units customer is returning |
| `qty_received` | Integer | Counted on receipt |
| `customer_tracking` | Char | Customer's outbound tracking # |
| `our_tracking` | Char | Our return-to-shop tracking # |
| `carrier_id` | M2O `delivery.carrier` | Optional |
| `qr_code` | Binary (compute) | QR encoding `/fp/rma/<id>` for the authorisation PDF |
| `auto_spawn_ncr` | Boolean | Default True. Manager can toggle off before saving. |
| `auto_spawn_hold` | Boolean | Default True. |
| `tag_ids` | M2M `fp.quality.tag` | (Sub 12 Phase B) |
| `reason_id` | M2O `fp.quality.reason` | (Sub 12 Phase B) |
| `team_id` | M2O `fp.quality.team` | (Sub 12 Phase B) |
| `chatter` | mail.thread | mandatory |
#### Lifecycle hooks
- **`action_authorise`**: state `draft → authorised`. Generate the RMA authorisation PDF + email link/QR to customer (using `fp.notification.template` if installed; falls back to standard mail.template).
- **`action_mark_shipped_to_us`**: customer-driven; updates state when carrier scan logged.
- **On `fp.receiving` create with `rma_id` set**: state `→ received`. If `auto_spawn_ncr`, create an `fusion.plating.ncr` pre-filled (description, severity, customer, parent SO line). If `auto_spawn_hold`, create `fusion.plating.quality.hold` for the returned qty.
- **`action_triage_complete`**: state `→ triaged`. Requires `resolution_type` set.
- **`action_resolve`**: state `→ resolved`. Triggers resolution-specific actions:
- `replace` → spawn new `fp.job` cloned from original
- `rework` → spawn new `fp.job` referencing the returned units (linked to inbound `fp.receiving`)
- `refund` → open `account.move.refund` wizard, link result to `refund_invoice_id`
- `scrap` → create `fp.job.consumption` row tagged 'rma_scrap' + post chatter
- **`action_close`**: state `→ closed`. Locks editing.
- **`action_cancel`**: any state → `cancelled` (manager only).
#### Smart buttons
RMA form gets buttons to: original SO, original Jobs, inbound Receiving, replacement Job, refund Invoice, NCRs (count), CAPAs (count), Holds (count). Per-target button visibility based on resolution_type / state.
#### Sequence
Create `ir.sequence` `fp.rma` with prefix `RMA/%(year)s/`, padding 4. Data file `fp_rma_sequence.xml`.
#### Reports
`fusion_plating_reports/report/report_fp_rma_authorisation.xml` — single-page customer-facing PDF with QR code. Branded "EN Technologies".
### Phase B — Categorisation & kanban infra (~half day)
**Files**: `fusion_plating_quality/models/fp_quality_tag.py`, `fp_quality_reason.py`, `fp_quality_team.py`, `fp_quality_alert_stage.py`
#### `fp.quality.tag`
- `name` (Char, required, translate)
- `color` (Integer, kanban color)
- `active` (Boolean)
- Reused by NCR / CAPA / Hold / RMA / Check via `M2M tag_ids`
#### `fp.quality.reason`
- `name`, `description`, `category` (selection: `process / supplier / equipment / human / material / other`)
- Curated reason library so root-cause classification is consistent
#### `fp.quality.team`
- `name`, `lead_user_id` (M2O res.users), `member_ids` (M2M res.users)
- `escalation_user_id` (manager who gets notified on missed deadlines)
- Used by NCR / RMA — primary owner team
#### `fp.quality.alert.stage`
- `name`, `sequence`, `fold` (Boolean — collapsed-by-default in kanban)
- Default stages seeded: New / Investigating / Containment / Disposition / Awaiting Sign-off / Closed / Cancelled
- Add `stage_id = fields.Many2one('fp.quality.alert.stage')` to `fusion.plating.ncr` AND `fusion.plating.rma`. Map state ↔ stage_id via `_inverse_*` so legacy code paths keep working.
#### Apply tag/reason/team M2M/M2O fields to: NCR, CAPA, Hold, Check, RMA
Each model gets `tag_ids`, `reason_id`, `team_id`. NCR + RMA additionally get `stage_id`.
### Phase C — Trigger-based quality points (~half day)
**File**: `fusion_plating_quality/models/fp_quality_point.py`
#### `fp.quality.point`
- `name`, `active`, `description`
- `trigger_type` (Selection): `manual / receiving_done / job_confirmed / job_step_done / job_done / so_confirmed`
- Filters (any combination): `partner_ids` (M2M), `part_catalog_ids` (M2M), `coating_config_ids` (M2M), `step_kind` (Selection — wet/bake/inspect/etc.)
- `template_id` (M2O `fp.qc.checklist.template`) — required, the checks to spawn
- `assignee_user_id` (M2O `res.users`) — optional default inspector
- Fires `_spawn_check_for(<source_record>)` which creates a `fusion.plating.quality.check` from the template + binds it to the source via `job_id` or `step_id`.
#### Hooks (already partly in place — extend)
- `fp.receiving.write` hook (existing): when state flips to `closed`, walk all `fp.quality.point` with trigger `receiving_done` matching the receiving's partner/parts → spawn checks.
- `fp.job.action_confirm` hook (existing — currently calls `_fp_create_qc_check_if_needed`): replace with quality.point lookup. Keep the existing partner-template fallback as a default point seeded by `fp_qc_data.xml`.
- `fp.job.button_mark_done`: trigger `job_done` points.
- `fp.job.step.button_finish`: trigger `job_step_done` points.
### Phase D — Integration polish (~1 day)
1. **`fp.job` form smart-button row**: add `Holds`, `Checks`, `NCRs`, `CAPAs`, `RMAs` buttons with badge counts. Always-visible (zero is OK).
2. **`sale.order` form smart-button row**: same five, rolled up across all linked jobs.
3. **`res.partner` form**: customer-level "Quality History" smart button that opens a kanban filtered to that partner across all 5 record types.
4. **One-click cross-creation**:
- Hold form → `Open NCR` button — pre-fills NCR with hold's part / customer / quantity / linked job.
- NCR form → `Spawn CAPA` button — visible when state ∈ {disposition, closed} and severity ≥ medium.
- CAPA form → `Verify Effectiveness` button — schedules a follow-up check on the originating NCR.
5. **Unified Quality Dashboard** (`fp_quality_dashboard` client action):
- 5 tabs: Holds / Checks / NCRs / CAPAs / RMAs
- Each tab is a kanban grouped by `stage_id` (NCRs/RMAs) or `state` (Holds/Checks/CAPAs)
- Header summary card: open count + overdue count across all 5 types
- Filters: my team / my customer / overdue / high-severity
- Menu: Plating → Quality → Dashboard
6. **CAPA closure-loop linkage**: when CAPA effectiveness verification fails, auto-spawn a new NCR linked back to the original. Closes the loop "we said we fixed it but it happened again."
### Phase E — Reports (~half day)
**Files**: `fusion_plating_reports/report/report_fp_rma_authorisation.xml`, `report_fp_8d.xml`, `report_fp_quality_monthly.xml`
1. **RMA Authorisation PDF**: single-page customer-facing. Header with our logo + customer info, RMA number, parts listed (table), return-to address, QR code linking to `/fp/rma/<id>` for status tracking, carrier instructions.
2. **8D Report (NCR + CAPA combined)**:
- D1: Team (from `team_id` + member_ids)
- D2: Problem description (NCR description + scope)
- D3: Containment (NCR containment narrative)
- D4: Root cause analysis (CAPA root_cause + reason_id)
- D5: Permanent corrective action (CAPA action_plan)
- D6: Implement & verify (CAPA implementation_date + verification_evidence)
- D7: Prevent recurrence (CAPA preventive_actions)
- D8: Congratulate the team (CAPA closure notes + team sign-offs)
- Auto-renders when both NCR and CAPA exist; degraded mode if CAPA missing.
3. **Monthly Quality Summary** (`fp_quality_monthly` report):
- Counts by record type / severity / customer / month
- Overdue ageing buckets
- CAPA effectiveness rate (verified / total closed)
- Repeat-customer-issue flag (>2 NCRs same customer in 90 days)
- Run via cron monthly + on-demand from dashboard.
### Phase F — Test + verify (~half day)
End-to-end smoke flow on a fresh DB:
1. Customer reports issue → create RMA → authorise → email PDF
2. Customer ships → carrier delivers → `fp.receiving` auto-created → RMA receives → NCR + Hold auto-spawn
3. QA triages NCR → finds root cause → spawns CAPA (auto via severity rule)
4. CAPA assigned to engineering → action plan written → implemented → effectiveness check scheduled
5. Effectiveness verified → CAPA closes → NCR closes → RMA resolves (rework path) → replacement job created from original → ships → CoC issued → invoice
6. Run 8D report on the closed NCR/CAPA pair
7. Verify dashboard counts update at every state transition
8. Confirm legacy NCR/CAPA/Hold/Check forms still work (no regressions)
9. ACL drilldown: operator sees what they should, supervisor more, manager all
### Phase G — Drop Odoo quality cascade (~30 min)
Pre-conditions: Phases AF all merged + smoke-tested.
1. Strip the three custom fields from `fusion.plating.ncr` (`x_fc_quality_alert_id`, `x_fc_quality_alert_synced`, `x_fc_auto_sync` — added by bridge_quality)
2. Remove `fusion_plating_bridge_quality` from `/mnt/extra-addons/custom/`
3. SQL: `UPDATE ir_module_module SET state='to remove' WHERE name IN ('fusion_plating_bridge_quality', 'quality_control', 'quality') AND state='installed';`
4. Restart odoo → cascade uninstall fires
5. ALTER TABLE drop the three NCR columns
6. (Optional) move `/mnt/extra-addons/inventory_manufacturing/quality{,_control}/` out of the path so they can't auto-reinstall
### Server / deployment notes (entech)
- LXC 111 on pve-worker5, native odoo (apt), DB `admin`, addons path `/mnt/extra-addons/custom/`
- Update flow:
```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_quality --stop-after-init\" && systemctl start odoo'"
```
- File copy:
```bash
cat LOCAL | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > REMOTE'"
```
- Asset cache bust: `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';`
- Always bump module version in `__manifest__.py` for migrations to fire (current `fusion_plating_quality`: `19.0.3.0.0`; bump to `19.0.4.0.0` for Sub 12).
### Build order (executable checklist for fresh session)
1. Read this Sub 12 section in full + the Sub 11 section above (for context on what's already native).
2. Bump `fusion_plating_quality/__manifest__.py` version to `19.0.4.0.0`.
3. Phase A — RMA: create `fp_rma.py` model + `fp_rma_views.xml` + `fp_rma_sequence.xml` + ACL rows + add to `__manifest__.py` data list.
4. Phase A migration: not needed (new model, fresh table).
5. Phase B — categorisation: create the 4 small models + their views + ACL. Add `tag_ids / reason_id / team_id` M2M/M2O to NCR, CAPA, Hold, Check, RMA. Add `stage_id` to NCR + RMA.
6. Phase B data: seed default stages + a few starter tags/reasons/teams in `fp_quality_categorisation_data.xml`.
7. Phase C — `fp.quality.point` model + view + ACL + the 4 trigger hooks (in `fp.receiving`, `fp.job.action_confirm`, `fp.job.button_mark_done`, `fp.job.step.button_finish`).
8. Phase D — smart buttons on `fp.job`, `sale.order`, `res.partner`. Cross-creation buttons. Dashboard client action.
9. Phase E — three QWeb reports.
10. Phase F — manual smoke test + ACL drilldown + screenshot the dashboard.
11. Deploy each phase as it lands (don't batch — easier to roll back). Bump version each time.
12. Phase G runs LAST, only after confirmation that AF work end-to-end.
### Things to NOT do
- **Don't add `'quality'` or `'quality_control'` to any manifest dep.** They will be uninstalled by Phase G.
- **Don't import from `odoo.addons.quality.*`.** Use only native models.
- **Don't put RMA in a new module.** It belongs in `fusion_plating_quality`.
- **Don't break the existing QC tablet OWL.** Its template namespace is `fusion_plating_quality.FpQcChecklist`, endpoints are `/fp/qc/*`, and `fusion_plating_quality` depends on `fusion_plating_shopfloor` for SCSS tokens.
- **Don't re-introduce `production_id` references anywhere.** Use `job_id` / `x_fc_job_id`. MRP is gone.
- **Don't forget `rma_id` inverse field on NCR + Hold** — those One2many fields on RMA need an inverse Many2one on the linked model.
### Status check before starting (run this first in the fresh session)
```sql
-- Should show 4: NCR, CAPA, Hold, Check (Sub 12 adds RMA = 5)
SELECT model FROM ir_model WHERE model LIKE 'fusion.plating.%' AND model SIMILAR TO '%(ncr|capa|hold|check|rma)%';
-- Should show 'fusion_plating_quality_bridge_quality_control' state — likely 'installed' until Phase G
SELECT name, state FROM ir_module_module WHERE name LIKE 'quality%' OR name LIKE 'fusion_plating_bridge_quality';
-- Confirm MRP is gone (Sub 11)
SELECT name, state FROM ir_module_module WHERE name = 'mrp'; -- expect 'uninstalled'
-- Live row counts so you know what survives
SELECT 'ncr' AS m, count(*) FROM fusion_plating_ncr
UNION ALL SELECT 'capa', count(*) FROM fusion_plating_capa
UNION ALL SELECT 'hold', count(*) FROM fusion_plating_quality_hold
UNION ALL SELECT 'check', count(*) FROM fusion_plating_quality_check;
```

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.8.7.1', 'version': '19.0.9.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """
@@ -100,6 +100,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_job_views.xml', 'views/fp_job_views.xml',
'views/fp_job_step_views.xml', 'views/fp_job_step_views.xml',
'views/fp_jobs_menu.xml', 'views/fp_jobs_menu.xml',
'data/fp_work_role_data.xml',
'views/fp_work_role_views.xml',
'data/fp_recipe_enp_alum_basic.xml', 'data/fp_recipe_enp_alum_basic.xml',
'data/fp_recipe_enp_steel_basic.xml', 'data/fp_recipe_enp_steel_basic.xml',
'data/fp_recipe_enp_sp.xml', 'data/fp_recipe_enp_sp.xml',

View File

@@ -0,0 +1,76 @@
<?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.
Default shop roles. noupdate="1" so shops can rename/prune freely
without upgrades clobbering their changes.
-->
<odoo noupdate="1">
<record id="work_role_masking" model="fp.work.role">
<field name="name">Masking</field>
<field name="code">masking</field>
<field name="sequence">10</field>
<field name="icon">fa-scissors</field>
<field name="description">Applies masking tape/lacquer before plating and removes after.</field>
</record>
<record id="work_role_racking" model="fp.work.role">
<field name="name">Racking</field>
<field name="code">racking</field>
<field name="sequence">20</field>
<field name="icon">fa-cogs</field>
<field name="description">Fixtures parts onto racks/barrels for processing.</field>
</record>
<record id="work_role_plating" model="fp.work.role">
<field name="name">Plating Operator</field>
<field name="code">plating_op</field>
<field name="sequence">30</field>
<field name="icon">fa-flask</field>
<field name="description">Runs the plating line — chemistry checks, dwell, thickness.</field>
</record>
<record id="work_role_demask" model="fp.work.role">
<field name="name">De-Mask</field>
<field name="code">demask</field>
<field name="sequence">40</field>
<field name="icon">fa-scissors</field>
<field name="description">Removes masking material after plating.</field>
</record>
<record id="work_role_oven" model="fp.work.role">
<field name="name">Oven / Bake</field>
<field name="code">oven</field>
<field name="sequence">50</field>
<field name="icon">fa-fire</field>
<field name="description">Loads and operates embrittlement-relief ovens.</field>
</record>
<record id="work_role_derack" model="fp.work.role">
<field name="name">De-Rack</field>
<field name="code">derack</field>
<field name="sequence">60</field>
<field name="icon">fa-cogs</field>
<field name="description">Removes parts from racks/barrels for inspection.</field>
</record>
<record id="work_role_inspection" model="fp.work.role">
<field name="name">Inspection / QA</field>
<field name="code">inspection</field>
<field name="sequence">70</field>
<field name="icon">fa-search</field>
<field name="description">Post-plate inspection, Fischerscope, first-piece sign-off.</field>
</record>
<record id="work_role_rework" model="fp.work.role">
<field name="name">Rework</field>
<field name="code">rework</field>
<field name="sequence">80</field>
<field name="icon">fa-wrench</field>
<field name="description">Strips bad plating; routes parts back for re-processing.</field>
</record>
</odoo>

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
#
# Phase 1 (Sub 11) — relocate fp.work.role, fp.operator.proficiency,
# and the hr.employee shop-roles inherit from fusion_plating_bridge_mrp
# into fusion_plating core. Re-key all related ir.model.data so the
# new module owner picks up the existing records cleanly.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install — nothing to migrate
patterns = [
'model_fp_work_role',
'model_fp_operator_proficiency',
'access_fp_work_role_%',
'access_fp_proficiency_%',
'view_fp_work_role_%',
'action_fp_work_role%',
'menu_fp_work_role%',
'role_%', # data records seeded by fp_work_role_data.xml
]
for pat in patterns:
cr.execute(
"""
UPDATE ir_model_data
SET module = 'fusion_plating'
WHERE module = 'fusion_plating_bridge_mrp'
AND name LIKE %s
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d2
WHERE d2.module = 'fusion_plating'
AND d2.name = ir_model_data.name
)
""",
(pat,),
)
if cr.rowcount:
_logger.info(
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating",
cr.rowcount, pat,
)

View File

@@ -23,3 +23,12 @@ from . import fp_operator_certification
from . import fp_tz from . import fp_tz
from . import res_company from . import res_company
from . import res_config_settings from . import res_config_settings
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp via
# fusion_plating_jobs to core, so other downstream modules
# (fusion_plating_cgp, etc.) that touch hr.employee can see the
# shop-roles fields without a transitive dep on jobs.
from . import fp_work_role
from . import fp_proficiency
from . import hr_employee
from . import fp_process_node_inherit

View File

@@ -40,3 +40,15 @@ class FpJobStepTimeLog(models.Model):
log.duration_minutes = delta.total_seconds() / 60.0 log.duration_minutes = delta.total_seconds() / 60.0
else: else:
log.duration_minutes = 0.0 log.duration_minutes = 0.0
@api.depends('user_id', 'date_started', 'duration_minutes')
def _compute_display_name(self):
for log in self:
user = log.user_id.name or 'User'
when = log.date_started.strftime('%Y-%m-%d %H:%M') if log.date_started else ''
mins = ('%.0f min' % log.duration_minutes) if log.duration_minutes else 'open'
rec_bits = [user]
if when:
rec_bits.append(when)
rec_bits.append(mins)
log.display_name = ' · '.join(rec_bits)

View File

@@ -0,0 +1,23 @@
# -*- 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 FpProcessNode(models.Model):
"""Tag each recipe operation with the shop role that performs it.
The auto-assigner reads this when generating WOs: each WO inherits
its operation node's role, then hunts for an employee with a
matching x_fc_work_role_ids membership.
"""
_inherit = 'fusion.plating.process.node'
x_fc_work_role_id = fields.Many2one(
'fp.work.role', string='Performed By (Role)',
ondelete='set null',
help='Shop role that performs this step. When the WO is '
'generated it auto-routes to an employee with this role.',
)

View File

@@ -0,0 +1,163 @@
# -*- 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 (Sub 11) — relocated from fusion_plating_bridge_mrp. The model
# never had MRP fields; the bridge module was just its initial home.
from markupsafe import Markup
from odoo import _, api, fields, models
class FpOperatorProficiency(models.Model):
"""Operator proficiency tracker — counts successful step completions
per (employee, role) pair and auto-promotes the employee once the
role's mastery threshold is crossed.
The promotion mechanic lets managers casually train workers on the job:
they assign someone a task they've never done, the worker finishes it
successfully, and after N successes the role is added to the
employee's Shop Roles automatically. The operator never has to fill
in a form; their growing skill set just unlocks itself.
"""
_name = 'fp.operator.proficiency'
_description = 'Fusion Plating — Operator Task Proficiency'
_rec_name = 'display_name'
_order = 'employee_id, role_id'
employee_id = fields.Many2one(
'hr.employee', string='Operator',
required=True, ondelete='cascade', index=True,
)
role_id = fields.Many2one(
'fp.work.role', string='Role',
required=True, ondelete='cascade', index=True,
)
completed_count = fields.Integer(
string='Completions',
default=0,
help='Number of times this operator has successfully finished a '
'step that required this role.',
)
first_completed_at = fields.Datetime(
string='First Success',
help='When the operator finished their first step for this role.',
)
last_completed_at = fields.Datetime(
string='Last Success',
help='Most recent step completion against this role.',
)
promoted = fields.Boolean(
string='Promoted',
default=False,
index=True,
help='True once the role has been added to the operator\'s Shop '
'Roles automatically. Stays True even if a manager removes '
'the role afterwards — the count and promotion history are '
'preserved as a training record.',
)
promoted_at = fields.Datetime(
string='Promoted On',
help='When the auto-promotion fired (count crossed the role\'s '
'mastery threshold).',
)
display_name = fields.Char(
compute='_compute_display_name', store=True,
)
progress_label = fields.Char(
compute='_compute_progress_label',
help='"3 / 5" style indicator of how close this operator is to '
'mastery.',
)
_sql_constraints = [
('fp_proficiency_uniq',
'unique(employee_id, role_id)',
'There is already a proficiency record for this operator and role.'),
]
@api.depends('employee_id.name', 'role_id.name')
def _compute_display_name(self):
for rec in self:
rec.display_name = (
f'{rec.employee_id.name or "?"}{rec.role_id.name or "?"}'
)
@api.depends('completed_count', 'role_id.mastery_required')
def _compute_progress_label(self):
for rec in self:
target = rec.role_id.mastery_required or 0
rec.progress_label = (
f'{rec.completed_count} / {target}' if target
else str(rec.completed_count)
)
@api.model
def _record_completion(self, employee, role):
"""Increment the (employee, role) tally and promote if at threshold.
Idempotent for the (employee, role) pair — if no record exists,
we create one. Always uses sudo() because the worker may not
have write access to their own profile.
"""
if not employee or not role:
return self.browse()
rec = self.sudo().search([
('employee_id', '=', employee.id),
('role_id', '=', role.id),
], limit=1)
now = fields.Datetime.now()
if rec:
new_count = rec.completed_count + 1
rec.write({
'completed_count': new_count,
'last_completed_at': now,
})
else:
rec = self.sudo().create({
'employee_id': employee.id,
'role_id': role.id,
'completed_count': 1,
'first_completed_at': now,
'last_completed_at': now,
})
rec._maybe_promote()
return rec
def _maybe_promote(self):
for rec in self:
if rec.promoted:
continue
target = rec.role_id.mastery_required or 0
if target <= 0:
continue
if rec.completed_count < target:
continue
employee = rec.employee_id
role = rec.role_id
already_assigned = role in employee.x_fc_work_role_ids
rec.sudo().write({
'promoted': True,
'promoted_at': fields.Datetime.now(),
})
if already_assigned:
continue
employee.sudo().write({
'x_fc_work_role_ids': [(4, role.id)],
})
employee.message_post(
body=Markup(_(
'<b>%(name)s promoted</b> — qualified for '
'<b>%(role)s</b> after %(count)s successful '
'completions.'
)) % {
'name': employee.name,
'role': role.name,
'count': rec.completed_count,
},
subtype_xmlid='mail.mt_note',
)

View File

@@ -0,0 +1,62 @@
# -*- 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 (Sub 11) — relocated from fusion_plating_bridge_mrp. The model
# never had MRP fields; the bridge module was just its initial home.
from odoo import api, fields, models
class FpWorkRole(models.Model):
"""A shop role assigned to a recipe step and to the employees who
can perform it.
Shops run the same part with different staffing models:
- One employee does every step (small shop): give them every role.
- Specialists per operation (masking person, racker, plater): one
role each.
- Cross-trained workers: multiple roles per worker.
The model is intentionally flat — no hierarchy, no workflow. Roles
are just tags that the step auto-assignment compares.
"""
_name = 'fp.work.role'
_description = 'Fusion Plating — Shop Work Role'
_order = 'sequence, code'
name = fields.Char(string='Role Name', required=True, translate=True)
code = fields.Char(string='Code', required=True,
help='Short stable identifier used in auto-assignment.')
sequence = fields.Integer(default=10)
description = fields.Char(
string='Description',
help='Short operator-facing description of what this role covers.',
)
icon = fields.Selection(
[('fa-scissors', 'Scissors (masking)'),
('fa-cogs', 'Cogs (racking)'),
('fa-flask', 'Flask (plating)'),
('fa-fire', 'Fire (oven)'),
('fa-search', 'Inspection'),
('fa-wrench', 'Wrench (rework)'),
('fa-user', 'Generic worker')],
string='Icon', default='fa-user',
)
active = fields.Boolean(default=True)
mastery_required = fields.Integer(
string='Mastery Threshold',
default=lambda self: self._default_mastery_required(),
help='Number of successful step completions a worker needs on this '
"role before they're added to its qualified-operators list "
'automatically. 1 = promote on first success; 3 = solid '
"default for everyday roles; 5+ for tasks that need real "
'practice. Defaults from Settings > Fusion Plating > '
'Default Mastery Threshold.',
)
@api.model
def _default_mastery_required(self):
return self.env.company.x_fc_default_mastery_threshold or 3

View File

@@ -0,0 +1,161 @@
# -*- 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 HrEmployee(models.Model):
"""Tag employees with the shop roles they can perform.
An employee with role 'masking' receives the masking steps when WOs
are generated; an employee with multiple roles receives WOs for all
of them. A small shop where the owner wears every hat just tags
themselves with every role.
Lead hands are a separate per-role list — they don't have to be
primary owners of those roles, but they're authorised to step in
when the regular owner is absent or behind. The Manager Desk
promotes lead hands above other workers in its dropdown for any
role they cover.
"""
_inherit = 'hr.employee'
x_fc_work_role_ids = fields.Many2many(
'fp.work.role', 'fp_employee_work_role_rel',
'employee_id', 'role_id', string='Shop Roles',
help='Which shop roles this employee performs. Used by the '
'Manager Desk and auto-assignment on WO generation. '
'Roles are added automatically when an employee completes '
'a task that meets the role mastery threshold.',
)
# Per-role lead-hand list. Sarah might be a lead hand for masking +
# racking but not for plating; Mike might cover everything during
# a graveyard shift. Stored on a separate relation table so the
# primary "Shop Roles" list stays distinct from the cover-anything
# authority.
x_fc_lead_hand_role_ids = fields.Many2many(
'fp.work.role', 'fp_employee_lead_hand_role_rel',
'employee_id', 'role_id', string='Lead Hand For',
help='Roles where this employee is authorised to lead or cover '
'for an absent operator. Lead hands are surfaced first in '
'the Manager Desk worker picker for these roles.',
)
x_fc_proficiency_ids = fields.One2many(
'fp.operator.proficiency', 'employee_id',
string='Task Proficiency',
help='Per-role completion tally. Workers earn one count per WO '
'they finish on a given role. Once the count crosses the '
"role's mastery threshold the role is added to their "
'Shop Roles list automatically.',
)
# ------------------------------------------------------------------
# Attendance helpers — used by the Manager Desk to show who is
# currently clocked in. Works with vanilla hr_attendance or the
# full fusion_clock module — both store an open record (no
# check_out) for as long as the employee is on shift.
# ------------------------------------------------------------------
x_fc_is_clocked_in = fields.Boolean(
string='Clocked In',
compute='_compute_x_fc_is_clocked_in',
search='_search_x_fc_is_clocked_in',
help='True if this employee currently has an open hr.attendance '
'record (clocked in but not clocked out).',
)
def _compute_x_fc_is_clocked_in(self):
"""Compute attendance status from hr.attendance.
Batched so the manager dashboard doesn't issue one query per
employee — important when the shop has dozens of operators.
"""
if not self:
return
Att = self.env.get('hr.attendance')
if Att is None:
for emp in self:
emp.x_fc_is_clocked_in = False
return
# One read for the whole recordset.
open_emp_ids = set(Att.sudo().search([
('employee_id', 'in', self.ids),
('check_out', '=', False),
]).mapped('employee_id').ids)
for emp in self:
emp.x_fc_is_clocked_in = emp.id in open_emp_ids
def _search_x_fc_is_clocked_in(self, *args):
"""Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain.
Two compounding gotchas surfaced after fusion_clock auto-closed
the demo open attendances:
1. Odoo 19 normalises ``('=', True)`` into
``('in', OrderedSet([True]))`` before invoking the search
method. The previous code only handled ``=`` / ``!=`` and
fell through to ``return []`` for ``in`` / ``not in`` —
which Odoo treats as "no constraint" and matches every
row.
2. ``('id', 'in', [])`` is also treated as no-constraint in
some Odoo versions; replaced with a ``[0]`` sentinel so
the empty-open-list case correctly matches nothing.
Strategy: reduce caller intent to a *match_set* of booleans
(which values of ``x_fc_is_clocked_in`` should match), flip on
negative operators, then translate into ``id IN`` / ``NOT IN``
on the cached open-attendance employee ids. Variable signature
future-proofs against Odoo's compute-field API shifting again.
"""
# Variable signature — Odoo 19 may pass (records, op, val).
if len(args) == 3:
_records, operator, value = args
elif len(args) == 2:
operator, value = args
else:
return [('id', '=', False)]
Att = self.env.get('hr.attendance')
if Att is None:
return [('id', '=', False)]
if operator in ('=', '!='):
match_set = {bool(value)}
elif operator in ('in', 'not in'):
match_set = set(map(bool, value))
else:
return [('id', '=', False)]
# Negated operators flip the match set.
if operator in ('!=', 'not in'):
match_set = {True, False} - match_set
if not match_set:
return [('id', '=', False)]
if match_set == {True, False}:
return [] # every row matches
open_emp_ids = Att.sudo().search(
[('check_out', '=', False)]
).employee_id.ids
ids_term = open_emp_ids or [0]
return [('id', 'in' if True in match_set else 'not in', ids_term)]
@api.model
def _fp_clocked_in_user_ids(self):
"""Return the set of res.users.ids whose linked employee is on shift.
Used by the Manager Desk controller to short-circuit the worker
dropdown to "present today" without an N+1 attendance query
per worker.
"""
Att = self.env.get('hr.attendance')
if Att is None:
return set()
emps = Att.sudo().search([
('check_out', '=', False),
]).mapped('employee_id')
return set(emps.user_id.ids)

View File

@@ -56,3 +56,8 @@ access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0 access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,group_fusion_plating_operator,1,0,0,0
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion_plating_manager,1,1,1,1
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
56 access_fp_job_step_timelog_operator fp.job.step.timelog.operator model_fp_job_step_timelog fusion_plating.group_fusion_plating_operator 1 1 1 0
57 access_fp_job_step_timelog_supervisor fp.job.step.timelog.supervisor model_fp_job_step_timelog fusion_plating.group_fusion_plating_supervisor 1 1 1 0
58 access_fp_job_step_timelog_manager fp.job.step.timelog.manager model_fp_job_step_timelog fusion_plating.group_fusion_plating_manager 1 1 1 1
59 access_fp_work_role_operator fp.work.role.operator model_fp_work_role group_fusion_plating_operator 1 0 0 0
60 access_fp_work_role_manager fp.work.role.manager model_fp_work_role group_fusion_plating_manager 1 1 1 1
61 access_fp_proficiency_operator fp.operator.proficiency.operator model_fp_operator_proficiency group_fusion_plating_operator 1 0 0 0
62 access_fp_proficiency_supervisor fp.operator.proficiency.supervisor model_fp_operator_proficiency group_fusion_plating_supervisor 1 1 1 0
63 access_fp_proficiency_manager fp.operator.proficiency.manager model_fp_operator_proficiency group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,165 @@
<?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_work_role_list" model="ir.ui.view">
<field name="name">fp.work.role.list</field>
<field name="model">fp.work.role</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="icon" optional="show"/>
<field name="description"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_work_role_form" model="ir.ui.view">
<field name="name">fp.work.role.form</field>
<field name="model">fp.work.role</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Plating Operator"/></h1>
</div>
<group>
<group>
<field name="code" placeholder="plating_op"/>
<field name="icon"/>
<field name="sequence"/>
</group>
<group>
<field name="active" widget="boolean_toggle"/>
<field name="mastery_required"/>
</group>
</group>
<group>
<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
operator has finished this many WOs against this role, the role is
added to their Shop Roles automatically and a chatter line is
posted to their employee record. Defaults from
<em>Settings &gt; Fusion Plating &gt; Default Mastery Threshold</em>.
</div>
</sheet>
</form>
</field>
</record>
<record id="action_fp_work_role" model="ir.actions.act_window">
<field name="name">Shop Roles</field>
<field name="res_model">fp.work.role</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Define the roles on your shop floor
</p>
<p>
Tag each employee with the roles they can perform and tag each
recipe step with the role that performs it. Work orders will
auto-route to the right worker when an MO is confirmed.
</p>
</field>
</record>
<menuitem id="menu_fp_work_roles"
name="Shop Roles"
parent="fusion_plating.menu_fp_config"
action="action_fp_work_role"
sequence="55"
groups="fusion_plating.group_fusion_plating_manager"/>
<!-- Employee form — Shop Roles + Lead Hand For + Proficiency tracker -->
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
<field name="name">hr.employee.form.fp.roles</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Shop Roles" name="fp_shop_roles"
groups="fusion_plating.group_fusion_plating_supervisor">
<group>
<group string="Tasks This Operator Can Do">
<field name="x_fc_work_role_ids"
widget="many2many_tags"
options="{'no_create_edit': True}"
placeholder="Tag the shop roles this employee performs..."/>
<div class="text-muted small" colspan="2">
Work orders tagged with these roles auto-assign to
this employee (or to whoever has the same role and
the lighter open queue).
</div>
</group>
<group string="Lead Hand For"
groups="fusion_plating.group_fusion_plating_manager">
<field name="x_fc_lead_hand_role_ids"
widget="many2many_tags"
options="{'no_create_edit': True}"
placeholder="Roles where this employee can cover for absent operators..."/>
<div class="text-muted small" colspan="2">
Lead hands appear at the top of the Manager Desk
worker dropdown for these roles, even when they
aren't the primary owner. Use for cross-trained
workers who can step in during absences.
</div>
</group>
</group>
<separator string="Task Proficiency"/>
<p class="text-muted small">
Auto-tracked: every successfully completed WO bumps the
count for its role. When the count crosses the role's
mastery threshold the role is added to <em>Tasks This
Operator Can Do</em> automatically.
</p>
<field name="x_fc_proficiency_ids" nolabel="1"
readonly="1">
<list>
<field name="role_id"/>
<field name="completed_count"/>
<field name="progress_label" string="Progress"/>
<field name="promoted" widget="boolean_toggle"
readonly="1"/>
<field name="first_completed_at"/>
<field name="last_completed_at"/>
<field name="promoted_at"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
<!-- Process node form — add role field -->
<record id="view_fp_process_node_form_fp_roles" model="ir.ui.view">
<field name="name">fusion.plating.process.node.form.fp.roles</field>
<field name="model">fusion.plating.process.node</field>
<field name="inherit_id" ref="fusion_plating.view_fp_process_node_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='work_center_id']" position="after">
<field name="x_fc_work_role_id"
options="{'no_create_edit': True}"/>
</xpath>
</field>
</record>
<!--
NOTE: the WO form already shows x_fc_work_role_id + x_fc_assigned_user_id
via mrp_workorder_views.xml (after production_id). The earlier inherit
here would cause the fields to render twice.
-->
</odoo>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Batch Processing', 'name': 'Fusion Plating — Batch Processing',
'version': '19.0.1.0.0', 'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Group parts into rack or barrel loads for tank processing.', 'summary': 'Group parts into rack or barrel loads for tank processing.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Phase 6 (Sub 11) — drop legacy MRP columns from fusion_plating_batch.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
cr.execute("ALTER TABLE fusion_plating_batch DROP COLUMN IF EXISTS production_id")
cr.execute("ALTER TABLE fusion_plating_batch DROP COLUMN IF EXISTS workorder_id")
_logger.info("Sub 11: dropped production_id + workorder_id from fusion_plating_batch")

View File

@@ -73,15 +73,9 @@ class FpBatch(models.Model):
domain="[('state', '!=', 'retired')]", domain="[('state', '!=', 'retired')]",
tracking=True, tracking=True,
) )
workorder_id = fields.Many2one( # Phase 6 (Sub 11) — workorder_id / production_id retired (MRP gone).
'mrp.workorder', string='Work Order', # Native equivalents: x_fc_step_id (fp.job.step) + x_fc_job_id (fp.job)
help='The WO this batch ran through. Used for material traceability.', # are added by fusion_plating_jobs and carry the same traceability.
tracking=True,
)
production_id = fields.Many2one(
'mrp.production', string='Manufacturing Order',
related='workorder_id.production_id', store=True, readonly=True,
)
part_count = fields.Integer(string='Part Count') part_count = fields.Integer(string='Part Count')
start_time = fields.Datetime(string='Process Start', tracking=True) start_time = fields.Datetime(string='Process Start', tracking=True)
end_time = fields.Datetime(string='Process End', tracking=True) end_time = fields.Datetime(string='Process End', tracking=True)

View File

@@ -5,7 +5,7 @@
{ {
"name": "Fusion Plating — MRP Bridge", "name": "Fusion Plating — MRP Bridge",
'version': '19.0.12.2.0', 'version': '19.0.13.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """ 'description': """
@@ -59,33 +59,30 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
], ],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/fp_work_role_data.xml', # Phase 1 (Sub 11) — fp_work_role_data + fp_qc_data relocated
# to fusion_plating_jobs.
'data/fp_cron_data.xml', 'data/fp_cron_data.xml',
'data/fp_qc_data.xml',
'wizard/fp_recipe_config_wizard_views.xml', 'wizard/fp_recipe_config_wizard_views.xml',
'views/mrp_workcenter_views.xml', 'views/mrp_workcenter_views.xml',
'views/mrp_workorder_views.xml', 'views/mrp_workorder_views.xml',
'views/fp_qc_template_views.xml', # Phase 1 (Sub 11) — relocated to fusion_plating_jobs / fusion_plating_quality.
'views/fp_quality_check_views.xml', # 'views/fp_qc_template_views.xml',
# 'views/fp_quality_check_views.xml',
# 'views/fp_job_consumption_views.xml',
# 'views/fp_work_role_views.xml',
'views/mrp_production_views.xml', 'views/mrp_production_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/fp_quality_hold_views.xml', 'views/fp_quality_hold_views.xml',
'views/fp_batch_views.xml', 'views/fp_batch_views.xml',
'views/fp_workorder_priority_views.xml', # Phase 3 (Sub 11) — replaced by native fp.job.step priority kanban
'views/fp_job_consumption_views.xml', # in fusion_plating_jobs/views/fp_step_priority_views.xml.
'views/fp_work_role_views.xml', # 'views/fp_workorder_priority_views.xml',
'views/res_partner_views.xml', # Phase 4 (Sub 11) — relocated to fusion_plating_quality.
# 'views/res_partner_views.xml',
'views/fp_serial_views.xml', 'views/fp_serial_views.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ # Phase 2 (Sub 11) — QC tablet OWL relocated to fusion_plating_quality.
# Depends on _fp_shopfloor_tokens.scss being loaded first —
# shopfloor is in depends, so its tokens bundle-concatenate
# before this file and define $fp-card / $fp-accent / etc.
'fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss',
'fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml',
'fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js',
],
}, },
'installable': True, 'installable': True,
'application': False, 'application': False,

View File

@@ -2,4 +2,5 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_qc_controller # Phase 2 (Sub 11) — QC controller relocated to fusion_plating_quality.
# from . import fp_qc_controller

View File

@@ -11,16 +11,35 @@ from . import fp_portal_job
from . import fp_quality_hold from . import fp_quality_hold
from . import fp_delivery from . import fp_delivery
from . import fp_batch from . import fp_batch
# fusion.plating.job.node.override (mrp.production-bound) — kept here
# until Phase 5 deletes the bridge module. The native fp.job-bound
# override is `fp.job.node.override` in fusion_plating_jobs (different
# model, different table).
from . import fp_job_node_override from . import fp_job_node_override
from . import fp_job_consumption # Phase 1 (Sub 11) — fp.job.consumption is now in fusion_plating_jobs.
# bridge_mrp can't depend on jobs (would create a cycle through
# notifications/reports), so the legacy production_id/workorder_id
# fields are gone for good. mrp.production has 0 rows in native mode
# so the loss of the back-link is data-safe.
# from . import fp_job_consumption
from . import account_move from . import account_move
from . import sale_order from . import sale_order
from . import fp_work_role # Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
from . import hr_employee # from . import fp_work_role
from . import fp_proficiency # Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
from . import fp_process_node # from . import hr_employee
from . import fp_qc_template # Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
# from . import fp_proficiency
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs (fp.work.role lives there).
# from . import fp_process_node
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
# from . import fp_qc_template
# Phase 1 (Sub 11) — model relocated to fusion_plating_quality.
# This file now contains only a thin inherit that restores the
# legacy production_id back-link until Phase 5 retires the bridge.
from . import fp_quality_check from . import fp_quality_check
from . import fp_thickness_reading # Phase 1 (Sub 11) — relocated to fusion_plating_quality.
from . import res_partner # from . import fp_thickness_reading
# Phase 4 (Sub 11) — relocated to fusion_plating_quality.
# from . import res_partner
from . import fp_serial from . import fp_serial

View File

@@ -2,85 +2,24 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
#
# Phase 1 (Sub 11) — the model proper now lives in
# fusion_plating_jobs. This file restores the legacy production_id +
# workorder_id back-links so bridge_mrp's mrp.production O2M
# (x_fc_consumption_ids) keeps resolving until Phase 5 deletes the
# bridge module.
from odoo import api, fields, models, _ from odoo import fields, models
class FpJobConsumption(models.Model): class FpJobConsumption(models.Model):
"""A single consumable drawdown charged to a manufacturing order. _inherit = 'fp.job.consumption'
Sources include bath replenishment applied against a job, masking tape
rolls, PPE, nickel salts — anything that has a cost and should roll
into job costing.
Kept deliberately lightweight: one row per event, cost derived from
`product.standard_price` at log time (snapshot, not reactive).
"""
_name = 'fp.job.consumption'
_description = 'Fusion Plating — Job Consumption'
_order = 'logged_date desc, id desc'
production_id = fields.Many2one( production_id = fields.Many2one(
'mrp.production', string='Manufacturing Order', 'mrp.production', string='Manufacturing Order',
required=True, ondelete='cascade', ondelete='cascade', index=True,
) )
workorder_id = fields.Many2one( workorder_id = fields.Many2one(
'mrp.workorder', string='Work Order', 'mrp.workorder', string='Work Order',
domain="[('production_id', '=', production_id)]", domain="[('production_id', '=', production_id)]",
) )
product_id = fields.Many2one(
'product.product', string='Product', required=True,
domain="[('sale_ok', '=', False)]",
)
product_name = fields.Char(
string='Product Name (snapshot)',
help='Free-text product label if no inventory product is linked.',
)
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
uom_id = fields.Many2one(
'uom.uom', string='UoM',
)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
unit_cost = fields.Monetary(
string='Unit Cost (snapshot)', currency_field='currency_id',
help='Taken from product.standard_price at log time.',
)
total_cost = fields.Monetary(
string='Total Cost', currency_field='currency_id',
compute='_compute_total_cost', store=True,
)
logged_date = fields.Datetime(
string='Logged', default=fields.Datetime.now,
)
logged_by_id = fields.Many2one(
'res.users', string='Logged By', default=lambda self: self.env.user,
)
source = fields.Selection(
[('replenishment', 'Bath Replenishment'),
('masking', 'Masking Material'),
('ppe', 'PPE / Consumables'),
('chemistry', 'Process Chemistry'),
('other', 'Other')],
string='Source', default='other', required=True,
)
replenishment_id = fields.Many2one(
'fusion.plating.bath.replenishment.suggestion',
string='Replenishment Suggestion',
ondelete='set null',
)
notes = fields.Char(string='Notes')
@api.depends('quantity', 'unit_cost')
def _compute_total_cost(self):
for rec in self:
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
@api.onchange('product_id')
def _onchange_product(self):
if self.product_id:
self.product_name = self.product_id.display_name
self.unit_cost = self.product_id.standard_price or 0.0
self.uom_id = self.product_id.uom_id or False

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from odoo import fields, models from odoo import api, fields, models
class FpJobNodeOverride(models.Model): class FpJobNodeOverride(models.Model):
@@ -58,6 +58,14 @@ class FpJobNodeOverride(models.Model):
help='Whether this optional step is active for this job.', help='Whether this optional step is active for this job.',
) )
@api.depends('production_id', 'node_id', 'included')
def _compute_display_name(self):
for rec in self:
mo = rec.production_id.name or '(no MO)'
node = rec.node_id.display_name or '(no node)'
tag = 'included' if rec.included else 'excluded'
rec.display_name = '%s · %s [%s]' % (mo, node, tag)
_sql_constraints = [ _sql_constraints = [
('unique_production_node', ('unique_production_node',
'unique(production_id, node_id)', 'unique(production_id, node_id)',

View File

@@ -2,621 +2,22 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""Per-MO QC instance. #
# Phase 1 (Sub 11) — the QC model proper now lives in
# fusion_plating_quality. This file restores the legacy production_id
# back-link on fusion.plating.quality.check so bridge_mrp's
# mrp.production O2M (x_fc_qc_check_ids) keeps resolving until Phase 5
# deletes the bridge module entirely.
When an MO confirms and the customer requires QC, we clone the active from odoo import fields, models
checklist template into a `fusion.plating.quality.check` with one line
per template line. The inspector picks it up on the tablet, walks the
checks, and signs off — which unblocks `mrp.production.button_mark_done`.
The QC also owns the Fischerscope / XDAL 600 thickness report PDF.
When the operator uploads one, we extract per-reading data server-side
and auto-create `fp.thickness.reading` rows so the CoC PDF picks them up.
"""
import base64
import logging
import re
import subprocess
import tempfile
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpQualityCheck(models.Model): class FpQualityCheck(models.Model):
_name = 'fusion.plating.quality.check' _inherit = 'fusion.plating.quality.check'
_description = 'Fusion Plating — Quality Check'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
name = fields.Char(
string='Reference', required=True, copy=False, readonly=True,
default=lambda self: self._default_name(), tracking=True,
)
production_id = fields.Many2one( production_id = fields.Many2one(
'mrp.production', string='Manufacturing Order', 'mrp.production', string='Manufacturing Order',
required=True, ondelete='cascade', tracking=True, ondelete='cascade', index=True,
index=True, help='Legacy MRP back-link. Native flow uses job_id; this stays '
'for bridge_mrp until Phase 5 cuts the module.',
) )
partner_id = fields.Many2one(
'res.partner', string='Customer',
compute='_compute_partner_id', store=True,
)
template_id = fields.Many2one(
'fp.qc.checklist.template', string='Template',
help='The checklist template these lines were cloned from.',
)
state = fields.Selection(
[
('draft', 'Draft'),
('in_progress', 'In Progress'),
('passed', 'Passed'),
('failed', 'Failed'),
('rework', 'Rework Required'),
],
string='Status', default='draft', required=True, tracking=True,
)
overall_result = fields.Selection(
[('pass', 'Pass'), ('fail', 'Fail'), ('partial', 'Partial Pass')],
string='Result', tracking=True,
help='Summary outcome — set when inspector signs off.',
)
line_ids = fields.One2many(
'fusion.plating.quality.check.line', 'check_id',
string='Check Items',
)
line_count = fields.Integer(compute='_compute_line_stats', store=True)
lines_passed = fields.Integer(compute='_compute_line_stats', store=True)
lines_failed = fields.Integer(compute='_compute_line_stats', store=True)
lines_pending = fields.Integer(compute='_compute_line_stats', store=True)
inspector_id = fields.Many2one(
'res.users', string='Inspector',
help='Whoever signed the QC off. Filled when state moves to '
'passed/failed.',
tracking=True,
)
started_at = fields.Datetime(
string='Started', help='First time inspector opened this check.',
)
completed_at = fields.Datetime(
string='Completed', help='When the check was signed off.',
tracking=True,
)
notes = fields.Html(string='Inspector Notes')
# Fischerscope / XDAL 600 PDF + auto-extracted readings
thickness_report_pdf_id = fields.Many2one(
'ir.attachment', string='Thickness Report PDF',
help='Upload the Fischerscope / XDAL 600 export. On upload we '
'parse the PDF and auto-create fp.thickness.reading rows.',
)
thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'quality_check_id',
string='Thickness Readings',
)
thickness_reading_count = fields.Integer(
compute='_compute_thickness_count',
)
# Cached gate-policy flags from the template (denormalized so
# button_mark_done doesn't have to reach through a potentially-null
# template).
require_thickness_readings = fields.Boolean(
related='template_id.require_thickness_readings',
store=True, readonly=True,
)
require_thickness_report_pdf = fields.Boolean(
related='template_id.require_thickness_report_pdf',
store=True, readonly=True,
)
require_inspector_signoff = fields.Boolean(
related='template_id.require_inspector_signoff',
store=True, readonly=True,
)
company_id = fields.Many2one(
'res.company', related='production_id.company_id',
store=True, readonly=True,
)
# ------------------------------------------------------------------
# Computed
# ------------------------------------------------------------------
@api.depends('production_id.origin')
def _compute_partner_id(self):
SO = self.env['sale.order']
for rec in self:
partner = False
mo = rec.production_id
if mo and mo.origin:
so = SO.search([('name', '=', mo.origin)], limit=1)
if so:
partner = so.partner_id
rec.partner_id = partner
@api.depends('line_ids.result')
def _compute_line_stats(self):
for rec in self:
rec.line_count = len(rec.line_ids)
rec.lines_passed = len(rec.line_ids.filtered(
lambda l: l.result == 'pass'
))
rec.lines_failed = len(rec.line_ids.filtered(
lambda l: l.result == 'fail'
))
rec.lines_pending = len(rec.line_ids.filtered(
lambda l: l.result in (False, 'pending')
))
@api.depends('thickness_reading_ids')
def _compute_thickness_count(self):
for rec in self:
rec.thickness_reading_count = len(rec.thickness_reading_ids)
# ------------------------------------------------------------------
# Create + sequence
# ------------------------------------------------------------------
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code(
'fusion.plating.quality.check',
)
return seq or 'QC/NEW'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
# ------------------------------------------------------------------
# Factory — spawn a QC from a template
# ------------------------------------------------------------------
@api.model
def create_for_production(self, production, template=None):
"""Spin up a QC record for an MO, cloning lines from the template.
If no template is passed, we try to resolve one from the MO's
customer. Returns the created check, or an empty recordset if
no template matches (=> no QC required for this customer).
"""
self = self.sudo()
if template is None:
partner = False
if production.origin:
so = self.env['sale.order'].search(
[('name', '=', production.origin)], limit=1,
)
if so:
partner = so.partner_id
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
partner,
)
if not template:
return self.browse() # empty — no QC required
# Avoid duplicates — one active (non-failed) check per MO
existing = self.search([
('production_id', '=', production.id),
('state', '!=', 'failed'),
], limit=1)
if existing:
return existing
check = self.create({
'production_id': production.id,
'template_id': template.id,
'state': 'draft',
})
Line = self.env['fusion.plating.quality.check.line']
for tline in template.line_ids.sorted('sequence'):
Line.create({
'check_id': check.id,
'sequence': tline.sequence,
'name': tline.name,
'description': tline.description,
'check_type': tline.check_type,
'required': tline.required,
'requires_value': tline.requires_value,
'value_min': tline.value_min,
'value_max': tline.value_max,
'value_uom': tline.value_uom,
'requires_photo': tline.requires_photo,
'result': 'pending',
})
production.message_post(
body=_('QC checklist "%s" created — %d items to inspect.') % (
template.name, len(template.line_ids),
),
)
return check
# ------------------------------------------------------------------
# State actions
# ------------------------------------------------------------------
def action_start(self):
for rec in self:
if rec.state == 'draft':
rec.write({
'state': 'in_progress',
'started_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=_('QC started.'))
def action_pass(self):
for rec in self:
rec._ensure_all_required_complete()
rec.write({
'state': 'passed',
'overall_result': 'pass',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=Markup(
'<b>QC PASSED</b> — inspector %s.'
) % self.env.user.name)
def action_fail(self):
for rec in self:
rec.write({
'state': 'failed',
'overall_result': 'fail',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=Markup(
'<b>QC FAILED</b> — inspector %s.'
) % self.env.user.name)
def action_rework(self):
for rec in self:
rec.write({
'state': 'rework',
'overall_result': 'partial',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=_('QC flagged for rework.'))
def action_reset_to_draft(self):
for rec in self:
rec.write({
'state': 'draft',
'overall_result': False,
'completed_at': False,
})
def action_spawn_retry(self):
"""Spin up a fresh QC instance for the same MO.
Used after a failed QC — the original stays in history, the
new one gets the same template applied to a clean slate.
Manager-only via ACL.
"""
self.ensure_one()
if self.state != 'failed':
return # no-op; user can just finish the existing one
new_check = self.sudo().create_for_production(
self.production_id, template=self.template_id,
)
if not new_check:
return False
self.message_post(body=_(
'Retry QC created: %s'
) % new_check.name)
new_check.message_post(body=_(
'Retry of failed QC %s'
) % self.name)
return new_check.action_open_tablet()
def _ensure_all_required_complete(self):
"""Guard for action_pass — every required line must be resolved
to pass or n/a (fail would be handled by action_fail) and any
numeric-value / photo requirements must be honoured."""
for rec in self:
pending = rec.line_ids.filtered(
lambda l: l.required and l.result in (False, 'pending')
)
if pending:
raise UserError(_(
'Cannot pass QC "%(name)s"%(n)d required check '
'item(s) still pending:\n%(items)s'
) % {
'name': rec.name,
'n': len(pending),
'items': '\n'.join(pending.mapped('name')),
})
failed = rec.line_ids.filtered(lambda l: l.result == 'fail')
if failed:
raise UserError(_(
'Cannot pass QC "%(name)s"%(n)d check item(s) '
'failed. Fail the QC instead, or reset those '
'items to pass.'
) % {'name': rec.name, 'n': len(failed)})
# ------------------------------------------------------------------
# Fischerscope PDF upload → auto-extract readings
# ------------------------------------------------------------------
def _on_thickness_pdf_uploaded(self):
"""Parse the attached PDF with `pdftotext` and create
fp.thickness.reading rows.
Fischerscope XDAL 600 / WinFTM reports vary a bit in layout
but consistently print one line per reading with a column for
NiP thickness in mils and another for Ni / P percentages. The
parser is conservative: if a column isn't confidently found,
we skip that reading rather than write garbage.
"""
ThicknessReading = self.env['fp.thickness.reading']
for rec in self:
if not rec.thickness_report_pdf_id:
continue
try:
text = rec._extract_pdf_text(rec.thickness_report_pdf_id)
except Exception:
_logger.exception(
'QC %s: pdftotext extraction failed', rec.name,
)
continue
readings = rec._parse_fischerscope_text(text)
if not readings:
rec.message_post(body=_(
'Thickness report PDF attached but no readings '
'could be extracted automatically. Please enter '
'readings manually.'
))
continue
# Replace any prior auto-extracted readings so re-uploads
# don't stack duplicates.
auto = rec.thickness_reading_ids.filtered(
lambda r: r.auto_extracted
)
auto.unlink()
for idx, row in enumerate(readings, start=1):
ThicknessReading.create({
'quality_check_id': rec.id,
'production_id': rec.production_id.id,
'reading_number': idx,
'nip_mils': row.get('nip_mils', 0.0),
'ni_percent': row.get('ni_percent', 0.0),
'p_percent': row.get('p_percent', 0.0),
'position_label': row.get('position', ''),
'auto_extracted': True,
})
rec.message_post(body=_(
'Extracted %d thickness reading(s) from "%s".'
) % (len(readings), rec.thickness_report_pdf_id.name))
@staticmethod
def _extract_pdf_text(attachment):
"""Run pdftotext on an ir.attachment and return the text."""
raw = base64.b64decode(attachment.datas or b'')
if not raw:
return ''
with tempfile.NamedTemporaryFile(
suffix='.pdf', delete=True,
) as tmp:
tmp.write(raw)
tmp.flush()
try:
result = subprocess.run(
['pdftotext', '-layout', tmp.name, '-'],
capture_output=True, text=True, timeout=30,
)
return result.stdout or ''
except FileNotFoundError:
_logger.warning(
'pdftotext not installed — cannot auto-extract '
'Fischerscope PDF data. Install poppler-utils on '
'the Odoo host.',
)
return ''
@staticmethod
def _parse_fischerscope_text(text):
"""Best-effort Fischerscope WinFTM table parser.
WinFTM single-reading export lines look like:
n=1 0.000843 mils 91.5% Ni 8.5% P 120s
or (with labels bleeding together from the PDF layout):
1 0.000843 91.5 8.5 Pos 1
We match any row that has 14 floating-point numbers after a
reading index. The heuristic stays narrow enough that it won't
eat header rows like "Measuring time 120s" or junk lines.
"""
readings = []
# Row: <index> <nip-mils-or-microns> <ni%> <p%>
# Indices may appear as "n=1", "1.", "1", "N1"
row_re = re.compile(
r'^\s*(?:n\s*=\s*|N\s*)?(\d{1,3})[\s.:]+'
r'([0-9]*\.[0-9]+|\d+)' # nip
r'(?:\s*(?:mils|microns|µm|um))?'
r'[\s|]+'
r'([0-9]*\.?[0-9]+)' # ni%
r'[\s|%]+'
r'([0-9]*\.?[0-9]+)' # p%
r'[\s|%]*'
r'(.*)$',
re.IGNORECASE,
)
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
m = row_re.match(line)
if not m:
continue
try:
idx = int(m.group(1))
nip = float(m.group(2))
ni = float(m.group(3))
p = float(m.group(4))
except (TypeError, ValueError):
continue
# Sanity guards — NiP > 1 mil is unheard of on plating;
# Ni% and P% should sum to ~100.
if not (0 < nip < 1) and not (0 < nip < 30): # 30µm envelope
continue
if not (0 < ni < 100):
continue
if not (0 < p < 30):
continue
# Throw out rows where index is obviously wrong
if idx < 1 or idx > 500:
continue
position = (m.group(5) or '').strip()[:60]
readings.append({
'index': idx,
'nip_mils': nip,
'ni_percent': ni,
'p_percent': p,
'position': position,
})
# Keep only one reading per index (first wins)
seen = set()
dedup = []
for r in readings:
if r['index'] in seen:
continue
seen.add(r['index'])
dedup.append(r)
return dedup
def write(self, vals):
trigger = 'thickness_report_pdf_id' in vals and vals.get(
'thickness_report_pdf_id'
)
res = super().write(vals)
if trigger:
self._on_thickness_pdf_uploaded()
return res
# ------------------------------------------------------------------
# Navigation helpers
# ------------------------------------------------------------------
def action_open_tablet(self):
"""Launch the mobile QC checklist OWL client action."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_qc_checklist',
'name': _('QC — %s') % (self.production_id.name or ''),
'params': {'check_id': self.id},
'target': 'current',
}
class FpQualityCheckLine(models.Model):
_name = 'fusion.plating.quality.check.line'
_description = 'Fusion Plating — Quality Check Line'
_order = 'sequence, id'
check_id = fields.Many2one(
'fusion.plating.quality.check', string='Check',
required=True, ondelete='cascade', index=True,
)
sequence = fields.Integer(default=10)
name = fields.Char(string='Check Item', required=True)
description = fields.Text(string='Guidance')
check_type = fields.Selection(
selection=lambda self: self.env[
'fp.qc.checklist.template.line'
]._fields['check_type'].selection,
string='Type', default='visual',
)
required = fields.Boolean(default=True)
requires_value = fields.Boolean()
value = fields.Float(digits=(12, 4))
value_min = fields.Float(digits=(12, 4))
value_max = fields.Float(digits=(12, 4))
value_uom = fields.Char(string='Unit')
requires_photo = fields.Boolean()
photo_attachment_id = fields.Many2one(
'ir.attachment', string='Photo',
)
result = fields.Selection(
[
('pending', 'Pending'),
('pass', 'Pass'),
('fail', 'Fail'),
('na', 'N/A'),
],
string='Result', default='pending', required=True,
)
notes = fields.Text(string='Note')
inspector_id = fields.Many2one('res.users', string='Inspector')
completed_at = fields.Datetime(string='Completed At')
value_in_range = fields.Boolean(
compute='_compute_value_in_range', store=True,
)
@api.depends('value', 'value_min', 'value_max', 'requires_value')
def _compute_value_in_range(self):
for rec in self:
if not rec.requires_value:
rec.value_in_range = True
continue
vmin = rec.value_min
vmax = rec.value_max
if vmin and rec.value < vmin:
rec.value_in_range = False
elif vmax and rec.value > vmax:
rec.value_in_range = False
else:
rec.value_in_range = True
def action_mark_pass(self):
for rec in self:
if rec.requires_value and not rec.value_in_range:
raise UserError(_(
'Cannot pass "%(item)s" — value %(val)s is outside '
'the acceptance range (%(min)s %(max)s %(uom)s).'
) % {
'item': rec.name,
'val': rec.value,
'min': rec.value_min,
'max': rec.value_max,
'uom': rec.value_uom or '',
})
if rec.requires_photo and not rec.photo_attachment_id:
raise UserError(_(
'Cannot pass "%(item)s" — a photo is required.'
) % {'item': rec.name})
rec.write({
'result': 'pass',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})
def action_mark_fail(self):
for rec in self:
rec.write({
'result': 'fail',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})
def action_mark_na(self):
for rec in self:
if rec.required:
raise UserError(_(
'"%(item)s" is a required check and cannot be '
'marked N/A.'
) % {'item': rec.name})
rec.write({
'result': 'na',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})

View File

@@ -170,10 +170,12 @@ class MrpProduction(models.Model):
# T3.3 — Actuals vs quoted margin # T3.3 — Actuals vs quoted margin
# T3.4 — Consumables tied to jobs # T3.4 — Consumables tied to jobs
# ------------------------------------------------------------------ # ------------------------------------------------------------------
x_fc_consumption_ids = fields.One2many( # Phase 1 (Sub 11) — fp.job.consumption relocated to
'fp.job.consumption', 'production_id', # fusion_plating_jobs. The MO-side O2M would create a circular
string='Consumables Log', # dependency (bridge_mrp → jobs → notifications → bridge_mrp), and
) # mrp.production has 0 rows in native mode, so the field is gone.
# The native fp.job analogue carries consumption via
# fp.job.consumption.job_id.
x_fc_consumables_cost = fields.Monetary( x_fc_consumables_cost = fields.Monetary(
string='Consumables Cost', compute='_compute_job_costs', string='Consumables Cost', compute='_compute_job_costs',
store=True, currency_field='x_fc_currency_id', store=True, currency_field='x_fc_currency_id',
@@ -228,7 +230,7 @@ class MrpProduction(models.Model):
def _compute_consumption_count(self): def _compute_consumption_count(self):
for mo in self: for mo in self:
mo.x_fc_consumption_count = len(mo.x_fc_consumption_ids) mo.x_fc_consumption_count = 0
@api.depends('origin') @api.depends('origin')
def _compute_sale_order_id(self): def _compute_sale_order_id(self):
@@ -305,7 +307,6 @@ class MrpProduction(models.Model):
} }
@api.depends( @api.depends(
'x_fc_consumption_ids.total_cost',
'workorder_ids.duration', 'workorder_ids.duration',
'workorder_ids.workcenter_id.costs_hour', 'workorder_ids.workcenter_id.costs_hour',
'origin', 'origin',
@@ -314,7 +315,8 @@ class MrpProduction(models.Model):
SO = self.env['sale.order'] SO = self.env['sale.order']
for mo in self: for mo in self:
currency = mo.company_id.currency_id currency = mo.company_id.currency_id
consumables = sum(mo.x_fc_consumption_ids.mapped('total_cost')) # Phase 1 (Sub 11) — consumption now lives on fp.job, not MO.
consumables = 0.0
labour = 0.0 labour = 0.0
for wo in mo.workorder_ids: for wo in mo.workorder_ids:
rate = wo.workcenter_id.costs_hour or 0.0 rate = wo.workcenter_id.costs_hour or 0.0
@@ -1218,29 +1220,35 @@ class MrpProduction(models.Model):
def _resolve_mo_process_tree(self): def _resolve_mo_process_tree(self):
"""Resolve which process-tree root to walk for this MO. """Resolve which process-tree root to walk for this MO.
Sub 3 — prefers the linked part's cloned tree Resolution priority (Sub 9 — process variants):
(SO line's x_fc_part_catalog_id.default_process_id); falls back 1. SO line's `x_fc_process_variant_id` (per-order variant pick)
to the legacy x_fc_recipe_id for MOs without a linked part or 2. Linked part's `default_process_id` (the part's default variant)
without a composed part tree. 3. Legacy `x_fc_recipe_id` (coating config / product match)
Single entry point so Sub 4 / Sub 5 updates touch one method. Multi-line MOs: first line wins. Variants are part-scoped, and a
single MO is bound to a single part group via x_fc_wo_group_tag,
so first-line semantics match how the WO walker batches.
""" """
self.ensure_one() self.ensure_one()
# Resolve part via SO lines (MO's origin → sale.order → first line = False
# line's part). mrp.production has no direct part link; the if 'x_fc_sale_order_line_ids' in self._fields and self.x_fc_sale_order_line_ids:
# relationship lives on sale.order.line. line = self.x_fc_sale_order_line_ids[0]
part = False elif self.origin:
if self.origin:
so = self.env['sale.order'].search( so = self.env['sale.order'].search(
[('name', '=', self.origin)], limit=1, [('name', '=', self.origin)], limit=1,
) )
if so and so.order_line: if so and so.order_line:
first_line = so.order_line[0] line = so.order_line[0]
if 'x_fc_part_catalog_id' in first_line._fields:
part = first_line.x_fc_part_catalog_id if line:
if part and part.default_process_id: if ('x_fc_process_variant_id' in line._fields
return part.default_process_id and line.x_fc_process_variant_id):
# Fallback — legacy recipe lookup (coating config / product match) return line.x_fc_process_variant_id
if ('x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id
and line.x_fc_part_catalog_id.default_process_id):
return line.x_fc_part_catalog_id.default_process_id
return self.x_fc_recipe_id return self.x_fc_recipe_id
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -159,11 +159,10 @@ class MrpWorkorder(models.Model):
'manager; the Tablet Station shows only WOs assigned to the ' 'manager; the Tablet Station shows only WOs assigned to the '
'logged-in user.', 'logged-in user.',
) )
x_fc_work_role_id = fields.Many2one( # Phase 1 (Sub 11) — fp.work.role relocated to fusion_plating_jobs.
'fp.work.role', string='Role', # bridge_mrp can't depend on jobs (cycle through notifications →
help='Shop role required to perform this step (copied from the ' # bridge_mrp), so the legacy WO field is gone. mrp.workorder has 0
'recipe operation on WO generation).', # rows in native mode, so nothing breaks.
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Timer audit — surface the who / when of the timer on the WO header. # Timer audit — surface the who / when of the timer on the WO header.

View File

@@ -45,17 +45,17 @@ class SaleOrder(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
x_fc_workflow_stage = fields.Selection( x_fc_workflow_stage = fields.Selection(
[ [
('draft', 'Quotation — awaiting confirmation'), ('draft', 'Quote'),
('awaiting_parts', 'Parts en route'), ('awaiting_parts', 'Parts'),
('inspecting', 'Inspecting received parts'), ('inspecting', 'Inspecting'),
('accept_parts', 'Ready to accept parts'), ('accept_parts', 'Accept'),
('assign_work', 'Ready to assign manager'), ('assign_work', 'Assign'),
('in_production', 'In production'), ('in_production', 'Production'),
('ready_to_ship', 'Production complete — ready to ship'), ('ready_to_ship', 'Ready'),
('shipped', 'Shipped — awaiting invoice'), ('shipped', 'Shipped'),
('invoicing', 'Awaiting invoice / payment'), ('invoicing', 'Invoicing'),
('paid', 'Paid'), ('paid', 'Paid'),
('complete', 'Complete'), ('complete', 'Done'),
('cancelled', 'Cancelled'), ('cancelled', 'Cancelled'),
], ],
compute='_compute_workflow_stage', compute='_compute_workflow_stage',
@@ -199,13 +199,30 @@ class SaleOrder(models.Model):
) % (tag or 'single-line')) ) % (tag or 'single-line'))
continue continue
# Recipe: first line's coating -> recipe_id. # Recipe priority (Sub 9):
# 1. Line's explicit process variant
# 2. Line's part default variant
# 3. Line's coating recipe_id
# 4. Any recipe-type process node (last-ditch fallback)
recipe = False recipe = False
for ln in lines: for ln in lines:
cc = ln.x_fc_coating_config_id if ('x_fc_process_variant_id' in ln._fields
if cc and 'recipe_id' in cc._fields and cc.recipe_id: and ln.x_fc_process_variant_id):
recipe = cc.recipe_id recipe = ln.x_fc_process_variant_id
break break
if not recipe:
for ln in lines:
pc = ln.x_fc_part_catalog_id
if (pc and 'default_process_id' in pc._fields
and pc.default_process_id):
recipe = pc.default_process_id
break
if not recipe:
for ln in lines:
cc = ln.x_fc_coating_config_id
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
recipe = cc.recipe_id
break
if not recipe: if not recipe:
recipe = self.env['fusion.plating.process.node'].search( recipe = self.env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1, [('node_type', '=', 'recipe')], limit=1,

View File

@@ -5,30 +5,10 @@ access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_worko
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0 access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0 access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0 access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0 access_fp_job_node_override_legacy_operator,fusion.plating.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_node_override_legacy_supervisor,fusion.plating.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_job_node_override_legacy_manager,fusion.plating.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_fp_bridge_mrp_workorder_supervisor fp.bridge.mrp.workorder.supervisor mrp_workorder.model_mrp_workorder fusion_plating.group_fusion_plating_supervisor 1 0 0 0
6 access_fp_bridge_mrp_production_manager fp.bridge.mrp.production.manager mrp.model_mrp_production fusion_plating.group_fusion_plating_manager 1 1 1 0
7 access_fp_bridge_mrp_production_supervisor fp.bridge.mrp.production.supervisor mrp.model_mrp_production fusion_plating.group_fusion_plating_supervisor 1 0 0 0
access_fp_job_node_override_operator fp.job.node.override.operator model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_job_node_override_supervisor fp.job.node.override.supervisor model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_job_node_override_manager fp.job.node.override.manager model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_recipe_config_wizard_supervisor fp.recipe.config.wizard.supervisor model_fp_recipe_config_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 0
9 access_fp_recipe_config_wizard_manager fp.recipe.config.wizard.manager model_fp_recipe_config_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
10 access_fp_recipe_config_wizard_line_supervisor fp.recipe.config.wizard.line.supervisor model_fp_recipe_config_wizard_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
11 access_fp_recipe_config_wizard_line_manager fp.recipe.config.wizard.line.manager model_fp_recipe_config_wizard_line fusion_plating.group_fusion_plating_manager 1 1 1 1
12 access_fp_job_consumption_operator access_fp_job_node_override_legacy_operator fp.job.consumption.operator fusion.plating.job.node.override.operator model_fp_job_consumption model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_operator 1 1 0 1 0 0
13 access_fp_job_consumption_supervisor access_fp_job_node_override_legacy_supervisor fp.job.consumption.supervisor fusion.plating.job.node.override.supervisor model_fp_job_consumption model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
14 access_fp_job_consumption_manager access_fp_job_node_override_legacy_manager fp.job.consumption.manager fusion.plating.job.node.override.manager model_fp_job_consumption model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_work_role_operator fp.work.role.operator model_fp_work_role fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_work_role_manager fp.work.role.manager model_fp_work_role fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_proficiency_operator fp.operator.proficiency.operator model_fp_operator_proficiency fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_proficiency_supervisor fp.operator.proficiency.supervisor model_fp_operator_proficiency fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_proficiency_manager fp.operator.proficiency.manager model_fp_operator_proficiency fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_template_operator fp.qc.checklist.template.operator model_fp_qc_checklist_template fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_qc_template_supervisor fp.qc.checklist.template.supervisor model_fp_qc_checklist_template fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_template_manager fp.qc.checklist.template.manager model_fp_qc_checklist_template fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_template_line_operator fp.qc.checklist.template.line.operator model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_qc_template_line_supervisor fp.qc.checklist.template.line.supervisor model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_template_line_manager fp.qc.checklist.template.line.manager model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_check_operator fusion.plating.quality.check.operator model_fusion_plating_quality_check fusion_plating.group_fusion_plating_operator 1 1 1 0
access_fp_qc_check_supervisor fusion.plating.quality.check.supervisor model_fusion_plating_quality_check fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_check_manager fusion.plating.quality.check.manager model_fusion_plating_quality_check fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_check_line_operator fusion.plating.quality.check.line.operator model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_operator 1 1 1 0
access_fp_qc_check_line_supervisor fusion.plating.quality.check.line.supervisor model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_check_line_manager fusion.plating.quality.check.line.manager model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -95,7 +95,6 @@
string="Assigned To" string="Assigned To"
required="1" required="1"
options="{'no_create': True}"/> options="{'no_create': True}"/>
<field name="x_fc_work_role_id" readonly="1"/>
<field name="x_fc_wo_kind" widget="badge" readonly="1" <field name="x_fc_wo_kind" widget="badge" readonly="1"
decoration-info="x_fc_wo_kind == 'wet'" decoration-info="x_fc_wo_kind == 'wet'"
decoration-warning="x_fc_wo_kind == 'bake'" decoration-warning="x_fc_wo_kind == 'bake'"

View File

@@ -14,11 +14,12 @@
<field name="model">sale.order</field> <field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/> <field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<!-- Manufacturing: right after Transfers (from configurator). --> <!-- Manufacturing: right after Transfers (from configurator).
Always visible (no invisible-on-zero) so users have a
navigation entry point even when the SO has no MO yet. -->
<xpath expr="//button[@name='action_view_pickings']" position="after"> <xpath expr="//button[@name='action_view_pickings']" position="after">
<button name="action_view_productions" type="object" <button name="action_view_productions" type="object"
class="oe_stat_button" icon="fa-industry" class="oe_stat_button" icon="fa-industry">
invisible="x_fc_production_count == 0">
<field name="x_fc_production_count" widget="statinfo" <field name="x_fc_production_count" widget="statinfo"
string="Manufacturing"/> string="Manufacturing"/>
</button> </button>
@@ -53,12 +54,20 @@
</button> </button>
</xpath> </xpath>
<!-- Hide Odoo's default state statusbar — replaced below by
the custom plating workflow statusbar that reflects the
real lifecycle (awaiting parts → in production → shipped → ...). -->
<xpath expr="//header//field[@name='state']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- ===== Contextual workflow buttons on the header ===== <!-- ===== Contextual workflow buttons on the header =====
One (sometimes two) visible at a time. Pattern mirrors One (sometimes two) visible at a time. Pattern mirrors
fusion_claims ADP handling — invisible bindings key off fusion_claims ADP handling — invisible bindings key off
the computed x_fc_workflow_stage selector. --> the computed x_fc_workflow_stage selector. -->
<xpath expr="//header" position="inside"> <xpath expr="//header" position="inside">
<field name="x_fc_workflow_stage" invisible="1"/> <field name="x_fc_workflow_stage" widget="statusbar"
statusbar_visible="draft,awaiting_parts,inspecting,in_production,ready_to_ship,shipped,invoicing,complete"/>
<field name="x_fc_assigned_manager_id" invisible="1"/> <field name="x_fc_assigned_manager_id" invisible="1"/>
<button name="action_fp_mark_inspected" <button name="action_fp_mark_inspected"

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Certificates', 'name': 'Fusion Plating — Certificates',
'version': '19.0.4.0.0', 'version': '19.0.5.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """ 'description': """
@@ -27,7 +27,6 @@ Includes Fischerscope thickness measurement data capture.
'fusion_plating_portal', 'fusion_plating_portal',
'fusion_plating_batch', 'fusion_plating_batch',
'fusion_plating_configurator', 'fusion_plating_configurator',
'mrp',
'sale_management', 'sale_management',
], ],
'data': [ 'data': [

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Phase 6 (Sub 11) — drop legacy MRP columns from certificate tables.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
cr.execute("ALTER TABLE fp_certificate DROP COLUMN IF EXISTS production_id")
cr.execute("ALTER TABLE fp_thickness_reading DROP COLUMN IF EXISTS production_id")
_logger.info("Sub 11: dropped production_id from fp_certificate + fp_thickness_reading")

View File

@@ -35,7 +35,8 @@ class FpCertificate(models.Model):
domain="[('customer_rank', '>', 0)]", domain="[('customer_rank', '>', 0)]",
) )
sale_order_id = fields.Many2one('sale.order', string='Sale Order') sale_order_id = fields.Many2one('sale.order', string='Sale Order')
production_id = fields.Many2one('mrp.production', string='Manufacturing Order') # Phase 6 (Sub 11) — production_id retired (MRP module gone).
# Certificates link via sale_order_id + portal_job_id natively.
portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job') portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job')
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.') part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
process_description = fields.Char( process_description = fields.Char(
@@ -84,23 +85,32 @@ class FpCertificate(models.Model):
string='Baths Used', string='Baths Used',
) )
@api.depends('production_id') @api.depends('sale_order_id')
def _compute_batch_ids(self): def _compute_batch_ids(self):
# Phase 6 (Sub 11) — walks fp.job via SO instead of mrp.production.
Batch = self.env.get('fusion.plating.batch') Batch = self.env.get('fusion.plating.batch')
Bath = self.env['fusion.plating.bath'] Bath = self.env['fusion.plating.bath']
Job = self.env.get('fp.job')
empty_batch = self.env['fusion.plating.batch'] empty_batch = self.env['fusion.plating.batch']
for rec in self: for rec in self:
if Batch is not None and rec.production_id: if Batch is None or Job is None or not rec.sale_order_id:
batches = Batch.search([
('production_id', '=', rec.production_id.id),
])
rec.batch_ids = batches
rec.batch_count = len(batches)
rec.bath_ids = batches.mapped('bath_id')
else:
rec.batch_ids = empty_batch rec.batch_ids = empty_batch
rec.batch_count = 0 rec.batch_count = 0
rec.bath_ids = Bath rec.bath_ids = Bath
continue
jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
if not jobs:
rec.batch_ids = empty_batch
rec.batch_count = 0
rec.bath_ids = Bath
continue
if 'x_fc_job_id' in Batch._fields:
batches = Batch.search([('x_fc_job_id', 'in', jobs.ids)])
else:
batches = empty_batch
rec.batch_ids = batches
rec.batch_count = len(batches)
rec.bath_ids = batches.mapped('bath_id')
state = fields.Selection( state = fields.Selection(
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')], [('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
string='Status', default='draft', tracking=True, required=True, string='Status', default='draft', tracking=True, required=True,
@@ -289,12 +299,12 @@ class FpCertificate(models.Model):
'Cannot issue CoC "%(name)s" — customer "%(cust)s" ' 'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
'requires actual thickness readings on every CoC ' 'requires actual thickness readings on every CoC '
'(Nadcap / aerospace).\n\nLog Fischerscope readings ' '(Nadcap / aerospace).\n\nLog Fischerscope readings '
'against MO %(mo)s via the Tablet Station before ' 'against the job for SO %(so)s via the Tablet Station '
'issuing.' 'before issuing.'
) % { ) % {
'name': rec.name or rec.display_name, 'name': rec.name or rec.display_name,
'cust': rec.partner_id.name, 'cust': rec.partner_id.name,
'mo': rec.production_id.name if rec.production_id else '?', 'so': rec.sale_order_id.name if rec.sale_order_id else '?',
}) })
rec.state = 'issued' rec.state = 'issued'
rec.message_post(body=_('Certificate issued.')) rec.message_post(body=_('Certificate issued.'))

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from odoo import fields, models from odoo import api, fields, models
class FpThicknessReading(models.Model): class FpThicknessReading(models.Model):
@@ -20,9 +20,8 @@ class FpThicknessReading(models.Model):
certificate_id = fields.Many2one( certificate_id = fields.Many2one(
'fp.certificate', string='Certificate', ondelete='cascade', 'fp.certificate', string='Certificate', ondelete='cascade',
) )
production_id = fields.Many2one( # Phase 6 (Sub 11) — production_id retired (MRP module gone).
'mrp.production', string='Manufacturing Order', # Thickness readings link via certificate_id and quality_check_id.
)
reading_number = fields.Integer( reading_number = fields.Integer(
string='Reading #', default=1, help='Sequence number (n=1, n=2, n=3).', string='Reading #', default=1, help='Sequence number (n=1, n=2, n=3).',
) )
@@ -65,3 +64,14 @@ class FpThicknessReading(models.Model):
measuring_time_seconds = fields.Integer( measuring_time_seconds = fields.Integer(
string='Measuring Time (sec)', default=120, string='Measuring Time (sec)', default=120,
) )
@api.depends('reading_number', 'nip_mils', 'certificate_id')
def _compute_display_name(self):
for rec in self:
ctx = rec.certificate_id.display_name or ''
label = 'Reading #%d' % (rec.reading_number or 0)
if rec.nip_mils:
label = '%s (%.4f mils)' % (label, rec.nip_mils)
if ctx:
label = '%s%s' % (label, ctx)
rec.display_name = label

View File

@@ -72,7 +72,6 @@
<field name="certificate_type"/> <field name="certificate_type"/>
<field name="partner_id"/> <field name="partner_id"/>
<field name="sale_order_id"/> <field name="sale_order_id"/>
<field name="production_id"/>
<field name="portal_job_id"/> <field name="portal_job_id"/>
<field name="issue_date"/> <field name="issue_date"/>
</group> </group>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Configurator', 'name': 'Fusion Plating — Configurator',
'version': '19.0.14.2.0', 'version': '19.0.17.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """ 'description': """
@@ -49,12 +49,13 @@ Provides:
'views/fp_quote_configurator_views.xml', 'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/fp_configurator_menu.xml',
'views/fp_sale_description_template_views.xml', 'views/fp_sale_description_template_views.xml',
'wizard/fp_direct_order_wizard_views.xml', 'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_add_from_so_wizard_views.xml', 'wizard/fp_add_from_so_wizard_views.xml',
'wizard/fp_add_from_quote_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_part_catalog_import_wizard_views.xml',
'views/fp_configurator_menu.xml',
'data/fp_sale_description_template_data.xml', 'data/fp_sale_description_template_data.xml',
], ],
'assets': { 'assets': {

View File

@@ -37,6 +37,28 @@ _CLONABLE_FIELDS = (
) )
def _list_variants(part):
"""Return a list of {id, label, is_default, node_count} for a part's variants."""
Node = part.env['fusion.plating.process.node']
variants = part.process_variant_ids.sorted(
lambda v: (not v.is_default_variant, v.variant_label or v.name or '')
)
out = []
for v in variants:
node_count = Node.search_count([
('part_catalog_id', '=', part.id),
('id', 'child_of', v.id),
])
out.append({
'id': v.id,
'label': v.variant_label or v.name or '(unnamed)',
'name': v.name or '',
'is_default': bool(v.is_default_variant),
'node_count': node_count,
})
return out
def _clone_subtree(env, source, part, parent): def _clone_subtree(env, source, part, parent):
"""Recursively clone a process node subtree for a specific part. """Recursively clone a process node subtree for a specific part.
@@ -117,7 +139,7 @@ class FpPartComposerController(http.Controller):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/part/composer/state', type='jsonrpc', auth='user') @http.route('/fp/part/composer/state', type='jsonrpc', auth='user')
def state(self, part_id): def state(self, part_id):
"""Return part info plus the current default_process_id tree (or None).""" """Return part info, current default tree, and full variant list."""
part = request.env['fp.part.catalog'].browse(int(part_id)).exists() part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
if not part: if not part:
return {'ok': False, 'error': 'Part not found'} return {'ok': False, 'error': 'Part not found'}
@@ -134,6 +156,7 @@ class FpPartComposerController(http.Controller):
}, },
'has_tree': bool(root), 'has_tree': bool(root),
'root_id': root.id if root else False, 'root_id': root.id if root else False,
'variants': _list_variants(part),
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -157,18 +180,20 @@ class FpPartComposerController(http.Controller):
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Write — clone a template into the part # Write — create a new variant by cloning a template OR another variant
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/part/composer/load_template', type='jsonrpc', auth='user') @http.route('/fp/part/composer/load_template', type='jsonrpc', auth='user')
def load_template(self, part_id, template_id): def load_template(self, part_id, template_id, variant_label=None,
"""Clone a shared template into a part-scoped tree. make_default=None):
"""Clone a shared template into a NEW variant on this part.
Deletes any existing part-owned tree for this part first, then Unlike the previous behaviour (wipe & replace), this now adds a
deep-clones the template subtree with part ownership set. Finally variant alongside any existing ones. The first variant created
pins ``part.default_process_id`` to the new root. becomes the default; subsequent variants only become default if
``make_default`` is true.
The whole operation runs inside a savepoint — if anything fails If ``variant_label`` is omitted, the controller uses the
partway through, the part is left in its previous state. template's name as the label.
""" """
part = request.env['fp.part.catalog'].browse(int(part_id)).exists() part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
tpl = request.env['fusion.plating.process.node'].browse(int(template_id)).exists() tpl = request.env['fusion.plating.process.node'].browse(int(template_id)).exists()
@@ -181,38 +206,119 @@ class FpPartComposerController(http.Controller):
if tpl.node_type != 'recipe': if tpl.node_type != 'recipe':
return {'ok': False, 'error': 'Template must be a recipe-type node'} return {'ok': False, 'error': 'Template must be a recipe-type node'}
label = (variant_label or tpl.name or 'Variant').strip()
try: try:
with request.env.cr.savepoint(): with request.env.cr.savepoint():
# 1. Delete any prior part-owned tree for this part. # First variant on this part is always the default.
# parent_id has ondelete='cascade', so deleting root(s) is_first = not part.process_variant_ids
# wipes their descendants. Use search so we don't assume make_default_flag = bool(make_default) or is_first
# only default_process_id's tree exists.
prior = request.env['fusion.plating.process.node'].search([
('part_catalog_id', '=', part.id),
])
if prior:
prior.unlink()
# 2. Deep-clone the template subtree with part ownership.
new_root = _clone_subtree(request.env, tpl, part, parent=False) new_root = _clone_subtree(request.env, tpl, part, parent=False)
new_root.variant_label = label
new_root.is_default_variant = make_default_flag
# 3. Pin part.default_process_id to the new root. if make_default_flag:
part.default_process_id = new_root.id # Clear flag from any other variants and pin default_process_id.
others = part.process_variant_ids.filtered(
lambda v: v.id != new_root.id and v.is_default_variant
)
if others:
others.write({'is_default_variant': False})
part.default_process_id = new_root.id
node_count = request.env['fusion.plating.process.node'].search_count([ node_count = request.env['fusion.plating.process.node'].search_count([
('part_catalog_id', '=', part.id), ('id', 'child_of', new_root.id),
]) ])
_logger.info( _logger.info(
'Part Composer: cloned template %s (%s) → part %s (%s), %s nodes, by uid %s', 'Part Composer: variant "%s" cloned from template %s onto part %s (default=%s, %s nodes), uid %s',
tpl.id, tpl.name, part.id, part.display_name, label, tpl.id, part.id, make_default_flag, node_count, request.env.uid,
node_count, request.env.uid,
) )
return { return {
'ok': True, 'ok': True,
'root_id': new_root.id, 'root_id': new_root.id,
'node_count': node_count, 'node_count': node_count,
'variants': _list_variants(part),
} }
except Exception as exc: except Exception as exc:
_logger.exception('Part Composer load_template failed') _logger.exception('Part Composer load_template failed')
return {'ok': False, 'error': str(exc)} return {'ok': False, 'error': str(exc)}
# ------------------------------------------------------------------
# Variant CRUD
# ------------------------------------------------------------------
@http.route('/fp/part/composer/duplicate_variant', type='jsonrpc', auth='user')
def duplicate_variant(self, part_id, source_variant_id, variant_label=None):
"""Deep-copy an existing variant into a new variant on the same part."""
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
src = request.env['fusion.plating.process.node'].browse(int(source_variant_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
if not src or src.part_catalog_id.id != part.id or src.parent_id:
return {'ok': False, 'error': 'Invalid source variant'}
label = (variant_label or ((src.variant_label or src.name or 'Variant') + ' (copy)')).strip()
try:
with request.env.cr.savepoint():
new_root = _clone_subtree(request.env, src, part, parent=False)
new_root.variant_label = label
new_root.is_default_variant = False # never auto-default a duplicate
node_count = request.env['fusion.plating.process.node'].search_count([
('id', 'child_of', new_root.id),
])
return {
'ok': True,
'root_id': new_root.id,
'node_count': node_count,
'variants': _list_variants(part),
}
except Exception as exc:
_logger.exception('Part Composer duplicate_variant failed')
return {'ok': False, 'error': str(exc)}
@http.route('/fp/part/composer/rename_variant', type='jsonrpc', auth='user')
def rename_variant(self, part_id, variant_id, variant_label):
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
if not v or v.part_catalog_id.id != part.id or v.parent_id:
return {'ok': False, 'error': 'Invalid variant'}
label = (variant_label or '').strip()
if not label:
return {'ok': False, 'error': 'Label cannot be empty'}
v.variant_label = label
return {'ok': True, 'variants': _list_variants(part)}
@http.route('/fp/part/composer/set_default_variant', type='jsonrpc', auth='user')
def set_default_variant(self, part_id, variant_id):
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
ok = part.action_set_default_variant(int(variant_id))
if not ok:
return {'ok': False, 'error': 'Variant does not belong to this part'}
return {'ok': True, 'variants': _list_variants(part)}
@http.route('/fp/part/composer/delete_variant', type='jsonrpc', auth='user')
def delete_variant(self, part_id, variant_id):
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
if not part:
return {'ok': False, 'error': 'Part not found'}
if not v or v.part_catalog_id.id != part.id or v.parent_id:
return {'ok': False, 'error': 'Invalid variant'}
if v.is_default_variant and len(part.process_variant_ids) > 1:
return {'ok': False,
'error': 'Cannot delete the default variant. Set another variant as default first.'}
try:
with request.env.cr.savepoint():
if part.default_process_id.id == v.id:
part.default_process_id = False
# ondelete=cascade on parent_id wipes descendants.
v.unlink()
return {'ok': True, 'variants': _list_variants(part)}
except Exception as exc:
_logger.exception('Part Composer delete_variant failed')
return {'ok': False, 'error': str(exc)}

View File

@@ -14,4 +14,12 @@
<field name="company_id" eval="False"/> <field name="company_id" eval="False"/>
</record> </record>
<record id="seq_fp_direct_order_wizard" model="ir.sequence">
<field name="name">Fusion Plating: Direct Order Draft</field>
<field name="code">fp.direct.order.wizard</field>
<field name="prefix">DOD-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo> </odoo>

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
# Sub 9 — Process Variants per Part. Runs on upgrade to 19.0.15.0.0.
#
# For every part that had a default_process_id, mark its root node as
# the default variant and seed a friendly label. Idempotent (NULL guards).
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install — nothing to migrate
_logger.info("Sub 9: backfilling process variant flags")
# Step 1: Mark each part's existing default_process_id root as the
# default variant. The flag is cleared on every other root so we
# land in a consistent "exactly one default" state.
cr.execute("""
UPDATE fusion_plating_process_node
SET is_default_variant = FALSE
WHERE parent_id IS NULL
AND node_type = 'recipe'
AND part_catalog_id IS NOT NULL
""")
_logger.info("Sub 9: cleared is_default_variant on %d roots", cr.rowcount)
cr.execute("""
UPDATE fusion_plating_process_node node
SET is_default_variant = TRUE
FROM fp_part_catalog part
WHERE part.default_process_id = node.id
AND node.parent_id IS NULL
AND node.node_type = 'recipe'
AND node.part_catalog_id = part.id
""")
_logger.info(
"Sub 9: flagged is_default_variant on %d roots (one per part with default_process_id)",
cr.rowcount,
)
# Step 2: Seed variant_label='Default' on the now-flagged variants
# so the picker shows something readable. Only fills NULL/empty.
cr.execute("""
UPDATE fusion_plating_process_node
SET variant_label = 'Default'
WHERE is_default_variant = TRUE
AND (variant_label IS NULL OR variant_label = '')
""")
_logger.info("Sub 9: seeded variant_label='Default' on %d records", cr.rowcount)
_logger.info("Sub 9: migration complete")

View File

@@ -154,14 +154,36 @@ class FpPartCatalog(models.Model):
# Sub 3 — part's cloned process tree. NULL until the user first # Sub 3 — part's cloned process tree. NULL until the user first
# composes a process. The Composer client action sets this to the # composes a process. The Composer client action sets this to the
# root node of the cloned tree. # root node of the cloned tree.
#
# Sub 9 — multiple variants per part. `default_process_id` now points
# to "the variant flagged is_default_variant". `process_variant_ids`
# is the full set; estimators pick one per order line.
default_process_id = fields.Many2one( default_process_id = fields.Many2one(
'fusion.plating.process.node', 'fusion.plating.process.node',
string='Default Process', string='Default Process',
domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe')]", domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe'), "
help='Root of this part\'s composed process tree. Use the ' "('parent_id', '=', False)]",
'Compose button to edit. When a job runs for this part, ' help='Root of this part\'s default process variant. Use the '
'work orders are generated from this tree.', 'Compose button to edit. When an order does not pick a '
'specific variant, this one is used.',
) )
process_variant_ids = fields.One2many(
'fusion.plating.process.node',
'part_catalog_id',
string='Process Variants',
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
help='All recipe variants composed for this part. Each order line '
'picks one (or falls back to the default).',
)
process_variant_count = fields.Integer(
string='Variants',
compute='_compute_process_variant_count',
)
@api.depends('process_variant_ids')
def _compute_process_variant_count(self):
for rec in self:
rec.process_variant_count = len(rec.process_variant_ids)
# ---- Direct-order defaults (Phase C — C4) ---- # ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one( x_fc_default_coating_config_id = fields.Many2one(
@@ -404,6 +426,28 @@ class FpPartCatalog(models.Model):
'target': 'current', 'target': 'current',
} }
def action_set_default_variant(self, variant_id):
"""Flip the default variant for this part.
Clears the flag from any other variant and pins
`default_process_id` to the chosen one. Called by the Composer
when the estimator switches default in the variant picker.
"""
self.ensure_one()
Node = self.env['fusion.plating.process.node']
new_default = Node.browse(int(variant_id)).exists()
if not new_default or new_default.part_catalog_id.id != self.id:
return False
# Clear flag on any other variant; set on the new one.
siblings = self.process_variant_ids.filtered(
lambda v: v.id != new_default.id and v.is_default_variant
)
if siblings:
siblings.write({'is_default_variant': False})
new_default.is_default_variant = True
self.default_process_id = new_default.id
return True
def action_view_customer(self): def action_view_customer(self):
self.ensure_one() self.ensure_one()
return { return {

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from odoo import fields, models from odoo import api, fields, models
class FpPricingComplexitySurcharge(models.Model): class FpPricingComplexitySurcharge(models.Model):
@@ -19,6 +19,15 @@ class FpPricingComplexitySurcharge(models.Model):
) )
surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.') surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.')
@api.depends('complexity', 'surcharge_percent')
def _compute_display_name(self):
labels = dict(self._fields['complexity'].selection)
for rec in self:
label = labels.get(rec.complexity, rec.complexity or '')
if rec.surcharge_percent:
label = '%s +%g%%' % (label, rec.surcharge_percent)
rec.display_name = label or 'Surcharge'
_sql_constraints = [ _sql_constraints = [
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)', ('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
'Only one surcharge per complexity level per rule.'), 'Only one surcharge per complexity level per rule.'),

View File

@@ -52,3 +52,24 @@ class FpProcessNode(models.Model):
'Picks which physical property of the part to multiply by ' 'Picks which physical property of the part to multiply by '
'the per-unit rate: weight (Lbs) or surface area (Sq in).', 'the per-unit rate: weight (Lbs) or surface area (Sq in).',
) )
# ---- Process Variants (per-part) ----------------------------------------
# A part can carry multiple recipe-root trees ("variants"). Examples:
# "Standard ENP", "Selective Masking", "Rework". Each order line picks a
# variant; the MO walker resolves through it. One variant per part is the
# default — used when the order line doesn't pick one explicitly.
#
# Variant identification only applies to root nodes (parent_id IS NULL,
# node_type='recipe') with a part_catalog_id set. Non-root nodes carry
# these fields too because they sit on the same model, but they're only
# meaningful on roots.
is_default_variant = fields.Boolean(
string='Default Variant',
help='When ticked, this variant is used by default for new orders '
'of this part. Exactly one variant per part is the default.',
)
variant_label = fields.Char(
string='Variant Label',
help='Friendly label shown in the variant picker '
'(e.g. "Standard ENP", "Selective Masking", "Rework").',
)

View File

@@ -510,8 +510,53 @@ class FpQuoteConfigurator(models.Model):
'fp.quote.configurator') or 'New' 'fp.quote.configurator') or 'New'
return super().create(vals_list) return super().create(vals_list)
def action_promote_to_direct_order(self):
"""Sub 10 — push this quote onto a Direct Order draft.
Replaces the legacy 1-line-SO creation. The estimator picks an
existing draft for the customer (consolidating multiple quotes
onto one PO) or spawns a fresh draft. The quote stays in
`draft` state until the Direct Order is confirmed; that confirm
flips the quote to `won` and back-links the SO.
"""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('Only draft quotes can be promoted.'))
if self.sale_order_id:
raise UserError(_(
'A sale order has already been created for this quote.'
))
if not self.part_catalog_id:
raise UserError(_(
'Pick a part catalog entry before promoting this quote.'
))
if not self.coating_config_id:
raise UserError(_(
'Pick a coating configuration before promoting this quote.'
))
existing_line = self.env['fp.direct.order.line'].search([
('quote_id', '=', self.id),
('wizard_id.state', '=', 'draft'),
], limit=1)
if existing_line:
raise UserError(_(
'This quote is already on draft "%s". Open that draft '
'and remove its line if you want to move it elsewhere.'
) % existing_line.wizard_id.name)
return {
'type': 'ir.actions.act_window',
'name': _('Add Quote to Direct Order'),
'res_model': 'fp.quote.promote.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_quote_id': self.id},
}
def action_create_quotation(self): def action_create_quotation(self):
"""Create a sale.order from this configurator session.""" """LEGACY (Sub 10): kept for backwards-compat with any in-flight
records or external triggers. New flow is via
action_promote_to_direct_order.
"""
self.ensure_one() self.ensure_one()
if self.state != 'draft': if self.state != 'draft':
raise UserError(_('Only draft configurators can create quotations.')) raise UserError(_('Only draft configurators can create quotations.'))

View File

@@ -139,11 +139,45 @@ class SaleOrder(models.Model):
'margin fields should render "n/a" in the UI.', 'margin fields should render "n/a" in the UI.',
) )
x_fc_workorder_count = fields.Integer( # NB. The compute lives in fusion_plating_bridge_mrp. We keep a
string='Active WOs', # stub field here so configurator's SO view (loaded before
compute='_compute_workorder_count', # bridge_mrp on `-u`) can reference the field by name. bridge_mrp's
# `fields.Integer(compute=…)` redeclaration fills in the compute on
# top of this stub during its own load pass.
x_fc_workorder_count = fields.Integer(string='Work Orders')
# Sub 9 — process variant summary across order lines. Renders one
# variant label when all lines share one, otherwise "Mixed (N)".
x_fc_process_summary = fields.Char(
string='Process',
compute='_compute_process_summary',
help='Process variant(s) used by this order. Drives WO generation.',
) )
@api.depends(
'order_line.x_fc_process_variant_id',
'order_line.x_fc_part_catalog_id.default_process_id',
)
def _compute_process_summary(self):
for so in self:
variants = []
for line in so.order_line:
if not (line.x_fc_part_catalog_id or line.x_fc_coating_config_id):
continue # non-plating line
variant = (line.x_fc_process_variant_id
or line.x_fc_part_catalog_id.default_process_id)
if variant and variant not in variants:
variants.append(variant)
if not variants:
so.x_fc_process_summary = False
elif len(variants) == 1:
v = variants[0]
so.x_fc_process_summary = (
v.variant_label or v.name or 'Default'
)
else:
so.x_fc_process_summary = 'Mixed (%d variants)' % len(variants)
# ---- Phase E: list view helpers ---- # ---- Phase E: list view helpers ----
x_fc_wo_completion = fields.Char( x_fc_wo_completion = fields.Char(
string='WO Progress', string='WO Progress',
@@ -307,34 +341,6 @@ class SaleOrder(models.Model):
- sum(refunds.mapped('amount_total')) - sum(refunds.mapped('amount_total'))
) )
@api.depends('name')
def _compute_workorder_count(self):
for rec in self:
rec.x_fc_workorder_count = 0
names = [so.name for so in self if so.name]
if not names:
return
WO = self.env['mrp.workorder'].sudo()
rows = WO.read_group(
[('production_id.origin', 'in', names),
('state', 'not in', ('done', 'cancel'))],
['production_id'],
['production_id'],
lazy=False,
)
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
if origin:
totals[origin] = totals.get(origin, 0) + r['__count']
for rec in self:
rec.x_fc_workorder_count = totals.get(rec.name, 0)
def action_view_workorders(self): def action_view_workorders(self):
self.ensure_one() self.ensure_one()
return { return {

View File

@@ -60,6 +60,19 @@ class SaleOrderLine(models.Model):
string='Linked Quote', string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.', 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.
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')]",
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.',
)
x_fc_archived = fields.Boolean( x_fc_archived = fields.Boolean(
string='Archived', string='Archived',
default=False, default=False,
@@ -226,6 +239,16 @@ class SaleOrderLine(models.Model):
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
return vals return vals
@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."""
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
@api.onchange('x_fc_coating_config_id') @api.onchange('x_fc_coating_config_id')
def _onchange_coating_clears_thickness(self): def _onchange_coating_clears_thickness(self):
"""Clear the thickness picker when coating config changes. """Clear the thickness picker when coating config changes.

View File

@@ -25,6 +25,8 @@ access_fp_add_from_so_wizard_estimator,fp.add.from.so.wizard.estimator,model_fp_
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1 access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quote_promote_wizard_estimator,fp.quote.promote.wizard.estimator,model_fp_quote_promote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_quote_promote_wizard_manager,fp.quote.promote.wizard.manager,model_fp_quote_promote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0 access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating_configurator.group_fp_estimator,1,1,1,1 access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
25 access_fp_add_from_so_wizard_manager fp.add.from.so.wizard.manager model_fp_add_from_so_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
26 access_fp_add_from_quote_wizard_estimator fp.add.from.quote.wizard.estimator model_fp_add_from_quote_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
27 access_fp_add_from_quote_wizard_manager fp.add.from.quote.wizard.manager model_fp_add_from_quote_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
28 access_fp_quote_promote_wizard_estimator fp.quote.promote.wizard.estimator model_fp_quote_promote_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
29 access_fp_quote_promote_wizard_manager fp.quote.promote.wizard.manager model_fp_quote_promote_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
30 access_fp_sale_assembly_user fp.sale.assembly.user model_fp_sale_assembly base.group_user 1 0 0 0
31 access_fp_sale_assembly_estimator fp.sale.assembly.estimator model_fp_sale_assembly fusion_plating_configurator.group_fp_estimator 1 1 1 1
32 access_fp_sale_assembly_manager fp.sale.assembly.manager model_fp_sale_assembly fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -4,9 +4,9 @@
// Copyright 2026 Nexa Systems Inc. // Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0) // License OPL-1 (Odoo Proprietary License v1.0)
// //
// Thin wrapper around the existing recipe tree editor. Gives a part // Sub 9 — multi-variant Composer. Each part can carry several recipe trees
// its own composed process tree by cloning a shared template, then // (e.g. "Standard ENP", "Selective Masking", "Rework"). One is the default;
// hands off to the fp_recipe_tree_editor action for edits. // estimators may pick a non-default variant on a per-order basis.
// //
// Odoo 19 conventions: // Odoo 19 conventions:
// * Backend OWL: static template + static props = ["*"] // * Backend OWL: static template + static props = ["*"]
@@ -27,8 +27,6 @@ export class FpPartProcessComposer extends Component {
this.action = useService("action"); this.action = useService("action");
this.notification = useService("notification"); this.notification = useService("notification");
// Pull part_id out of the client action's params (set by
// fp.part.catalog.action_open_part_composer on the server).
const params = (this.props.action && this.props.action.params) || {}; const params = (this.props.action && this.props.action.params) || {};
this.partId = params.part_id || null; this.partId = params.part_id || null;
@@ -38,9 +36,11 @@ export class FpPartProcessComposer extends Component {
part: null, part: null,
hasTree: false, hasTree: false,
rootId: null, rootId: null,
variants: [],
templates: [], templates: [],
selectedTemplateId: null, selectedTemplateId: null,
loadingTemplate: false, newVariantLabel: "",
busy: false,
}); });
onMounted(() => this.refresh()); onMounted(() => this.refresh());
@@ -67,10 +67,9 @@ export class FpPartProcessComposer extends Component {
this.state.part = stateRes.part; this.state.part = stateRes.part;
this.state.hasTree = stateRes.has_tree; this.state.hasTree = stateRes.has_tree;
this.state.rootId = stateRes.root_id || null; this.state.rootId = stateRes.root_id || null;
this.state.variants = stateRes.variants || [];
this.state.templates = tplRes.templates || []; this.state.templates = tplRes.templates || [];
// Default the dropdown selection to the first template so the
// user can click Load immediately.
if (this.state.templates.length > 0 && !this.state.selectedTemplateId) { if (this.state.templates.length > 0 && !this.state.selectedTemplateId) {
this.state.selectedTemplateId = this.state.templates[0].id; this.state.selectedTemplateId = this.state.templates[0].id;
} }
@@ -87,46 +86,110 @@ export class FpPartProcessComposer extends Component {
this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null; this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null;
} }
async onLoadTemplate() { onNewLabelInput(ev) {
if (!this.state.selectedTemplateId) return; this.state.newVariantLabel = ev.target.value || "";
const confirmReplace = this.state.hasTree }
? window.confirm("This will replace the current process tree for this part. Continue?")
: true;
if (!confirmReplace) return;
this.state.loadingTemplate = true; async onAddVariantFromTemplate() {
try { if (!this.state.selectedTemplateId) {
this.notification.add("Pick a template first.", { type: "warning" });
return;
}
const label = (this.state.newVariantLabel || "").trim()
|| (this.state.templates.find(t => t.id === this.state.selectedTemplateId)?.name)
|| "Variant";
await this._busy(async () => {
const res = await rpc("/fp/part/composer/load_template", { const res = await rpc("/fp/part/composer/load_template", {
part_id: this.partId, part_id: this.partId,
template_id: this.state.selectedTemplateId, template_id: this.state.selectedTemplateId,
variant_label: label,
}); });
if (!res.ok) throw new Error(res.error || "Load failed."); if (!res.ok) throw new Error(res.error || "Add variant failed.");
this.notification.add( this.notification.add(
`Template loaded ${res.node_count} nodes cloned into this part's tree.`, `Variant "${label}" added (${res.node_count} nodes).`,
{ type: "success" } { type: "success" },
); );
this.state.newVariantLabel = "";
await this.refresh(); await this.refresh();
// Hand off directly to the tree editor so the user can
// immediately start customising.
this.openRecipeEditor(res.root_id); this.openRecipeEditor(res.root_id);
});
}
async onDuplicateVariant(variantId) {
const src = this.state.variants.find(v => v.id === variantId);
const proposed = window.prompt(
"Name for the duplicated variant:",
(src?.label || "Variant") + " (copy)",
);
if (!proposed) return;
await this._busy(async () => {
const res = await rpc("/fp/part/composer/duplicate_variant", {
part_id: this.partId,
source_variant_id: variantId,
variant_label: proposed,
});
if (!res.ok) throw new Error(res.error || "Duplicate failed.");
this.notification.add(`Variant "${proposed}" created.`, { type: "success" });
await this.refresh();
this.openRecipeEditor(res.root_id);
});
}
async onRenameVariant(variantId) {
const v = this.state.variants.find(x => x.id === variantId);
const proposed = window.prompt("New label:", v?.label || "");
if (!proposed) return;
await this._busy(async () => {
const res = await rpc("/fp/part/composer/rename_variant", {
part_id: this.partId,
variant_id: variantId,
variant_label: proposed,
});
if (!res.ok) throw new Error(res.error || "Rename failed.");
await this.refresh();
});
}
async onSetDefaultVariant(variantId) {
await this._busy(async () => {
const res = await rpc("/fp/part/composer/set_default_variant", {
part_id: this.partId,
variant_id: variantId,
});
if (!res.ok) throw new Error(res.error || "Set default failed.");
this.notification.add("Default variant updated.", { type: "success" });
await this.refresh();
});
}
async onDeleteVariant(variantId) {
const v = this.state.variants.find(x => x.id === variantId);
if (!window.confirm(`Delete variant "${v?.label || ""}"? This removes its tree.`)) return;
await this._busy(async () => {
const res = await rpc("/fp/part/composer/delete_variant", {
part_id: this.partId,
variant_id: variantId,
});
if (!res.ok) throw new Error(res.error || "Delete failed.");
this.notification.add("Variant deleted.", { type: "success" });
await this.refresh();
});
}
async _busy(fn) {
this.state.busy = true;
try {
await fn();
} catch (err) { } catch (err) {
this.notification.add( this.notification.add(err.message || String(err), { type: "danger" });
`Load failed: ${err.message || err}`,
{ type: "danger" }
);
} finally { } finally {
this.state.loadingTemplate = false; this.state.busy = false;
} }
} }
openRecipeEditor(rootId) { openRecipeEditor(rootId) {
const id = rootId || this.state.rootId; const id = rootId || this.state.rootId;
if (!id) return; if (!id) return;
// The existing fp_recipe_tree_editor reads recipe_id from
// this.props.action?.context — pass it via `context`, not `params`.
// Label the editor as "Process Editor …" so it doesn't collide with
// "Process Composer …" in the breadcrumb stack; the two pages are
// distinct roles and should read differently in the trail.
this.action.doAction({ this.action.doAction({
type: "ir.actions.client", type: "ir.actions.client",
tag: "fp_recipe_tree_editor", tag: "fp_recipe_tree_editor",
@@ -137,10 +200,6 @@ export class FpPartProcessComposer extends Component {
} }
backToPart() { backToPart() {
// clearBreadcrumbs: "Back" is semantically a RETURN, not a forward
// navigation — reset the stack to just the part form so repeated
// round-trips (part → composer → editor → back) don't accumulate
// duplicate entries.
this.action.doAction({ this.action.doAction({
type: "ir.actions.act_window", type: "ir.actions.act_window",
res_model: "fp.part.catalog", res_model: "fp.part.catalog",

View File

@@ -5,6 +5,7 @@
Part of the Fusion Plating product family. Part of the Fusion Plating product family.
OWL template for the part-scoped Process Composer client action. OWL template for the part-scoped Process Composer client action.
Sub 9 — multi-variant Composer.
--> -->
<templates xml:space="preserve"> <templates xml:space="preserve">
@@ -36,53 +37,105 @@
</div> </div>
</div> </div>
<div class="o_fp_part_composer_loader"> <div class="o_fp_part_composer_variants mt-3">
<label>Load Existing Process:</label> <h4>Process Variants</h4>
<select class="form-select" t-on-change="onSelectTemplate"> <p class="text-muted small">
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id"> Add as many variants as you need (e.g. "Standard", "Selective Masking", "Rework").
<option t-att-value="tpl.id" One variant is the default; order lines may pick another at entry time.
t-att-selected="tpl.id == state.selectedTemplateId"> </p>
<t t-esc="tpl.name"/> <t t-if="state.variants.length === 0">
</option> <div class="o_fp_part_composer_empty">
</t> <i class="fa fa-cogs fa-2x"/>
</select> <p>No variants yet. Pick a template below and add the first one.</p>
<button class="btn btn-primary"
t-on-click="onLoadTemplate"
t-att-disabled="state.loadingTemplate or !state.selectedTemplateId">
<t t-if="state.loadingTemplate">
<i class="fa fa-spinner fa-spin"/>
<span> Loading…</span>
</t>
<t t-else="">
<t t-if="state.hasTree">Replace with Selected</t>
<t t-else="">Load</t>
</t>
</button>
</div>
<div class="o_fp_part_composer_tree">
<t t-if="state.hasTree">
<div class="o_fp_part_composer_hint">
<p>This part has a composed process tree. Click below to open the
full tree editor where you can add, remove, reorder, and configure
the process nodes.</p>
<button class="btn btn-primary o_fp_part_composer_editor_btn"
t-on-click="() => this.openRecipeEditor()">
<i class="fa fa-sitemap"/>
<span>Open Process Editor</span>
</button>
</div> </div>
</t> </t>
<t t-else=""> <t t-else="">
<div class="o_fp_part_composer_empty"> <table class="table table-sm align-middle">
<i class="fa fa-cogs fa-3x"/> <thead>
<p>No process composed yet.</p> <tr>
<p class="text-muted"> <th>Default</th>
Pick a template above and click <strong>Load</strong> to get started. <th>Label</th>
</p> <th>Recipe Name</th>
</div> <th class="text-end">Nodes</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.variants" t-as="v" t-key="v.id">
<tr>
<td>
<t t-if="v.is_default">
<span class="badge bg-success">Default</span>
</t>
<t t-else="">
<button class="btn btn-link btn-sm p-0"
t-att-disabled="state.busy"
t-on-click="() => this.onSetDefaultVariant(v.id)">
Set Default
</button>
</t>
</td>
<td>
<strong t-esc="v.label"/>
</td>
<td class="text-muted" t-esc="v.name"/>
<td class="text-end" t-esc="v.node_count"/>
<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
</button>
<button class="btn btn-sm btn-secondary me-1"
t-att-disabled="state.busy"
t-on-click="() => this.onDuplicateVariant(v.id)">
<i class="fa fa-copy"/> Duplicate
</button>
<button class="btn btn-sm btn-secondary me-1"
t-att-disabled="state.busy"
t-on-click="() => this.onRenameVariant(v.id)">
<i class="fa fa-i-cursor"/> Rename
</button>
<button class="btn btn-sm btn-outline-danger"
t-att-disabled="state.busy"
t-on-click="() => this.onDeleteVariant(v.id)">
<i class="fa fa-trash"/>
</button>
</td>
</tr>
</t>
</tbody>
</table>
</t> </t>
</div> </div>
<div class="o_fp_part_composer_loader mt-4">
<h4>Add Variant from Template</h4>
<div class="d-flex gap-2 align-items-center flex-wrap">
<label class="me-2">Template:</label>
<select class="form-select" style="max-width: 280px;"
t-on-change="onSelectTemplate">
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id"
t-att-selected="tpl.id == state.selectedTemplateId">
<t t-esc="tpl.name"/>
</option>
</t>
</select>
<input class="form-control" style="max-width: 240px;"
placeholder="Variant label (e.g. Standard ENP)"
t-att-value="state.newVariantLabel"
t-on-input="onNewLabelInput"/>
<button class="btn btn-primary"
t-on-click="onAddVariantFromTemplate"
t-att-disabled="state.busy or !state.selectedTemplateId">
<i class="fa fa-plus"/> Add Variant
</button>
</div>
<p class="text-muted small mt-1">
Leave the label blank to use the template name. The first variant added becomes the default automatically.
</p>
</div>
</t> </t>
</div> </div>
</t> </t>

View File

@@ -35,6 +35,12 @@
action="action_fp_direct_order_wizard" action="action_fp_direct_order_wizard"
sequence="5"/> sequence="5"/>
<menuitem id="menu_fp_direct_order_drafts"
name="Direct Order Drafts"
parent="menu_fp_sales"
action="action_fp_direct_order_drafts"
sequence="6"/>
<menuitem id="menu_fp_quotations" <menuitem id="menu_fp_quotations"
name="Quotations" name="Quotations"
parent="menu_fp_sales" parent="menu_fp_sales"

View File

@@ -167,20 +167,30 @@
<page string="Process" name="process"> <page string="Process" name="process">
<group> <group>
<field name="default_process_id" readonly="1" <field name="default_process_id" readonly="1"
help="Use the Compose button to set up this part's process tree."/> help="The variant used by default when an order line does not pick another."/>
<field name="process_variant_count" readonly="1"/>
</group> </group>
<div class="mt-2"> <div class="mt-2">
<button name="action_open_part_composer" type="object" <button name="action_open_part_composer" type="object"
string="Compose" string="Compose"
icon="fa-wrench" icon="fa-wrench"
class="btn-primary" class="btn-primary"
help="Open the Process Composer to load a template and edit this part's tree."/> help="Open the Process Composer to manage this part's process variants."/>
</div> </div>
<p class="text-muted mt-3"> <p class="text-muted mt-3">
The <strong>Compose</strong> button opens the Process Composer where you can The <strong>Compose</strong> button opens the Process Composer where you can add
load a shared template and customise it for this part. When a job runs for multiple process <em>variants</em> for this part — for example "Standard ENP",
this part, work orders are generated from the composed tree. "Selective Masking", "Rework". One variant is flagged as default; estimators
may pick a different variant on a per-order basis.
</p> </p>
<field name="process_variant_ids" readonly="1">
<list>
<field name="is_default_variant" widget="boolean_toggle" readonly="1"/>
<field name="variant_label"/>
<field name="name"/>
<field name="estimated_duration" optional="hide"/>
</list>
</field>
</page> </page>
<page string="Dimensions &amp; Complexity" name="dimensions"> <page string="Dimensions &amp; Complexity" name="dimensions">
<group> <group>

View File

@@ -13,12 +13,12 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Quote Configurator"> <form string="Quote Configurator">
<header> <header>
<button name="action_create_quotation" <button name="action_promote_to_direct_order"
string="Create Quotation" string="Add to Direct Order"
type="object" type="object"
class="btn-primary" class="btn-primary"
confirm="This will create a Sale Order from this configurator session. Continue?" invisible="state != 'draft'"
invisible="state != 'draft'"/> help="Add this quote as a line on a Direct Order draft. Multiple quotes can land on the same draft so one PO covers them all."/>
<button name="action_recalculate_price" <button name="action_recalculate_price"
string="Recalculate" string="Recalculate"
type="object" type="object"

View File

@@ -62,10 +62,9 @@
<button name="action_view_workorders" <button name="action_view_workorders"
type="object" type="object"
class="oe_stat_button" class="oe_stat_button"
icon="fa-cogs" icon="fa-cogs">
invisible="x_fc_workorder_count == 0">
<field name="x_fc_workorder_count" widget="statinfo" <field name="x_fc_workorder_count" widget="statinfo"
string="Active WOs"/> string="Work Orders"/>
</button> </button>
<button name="action_view_ncrs" <button name="action_view_ncrs"
type="object" type="object"
@@ -93,6 +92,7 @@
<field name="x_fc_configurator_id" readonly="1"/> <field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_part_catalog_id"/> <field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/> <field name="x_fc_coating_config_id"/>
<field name="x_fc_process_summary" readonly="1"/>
</group> </group>
<group string="RFQ / PO"> <group string="RFQ / PO">
<field name="x_fc_po_number"/> <field name="x_fc_po_number"/>
@@ -182,17 +182,29 @@
</group> </group>
</page> </page>
</xpath> </xpath>
<!-- Make the standard customer-facing description column togglable.
The base sale.order line view shows `name` always; flipping it
to optional lets estimators hide/show it like the other columns. -->
<xpath expr="//field[@name='order_line']/list/field[@name='name']" position="attributes">
<attribute name="string">Customer-Facing</attribute>
<attribute name="optional">show</attribute>
</xpath>
<xpath expr="//field[@name='order_line']/list/field[@name='product_uom_qty']" position="before"> <xpath expr="//field[@name='order_line']/list/field[@name='product_uom_qty']" position="before">
<field name="x_fc_part_catalog_id" optional="show"/> <field name="x_fc_part_catalog_id" optional="show"/>
<field name="x_fc_description_template_id" <field name="x_fc_description_template_id"
domain="[('part_catalog_id', '=', x_fc_part_catalog_id)]" domain="[('part_catalog_id', '=', x_fc_part_catalog_id)]"
options="{'no_create': True}" context="{'default_part_catalog_id': x_fc_part_catalog_id}"
invisible="not x_fc_part_catalog_id" invisible="not x_fc_part_catalog_id"
optional="show"/> optional="show"/>
<field name="x_fc_internal_description" <field name="x_fc_internal_description"
placeholder="Shop-floor workflow instructions (prints on WO / traveler)" placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
optional="hide"/> optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/> <field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_process_variant_id"
string="Variant"
options="{'no_create': True}"
invisible="not x_fc_part_catalog_id"
optional="show"/>
<field name="x_fc_thickness_id" <field name="x_fc_thickness_id"
options="{'no_create': True}" options="{'no_create': True}"
invisible="not x_fc_coating_config_id" invisible="not x_fc_coating_config_id"

View File

@@ -6,4 +6,5 @@ from . import fp_direct_order_wizard
from . import fp_direct_order_line from . import fp_direct_order_line
from . import fp_add_from_so_wizard from . import fp_add_from_so_wizard
from . import fp_add_from_quote_wizard from . import fp_add_from_quote_wizard
from . import fp_quote_promote_wizard
from . import fp_part_catalog_import_wizard from . import fp_part_catalog_import_wizard

View File

@@ -45,17 +45,7 @@ class FpAddFromQuoteWizard(models.TransientModel):
for q in self.quote_ids: for q in self.quote_ids:
if not q.part_catalog_id or not q.coating_config_id: if not q.part_catalog_id or not q.coating_config_id:
continue continue
final = q.estimator_override_price or q.calculated_price Line._create_from_quote(q, wizard)
unit = (final / q.quantity) if (final and q.quantity) else 0.0
Line.create({
'wizard_id': wizard.id,
'part_catalog_id': q.part_catalog_id.id,
'coating_config_id': q.coating_config_id.id,
'quantity': int(q.quantity) or 1,
'unit_price': unit,
'quote_id': q.id,
'line_description': q.notes or False,
})
copied += 1 copied += 1
if not copied: if not copied:

View File

@@ -7,7 +7,8 @@ from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
class FpDirectOrderLine(models.TransientModel): class FpDirectOrderLine(models.Model):
"""Sub 9 — persistent so the parent draft survives navigation."""
_name = 'fp.direct.order.line' _name = 'fp.direct.order.line'
_description = 'Fusion Plating - Direct Order Line' _description = 'Fusion Plating - Direct Order Line'
_order = 'sequence, id' _order = 'sequence, id'
@@ -59,38 +60,50 @@ class FpDirectOrderLine(models.TransientModel):
string='Additional Treatments', string='Additional Treatments',
help='Extra pre/post treatments applied to this line.', help='Extra pre/post treatments applied to this line.',
) )
# Sub 9 — explicit per-line process variant override. NULL means
# "use the part's default variant".
process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('part_catalog_id', '=', part_catalog_id), "
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
help='Pick a specific process variant for this line. Leave blank '
'to use the part\'s default variant. Manage variants via the '
'Process Composer on the part form.',
)
# Read-only preview of the process tree that WILL drive WO generation # Read-only preview of the process tree that WILL drive WO generation
# for this line. Resolution priority: # for this line. Resolution priority:
# 1. Part's composed process (fp.part.catalog.default_process_id) # 1. Explicit process_variant_id (estimator pick)
# — a part-scoped customisation set via the Process Composer. # 2. Part's default variant (fp.part.catalog.default_process_id)
# 2. Primary Treatment's default recipe (fp.coating.config.recipe_id) # 3. Primary Treatment's default recipe (fp.coating.config.recipe_id)
# — the shared template used if the part has no override.
# Shown so operators can see *what will run* before confirming the
# order. Treatment answers the "what coating"; process answers the
# "how" — they're distinct but coupled via the resolution chain.
effective_process_id = fields.Many2one( effective_process_id = fields.Many2one(
'fusion.plating.process.node', 'fusion.plating.process.node',
string='Process', string='Process',
compute='_compute_effective_process', compute='_compute_effective_process',
help='Process tree that will generate work orders for this line. ' help='Process tree that will generate work orders for this line.',
'Uses the part-composed process if one exists, otherwise the '
"primary treatment's default recipe.",
) )
effective_process_source = fields.Char( effective_process_source = fields.Char(
compute='_compute_effective_process', compute='_compute_effective_process',
help='Tells the estimator whether the process comes from the ' help='Tells the estimator where the process comes from: '
'part (customised) or the coating (shared default).', 'an explicit variant pick, the part default, or the coating default.',
) )
@api.depends('part_catalog_id.default_process_id', @api.depends('process_variant_id',
'part_catalog_id.default_process_id',
'coating_config_id.recipe_id') 'coating_config_id.recipe_id')
def _compute_effective_process(self): def _compute_effective_process(self):
for rec in self: for rec in self:
if rec.process_variant_id:
rec.effective_process_id = rec.process_variant_id
label = rec.process_variant_id.variant_label or rec.process_variant_id.name
rec.effective_process_source = 'Variant: %s' % (label or 'unnamed')
continue
part_proc = (rec.part_catalog_id.default_process_id part_proc = (rec.part_catalog_id.default_process_id
if rec.part_catalog_id else False) if rec.part_catalog_id else False)
if part_proc: if part_proc:
rec.effective_process_id = part_proc rec.effective_process_id = part_proc
rec.effective_process_source = 'Part (customised)' rec.effective_process_source = 'Part default'
continue continue
cc_proc = (rec.coating_config_id.recipe_id cc_proc = (rec.coating_config_id.recipe_id
if rec.coating_config_id else False) if rec.coating_config_id else False)
@@ -101,6 +114,14 @@ class FpDirectOrderLine(models.TransientModel):
rec.effective_process_id = False rec.effective_process_id = False
rec.effective_process_source = False rec.effective_process_source = False
@api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self):
"""Clear variant pick when the part changes (variants are part-scoped)."""
for rec in self:
if (rec.process_variant_id
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
rec.process_variant_id = False
# ---- Qty / price ---- # ---- Qty / price ----
quantity = fields.Integer(string='Qty', default=1, required=True) quantity = fields.Integer(string='Qty', default=1, required=True)
currency_id = fields.Many2one(related='wizard_id.currency_id') currency_id = fields.Many2one(related='wizard_id.currency_id')
@@ -113,6 +134,19 @@ class FpDirectOrderLine(models.TransientModel):
currency_field='currency_id', currency_field='currency_id',
compute='_compute_line_subtotal', compute='_compute_line_subtotal',
) )
# Sub 9 — taxes per line. Defaults from the FP-SERVICE product's
# sale taxes; fiscal-position-mapped from the customer when the
# wizard creates the SO line. Overridable per row.
tax_ids = fields.Many2many(
'account.tax',
relation='fp_direct_order_line_tax_rel',
column1='line_id',
column2='tax_id',
string='Taxes',
domain="[('type_tax_use', '=', 'sale')]",
help='Sales taxes applied to this line. Defaults from the plating '
'service product; override for tax-exempt or special-rate orders.',
)
# ---- Scheduling / fulfilment ---- # ---- Scheduling / fulfilment ----
part_deadline = fields.Date( part_deadline = fields.Date(
@@ -258,6 +292,27 @@ class FpDirectOrderLine(models.TransientModel):
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids: if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
# Seed default taxes from the FP-SERVICE product, fiscal-position
# mapped from the customer. Only fills when the user hasn't set
# taxes manually.
if not self.tax_ids:
self._seed_default_taxes()
def _seed_default_taxes(self):
"""Pick taxes from the FP-SERVICE product, mapped through the
customer's fiscal position when one is set."""
self.ensure_one()
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
if not product or not product.taxes_id:
return
taxes = product.taxes_id
partner = self.wizard_id.partner_id
if partner and partner.property_account_position_id:
taxes = partner.property_account_position_id.map_tax(taxes)
if taxes:
self.tax_ids = [(6, 0, taxes.ids)]
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id') @api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
def _onchange_lookup_price(self): def _onchange_lookup_price(self):
@@ -343,6 +398,30 @@ class FpDirectOrderLine(models.TransientModel):
_apply(match) _apply(match)
# ---- Helpers ---- # ---- Helpers ----
@api.model
def _create_from_quote(self, quote, wizard):
"""Seed a Direct Order line from a `fp.quote.configurator` row.
Single source of truth for both the per-quote "Promote" action and
the bulk "Add From Quotes" sub-wizard — keeps the field mapping
in one place so the two flows can never drift.
"""
if not quote.part_catalog_id or not quote.coating_config_id:
raise UserError(_(
'Quote %s has no part or coating set; cannot seed a line.'
) % (quote.name or quote.id))
final = quote.estimator_override_price or quote.calculated_price
unit = (final / quote.quantity) if (final and quote.quantity) else 0.0
return self.create({
'wizard_id': wizard.id,
'part_catalog_id': quote.part_catalog_id.id,
'coating_config_id': quote.coating_config_id.id,
'quantity': int(quote.quantity) or 1,
'unit_price': unit,
'quote_id': quote.id,
'line_description': quote.notes or False,
})
def _get_or_bump_revision(self): def _get_or_bump_revision(self):
"""Return the part to use for the SO line, optionally bumping revision.""" """Return the part to use for the SO line, optionally bumping revision."""
self.ensure_one() self.ensure_one()

View File

@@ -7,23 +7,60 @@ from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
class FpDirectOrderWizard(models.TransientModel): class FpDirectOrderWizard(models.Model):
"""Direct order entry for repeat customers. """Direct order entry for repeat customers.
Sub 9 — converted from TransientModel to persistent Model so an
estimator can save a draft, navigate elsewhere (part form, Process
Composer, customer record), and come back. Entries persist across
sessions; finished drafts move to state='confirmed' and link to the
sale.order they produced.
Creates a sale.order (in draft / quotation state) with one Creates a sale.order (in draft / quotation state) with one
sale.order.line per wizard line. The user reviews the resulting sale.order.line per wizard line. The user reviews the resulting
quotation, makes any adjustments, and clicks Send / Confirm quotation, makes any adjustments, and clicks Send / Confirm
manually. The wizard does NOT auto-confirm and does NOT auto-email manually. The wizard does NOT auto-confirm and does NOT auto-email
the customer — that was deliberately removed in Sub 1 after the the customer.
client requested a review step before anything leaves the shop.
""" """
_name = 'fp.direct.order.wizard' _name = 'fp.direct.order.wizard'
_description = 'Fusion Plating - Direct Order Entry' _description = 'Fusion Plating - Direct Order Entry'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc, id desc'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
state = fields.Selection(
[('draft', 'Draft'),
('confirmed', 'Confirmed'),
('cancelled', 'Cancelled')],
string='Status', default='draft', required=True, copy=False,
tracking=True,
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
readonly=True, copy=False, tracking=True,
help='Set when the draft is confirmed — points to the SO created.',
)
user_id = fields.Many2one(
'res.users', string='Estimator',
default=lambda self: self.env.user, tracking=True,
)
# ---- Customer ---- # ---- Customer ----
# NB. Persistent model: partner is optional at draft-creation time so
# the estimator can spawn a blank draft and fill it in. The
# action_create_order method enforces the non-null check at confirm.
partner_id = fields.Many2one( partner_id = fields.Many2one(
'res.partner', string='Customer', required=True, 'res.partner', string='Customer',
domain="[('customer_rank', '>', 0)]", domain="[('customer_rank', '>', 0)]",
tracking=True,
) )
partner_invoice_id = fields.Many2one( partner_invoice_id = fields.Many2one(
'res.partner', string='Invoice Address', 'res.partner', string='Invoice Address',
@@ -46,7 +83,7 @@ class FpDirectOrderWizard(models.TransientModel):
string='Planned Start', default=fields.Date.context_today, string='Planned Start', default=fields.Date.context_today,
) )
internal_deadline = fields.Date(string='Internal Deadline') internal_deadline = fields.Date(string='Internal Deadline')
customer_deadline = fields.Date(string='Customer Deadline') customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
# ---- Order flags (Phase B) ---- # ---- Order flags (Phase B) ----
is_blanket_order = fields.Boolean( is_blanket_order = fields.Boolean(
@@ -65,7 +102,7 @@ class FpDirectOrderWizard(models.TransientModel):
# wizard now accepts a PO Pending flag in lieu of a PO#/doc; the # wizard now accepts a PO Pending flag in lieu of a PO#/doc; the
# underlying SO is confirmed with a chase activity scheduled for # underlying SO is confirmed with a chase activity scheduled for
# the expected date. # the expected date.
po_number = fields.Char(string='Customer PO #') po_number = fields.Char(string='Customer PO #', tracking=True)
po_attachment_file = fields.Binary(string='PO Document') po_attachment_file = fields.Binary(string='PO Document')
po_attachment_filename = fields.Char(string='PO Filename') po_attachment_filename = fields.Char(string='PO Filename')
po_pending = fields.Boolean( po_pending = fields.Boolean(
@@ -101,6 +138,16 @@ class FpDirectOrderWizard(models.TransientModel):
progress_initial_percent = fields.Float( progress_initial_percent = fields.Float(
string='Progress - Initial %', default=50.0, string='Progress - Initial %', default=50.0,
) )
# Sub 9 — payment terms surfaced on the wizard so the resulting SO
# picks them up. Auto-seeded from the customer's invoice-strategy
# default (or the partner's property_payment_term_id), then nudged
# again when the strategy changes (COD/Prepay → Immediate Payment).
# User can override per draft.
payment_term_id = fields.Many2one(
'account.payment.term', string='Payment Terms',
help='Carries onto the sale order. Auto-fills from the customer '
'invoice strategy default; COD / Prepay forces immediate payment.',
)
# ---- Notes ---- # ---- Notes ----
notes = fields.Text(string='Internal Notes') notes = fields.Text(string='Internal Notes')
@@ -121,6 +168,17 @@ class FpDirectOrderWizard(models.TransientModel):
# ---- Missing info banner ---- # ---- Missing info banner ----
missing_info_msg = fields.Char(compute='_compute_missing_info_msg') missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
# ---- Persistence helpers ----
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == _('New'):
vals['name'] = (
self.env['ir.sequence'].next_by_code('fp.direct.order.wizard')
or _('New Direct Order')
)
return super().create(vals_list)
# ---- Computes ---- # ---- Computes ----
@api.depends('line_ids.line_subtotal', 'line_ids.quantity') @api.depends('line_ids.line_subtotal', 'line_ids.quantity')
def _compute_totals(self): def _compute_totals(self):
@@ -151,7 +209,7 @@ class FpDirectOrderWizard(models.TransientModel):
# ---- Onchange ---- # ---- Onchange ----
@api.onchange('partner_id') @api.onchange('partner_id')
def _onchange_partner_id(self): def _onchange_partner_id(self):
"""Seed invoice defaults + default addresses when customer changes.""" """Seed invoice defaults + addresses + payment terms when customer changes."""
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields: if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0 self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
@@ -159,11 +217,93 @@ class FpDirectOrderWizard(models.TransientModel):
addrs = self.partner_id.address_get(['invoice', 'delivery']) addrs = self.partner_id.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
# Seed payment terms: customer's invoice-strategy default wins;
# fallback to partner.property_payment_term_id.
term = False
isd = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
)
if isd and isd.payment_term_id:
term = isd.payment_term_id
# Also seed strategy from the same record if not already set.
if not self.invoice_strategy:
self.invoice_strategy = isd.default_strategy
if not self.deposit_percent:
self.deposit_percent = isd.default_deposit_percent or 0.0
if not term and self.partner_id.property_payment_term_id:
term = self.partner_id.property_payment_term_id
self.payment_term_id = term or False
else: else:
self.partner_invoice_id = False self.partner_invoice_id = False
self.partner_shipping_id = False self.partner_shipping_id = False
self.payment_term_id = False
# Re-apply strategy → terms mapping after partner switch.
self._apply_strategy_payment_term()
@api.onchange('invoice_strategy')
def _onchange_invoice_strategy(self):
"""Map the strategy onto sensible payment terms."""
self._apply_strategy_payment_term()
def _apply_strategy_payment_term(self):
"""Mapping rule:
- cod_prepay → Immediate Payment (Odoo's stock term)
- deposit / progress / net_terms → keep what the partner default
already gave us; if blank, leave it blank so the user can pick.
Never overwrites an explicit user choice for non-COD strategies —
only fills in when payment_term_id is empty.
"""
for rec in self:
if rec.invoice_strategy == 'cod_prepay':
immediate = rec.env.ref(
'account.account_payment_term_immediate',
raise_if_not_found=False,
)
if immediate:
rec.payment_term_id = immediate.id
# ---- Actions ---- # ---- Actions ----
@api.model
def action_open_new_draft(self):
"""Create a fresh draft record and open it in form view.
Wired to the "New Direct Order" menu / button. Creating the
record up front means the draft is auto-persisted from the
first keystroke — the estimator can navigate away (to the
part form, the Process Composer, etc.) without losing work.
"""
draft = self.create({})
return {
'type': 'ir.actions.act_window',
'name': _('Direct Order'),
'res_model': 'fp.direct.order.wizard',
'res_id': draft.id,
'view_mode': 'form',
'target': 'current',
}
def action_cancel(self):
"""Move the draft to cancelled state. Kept for audit; not deleted."""
self.write({'state': 'cancelled'})
return True
def action_reopen(self):
"""Reopen a cancelled draft for further editing."""
self.filtered(lambda r: r.state == 'cancelled').write({'state': 'draft'})
return True
def action_view_sale_order(self):
self.ensure_one()
if not self.sale_order_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_add_from_prior_so(self): def action_add_from_prior_so(self):
"""Open a sub-wizard to copy lines from a prior sale.order.""" """Open a sub-wizard to copy lines from a prior sale.order."""
self.ensure_one() self.ensure_one()
@@ -207,6 +347,8 @@ class FpDirectOrderWizard(models.TransientModel):
Sub 1 in the Fine-Tuning Initiative roadmap (CLAUDE.md). Sub 1 in the Fine-Tuning Initiative roadmap (CLAUDE.md).
""" """
self.ensure_one() self.ensure_one()
if not self.partner_id:
raise UserError(_('Pick a customer before confirming.'))
if not self.line_ids: if not self.line_ids:
raise UserError(_('Add at least one part line before confirming.')) raise UserError(_('Add at least one part line before confirming.'))
@@ -269,6 +411,7 @@ class FpDirectOrderWizard(models.TransientModel):
'x_fc_invoice_strategy': self.invoice_strategy, 'x_fc_invoice_strategy': self.invoice_strategy,
'x_fc_deposit_percent': self.deposit_percent, 'x_fc_deposit_percent': self.deposit_percent,
'x_fc_progress_initial_percent': self.progress_initial_percent, 'x_fc_progress_initial_percent': self.progress_initial_percent,
'payment_term_id': self.payment_term_id.id or False,
'x_fc_delivery_method': self.delivery_method, 'x_fc_delivery_method': self.delivery_method,
'x_fc_is_blanket_order': self.is_blanket_order, 'x_fc_is_blanket_order': self.is_blanket_order,
'x_fc_block_partial_shipments': self.block_partial_shipments, 'x_fc_block_partial_shipments': self.block_partial_shipments,
@@ -312,11 +455,18 @@ class FpDirectOrderWizard(models.TransientModel):
'x_fc_start_at_node_id': line.start_at_node_id.id or False, 'x_fc_start_at_node_id': line.start_at_node_id.id or False,
'x_fc_is_one_off': line.is_one_off, 'x_fc_is_one_off': line.is_one_off,
'x_fc_quote_id': line.quote_id.id or False, 'x_fc_quote_id': line.quote_id.id or False,
'x_fc_process_variant_id': line.process_variant_id.id or False,
# Sub 5 — carry serial / job# / thickness onto the SO line. # Sub 5 — carry serial / job# / thickness onto the SO line.
# Revision snapshot auto-fills on SO-line create from the part. # Revision snapshot auto-fills on SO-line create from the part.
'x_fc_serial_id': line.serial_id.id or False, 'x_fc_serial_id': line.serial_id.id or False,
'x_fc_job_number': line.job_number or False, 'x_fc_job_number': line.job_number or False,
'x_fc_thickness_id': line.thickness_id.id or False, 'x_fc_thickness_id': line.thickness_id.id or False,
# Sub 9 — explicit tax override from the wizard line.
# When blank, Odoo will compute taxes from the product
# defaults at SO-line save time (the standard behaviour).
# NB. Odoo 19 renamed the SO line field to tax_ids.
'tax_ids': ([(6, 0, line.tax_ids.ids)]
if line.tax_ids else False),
})) }))
# 5. Create — stays in draft / quotation. Sub 1: user reviews # 5. Create — stays in draft / quotation. Sub 1: user reviews
@@ -324,6 +474,27 @@ class FpDirectOrderWizard(models.TransientModel):
# auto-email to the client. # auto-email to the client.
so = self.env['sale.order'].create(so_vals) so = self.env['sale.order'].create(so_vals)
# Mark this draft as confirmed and link the SO.
self.write({'state': 'confirmed', 'sale_order_id': so.id})
# Sub 10 — flip every linked quote to "won" now that an SO exists.
# We deliberately wait until SO creation rather than at promote
# time, because "won" should mean "the deal closed", not "we put
# it on a draft." A draft can still be cancelled.
linked_quotes = self.line_ids.mapped('quote_id').filtered(
lambda q: q.state in ('draft', 'sent', 'accepted')
)
if linked_quotes:
linked_quotes.write({
'state': 'confirmed',
'won_date': fields.Date.today(),
'sale_order_id': so.id,
})
for q in linked_quotes:
q.message_post(body=_(
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
) % {'doo': self.name, 'so': so.name})
# 6. Push-to-defaults (C4) — uses the resolved part cached # 6. Push-to-defaults (C4) — uses the resolved part cached
# during the build loop so rev-bumped lines write defaults to # during the build loop so rev-bumped lines write defaults to
# the NEW revision, not the pre-bump one. # the NEW revision, not the pre-bump one.

View File

@@ -6,12 +6,32 @@
<field name="model">fp.direct.order.wizard</field> <field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Direct Order Entry"> <form string="Direct Order Entry">
<header>
<button name="action_create_order" type="object"
string="Create &amp; Confirm Order"
class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_view_sale_order" type="object"
string="Open Sale Order"
class="btn-primary"
invisible="state != 'confirmed' or not sale_order_id"/>
<button name="action_cancel" type="object"
string="Discard Draft"
confirm="Mark this draft as cancelled? The data is preserved for audit."
invisible="state != 'draft'"/>
<button name="action_reopen" type="object"
string="Reopen Draft"
invisible="state != 'cancelled'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed"/>
</header>
<div class="alert alert-info py-2 mb-0 small" <div class="alert alert-info py-2 mb-0 small"
role="alert"> role="alert"
invisible="state != 'draft'">
<i class="fa fa-info-circle me-1"/> <i class="fa fa-info-circle me-1"/>
Changes are not saved until you click This draft is auto-saved as you edit. You can navigate away
<strong>Create &amp; Confirm Order</strong>. Closing this (open the part form, the Process Composer, etc.) and return
window (Esc or X) discards your entries. via <strong>Sales → Direct Order Drafts</strong>.
</div> </div>
<div class="alert alert-warning mb-0" <div class="alert alert-warning mb-0"
role="alert" role="alert"
@@ -20,11 +40,23 @@
<field name="missing_info_msg" readonly="1" nolabel="1"/> <field name="missing_info_msg" readonly="1" nolabel="1"/>
</div> </div>
<sheet> <sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<div class="o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
</div>
<div class="oe_title"> <div class="oe_title">
<h1>New Direct Order</h1> <label for="name" class="o_form_label"/>
<p class="text-muted"> <h1><field name="name" readonly="1"/></h1>
Skip the quotation stage - create a confirmed order <field name="user_id" readonly="state != 'draft'"
when the customer has already sent a PO. options="{'no_create': True}"/>
<p class="text-muted" invisible="state != 'draft'">
Skip the quotation stage — create a confirmed order
when the customer has already sent a PO. Drafts auto-save.
</p> </p>
</div> </div>
@@ -70,6 +102,8 @@
<group string="Fulfilment &amp; Invoicing"> <group string="Fulfilment &amp; Invoicing">
<field name="delivery_method"/> <field name="delivery_method"/>
<field name="invoice_strategy"/> <field name="invoice_strategy"/>
<field name="payment_term_id"
options="{'no_create': True}"/>
<label for="deposit_percent" <label for="deposit_percent"
invisible="invoice_strategy != 'deposit'"/> invisible="invoice_strategy != 'deposit'"/>
<div class="o_row" <div class="o_row"
@@ -112,12 +146,20 @@
options="{'no_create_edit': True}"/> options="{'no_create_edit': True}"/>
<field name="description_template_id" <field name="description_template_id"
domain="[('part_catalog_id', '=', part_catalog_id)]" domain="[('part_catalog_id', '=', part_catalog_id)]"
options="{'no_create': True}" context="{'default_part_catalog_id': part_catalog_id}"
invisible="not part_catalog_id" invisible="not part_catalog_id"
optional="hide"/> optional="hide"/>
<field name="line_description"
string="Customer-Facing"
optional="hide"/>
<field name="internal_description" <field name="internal_description"
optional="hide"/> optional="hide"/>
<field name="coating_config_id"/> <field name="coating_config_id"/>
<field name="process_variant_id"
string="Variant"
options="{'no_create': True}"
invisible="not part_catalog_id"
optional="show"/>
<field name="effective_process_id" <field name="effective_process_id"
string="Process" string="Process"
readonly="1" readonly="1"
@@ -141,6 +183,10 @@
<field name="unit_price" <field name="unit_price"
widget="monetary" widget="monetary"
options="{'currency_field': 'currency_id'}"/> options="{'currency_field': 'currency_id'}"/>
<field name="tax_ids"
widget="many2many_tags"
options="{'no_create': True}"
optional="show"/>
<field name="line_subtotal" <field name="line_subtotal"
widget="monetary" widget="monetary"
options="{'currency_field': 'currency_id'}" options="{'currency_field': 'currency_id'}"
@@ -163,6 +209,10 @@
<field name="coating_config_id"/> <field name="coating_config_id"/>
<field name="treatment_ids" <field name="treatment_ids"
widget="many2many_tags"/> widget="many2many_tags"/>
<field name="process_variant_id"
string="Process Variant"
options="{'no_create': True}"
invisible="not part_catalog_id"/>
<field name="effective_process_id" <field name="effective_process_id"
string="Effective Process" string="Effective Process"
readonly="1"/> readonly="1"/>
@@ -178,6 +228,9 @@
<field name="unit_price" <field name="unit_price"
widget="monetary" widget="monetary"
options="{'currency_field': 'currency_id'}"/> options="{'currency_field': 'currency_id'}"/>
<field name="tax_ids"
widget="many2many_tags"
options="{'no_create': True}"/>
<field name="line_subtotal" <field name="line_subtotal"
widget="monetary" widget="monetary"
options="{'currency_field': 'currency_id'}"/> options="{'currency_field': 'currency_id'}"/>
@@ -199,8 +252,9 @@
</group> </group>
<group string="Line Description"> <group string="Line Description">
<field name="description_template_id" <field name="description_template_id"
options="{'no_create': True, 'no_open': True}" domain="[('part_catalog_id', '=', part_catalog_id)]"
placeholder="Start typing to search saved descriptions..."/> context="{'default_part_catalog_id': part_catalog_id}"
placeholder="Start typing to search saved descriptions, or type a new name to create one..."/>
<label for="line_description" <label for="line_description"
string="Customer-Facing"/> string="Customer-Facing"/>
<field name="line_description" <field name="line_description"
@@ -245,29 +299,100 @@
</notebook> </notebook>
</sheet> </sheet>
<footer> <chatter/>
<button name="action_create_order"
type="object"
string="Create &amp; Confirm Order"
class="btn-primary"/>
<button string="Cancel"
special="cancel"
class="btn-secondary"
confirm="Discard this order? All header data and line items will be lost."/>
</footer>
</form> </form>
</field> </field>
</record> </record>
<!-- Form action — keeps the same external ID as before so existing
button references survive (act_window cannot be replaced by a
server action with the same xmlid). target='current' lets the
estimator breadcrumb between the wizard and the part form / Composer.
Odoo prompts to save unsaved changes when navigating away. -->
<record id="action_fp_direct_order_wizard" model="ir.actions.act_window"> <record id="action_fp_direct_order_wizard" model="ir.actions.act_window">
<field name="name">New Direct Order</field> <field name="name">New Direct Order</field>
<field name="res_model">fp.direct.order.wizard</field> <field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">form</field> <field name="view_mode">form</field>
<field name="target">new</field> <field name="target">current</field>
<!-- Use Odoo's built-in extra-large dialog size so the line <field name="context">{}</field>
table (10+ columns) isn't squeezed into ellipsis at the </record>
default modal width. Roughly 30% wider than the default. -->
<field name="context">{'dialog_size': 'extra-large'}</field> <!-- ===== Drafts list view (resume an in-flight order entry) ===== -->
<record id="view_fp_direct_order_wizard_list" model="ir.ui.view">
<field name="name">fp.direct.order.wizard.list</field>
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<list string="Direct Order Drafts"
decoration-info="state == 'draft'"
decoration-muted="state == 'cancelled'"
decoration-success="state == 'confirmed'">
<field name="name"/>
<field name="partner_id"/>
<field name="user_id"/>
<field name="po_number" optional="show"/>
<field name="customer_deadline" optional="hide"/>
<field name="total_line_count" optional="hide"/>
<field name="total_qty" optional="hide"/>
<field name="total_amount" widget="monetary"
options="{'currency_field': 'currency_id'}"
sum="Total"/>
<field name="currency_id" column_invisible="1"/>
<field name="create_date" optional="show"/>
<field name="sale_order_id" optional="hide"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-success="state == 'confirmed'"
decoration-muted="state == 'cancelled'"/>
</list>
</field>
</record>
<record id="view_fp_direct_order_wizard_search" model="ir.ui.view">
<field name="name">fp.direct.order.wizard.search</field>
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="po_number"/>
<field name="user_id"/>
<filter name="filter_draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="filter_confirmed" string="Confirmed"
domain="[('state', '=', 'confirmed')]"/>
<filter name="filter_cancelled" string="Cancelled"
domain="[('state', '=', 'cancelled')]"/>
<separator/>
<filter name="filter_my" string="My Drafts"
domain="[('user_id', '=', uid)]"/>
<group>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="group_partner" string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="group_user" string="Estimator"
context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_direct_order_drafts" model="ir.actions.act_window">
<field name="name">Direct Order Drafts</field>
<field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">list,form</field>
<field name="target">current</field>
<field name="search_view_id" ref="view_fp_direct_order_wizard_search"/>
<field name="context">{'search_default_filter_draft': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No drafts yet — start one!
</p>
<p>
Drafts persist across sessions. Save your progress, switch to a
part form, edit the Process Composer, and come back to finish.
</p>
</field>
</record> </record>
</odoo> </odoo>

View File

@@ -0,0 +1,112 @@
# -*- 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
from odoo.exceptions import UserError
class FpQuotePromoteWizard(models.TransientModel):
"""Chooser dialog: promote a won quote into a Direct Order draft.
Sub 10 — quote→direct-order handoff. The estimator picks either an
existing open draft for this customer (lets multiple quotes
consolidate onto a single PO) or creates a fresh draft.
"""
_name = 'fp.quote.promote.wizard'
_description = 'Promote Quote to Direct Order'
quote_id = fields.Many2one(
'fp.quote.configurator', required=True, readonly=True,
)
partner_id = fields.Many2one(
related='quote_id.partner_id', readonly=True,
)
quote_currency_id = fields.Many2one(
related='quote_id.currency_id', readonly=True,
)
target_mode = fields.Selection(
[('existing', 'Add to existing draft'),
('new', 'Create new Direct Order')],
string='Target', required=True, default='new',
)
target_wizard_id = fields.Many2one(
'fp.direct.order.wizard',
string='Existing Draft',
domain="[('state', '=', 'draft'), ('partner_id', '=', partner_id)]",
help='Pick an open draft for this customer. The quote is added '
'as a new line to that draft.',
)
open_drafts_count = fields.Integer(
compute='_compute_open_drafts_count',
help='Drafts currently open for this customer.',
)
@api.depends('partner_id')
def _compute_open_drafts_count(self):
DOO = self.env['fp.direct.order.wizard']
for rec in self:
rec.open_drafts_count = DOO.search_count([
('state', '=', 'draft'),
('partner_id', '=', rec.partner_id.id),
]) if rec.partner_id else 0
@api.onchange('partner_id', 'open_drafts_count')
def _onchange_default_target(self):
for rec in self:
if rec.open_drafts_count == 0:
rec.target_mode = 'new'
def action_promote(self):
self.ensure_one()
q = self.quote_id
# Re-check the not-already-promoted invariant — a separate user
# could have added this quote to a draft between the action open
# and the click, so we re-verify before mutating.
existing_line = self.env['fp.direct.order.line'].search([
('quote_id', '=', q.id),
('wizard_id.state', '=', 'draft'),
], limit=1)
if existing_line:
raise UserError(_(
'This quote is already on draft "%s". Open that draft '
'and remove its line if you want to move it elsewhere.'
) % existing_line.wizard_id.name)
# Resolve target draft.
if self.target_mode == 'existing':
if not self.target_wizard_id:
raise UserError(_('Pick an existing draft, or switch to '
'"Create new Direct Order".'))
target = self.target_wizard_id
if target.state != 'draft':
raise UserError(_(
'Draft "%s" is no longer in draft state.'
) % target.name)
else:
target = self.env['fp.direct.order.wizard'].create({
'partner_id': q.partner_id.id,
'currency_id': q.currency_id.id,
})
# Currency must match — Direct Order doesn't convert.
if target.currency_id != q.currency_id:
raise UserError(_(
'Quote currency (%s) does not match Direct Order '
'currency (%s). Re-quote in the order currency, or '
'create a new Direct Order in this quote\'s currency.'
) % (q.currency_id.name, target.currency_id.name))
# Seed the line.
self.env['fp.direct.order.line']._create_from_quote(q, target)
# Open the target draft so the estimator can keep adding lines.
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.direct.order.wizard',
'res_id': target.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_quote_promote_wizard_form" model="ir.ui.view">
<field name="name">fp.quote.promote.wizard.form</field>
<field name="model">fp.quote.promote.wizard</field>
<field name="arch" type="xml">
<form string="Promote Quote to Direct Order">
<sheet>
<div class="oe_title">
<h2>Add Quote to Direct Order</h2>
<p class="text-muted">
This quote will be added as a single line on a
Direct Order draft. Multiple quotes can land on
the same draft so one PO covers them all.
</p>
</div>
<group>
<field name="quote_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="quote_currency_id" readonly="1"/>
<field name="open_drafts_count" readonly="1"/>
</group>
<group>
<field name="target_mode" widget="radio"/>
<field name="target_wizard_id"
options="{'no_create': True}"
required="target_mode == 'existing'"
invisible="target_mode != 'existing'"/>
</group>
<div class="alert alert-info py-2 mb-0 small"
role="alert"
invisible="open_drafts_count != 0 or target_mode != 'new'">
<i class="fa fa-info-circle me-1"/>
No open drafts for this customer — a fresh Direct
Order will be created.
</div>
</sheet>
<footer>
<button name="action_promote" type="object"
string="Add to Direct Order"
class="btn-primary"/>
<button string="Cancel" special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from odoo import fields, models from odoo import api, fields, models
class FpInvoiceStrategyDefault(models.Model): class FpInvoiceStrategyDefault(models.Model):
@@ -33,6 +33,17 @@ class FpInvoiceStrategyDefault(models.Model):
) )
notes = fields.Text(string='Notes') notes = fields.Text(string='Notes')
@api.depends('partner_id', 'default_strategy')
def _compute_display_name(self):
labels = dict(self._fields['default_strategy'].selection)
for rec in self:
bits = []
if rec.partner_id:
bits.append(rec.partner_id.display_name)
if rec.default_strategy:
bits.append(labels.get(rec.default_strategy, rec.default_strategy))
rec.display_name = ''.join(bits) or 'Invoice Strategy'
_sql_constraints = [ _sql_constraints = [
('fp_invoice_strategy_partner_uniq', 'unique(partner_id)', ('fp_invoice_strategy_partner_uniq', 'unique(partner_id)',
'Only one invoice strategy default per customer.'), 'Only one invoice strategy default per customer.'),

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.5.1.0', 'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',
@@ -54,6 +54,9 @@ full design rationale and §6.2 of the implementation plan for task list.
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/fp_job_form_inherit.xml', 'views/fp_job_form_inherit.xml',
'views/sale_order_views.xml',
'views/fp_job_consumption_views.xml',
'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml', 'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml', 'views/legacy_menu_hide.xml',
'report/report_fp_job_sticker.xml', 'report/report_fp_job_sticker.xml',

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
#
# Phase 1 (Sub 11) — relocate ir.model.data XML IDs from
# fusion_plating_bridge_mrp to fusion_plating_jobs for the four
# models that moved: fp.work.role, fp.operator.proficiency,
# fp.qc.checklist.template (+line), fp.job.consumption.
#
# Pre-migration so Odoo's normal load pass sees the records under the
# new module owner, not as orphans pending deletion.
#
# Idempotent — `ON CONFLICT DO NOTHING` skips rows already migrated.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install — nothing to migrate
moves = [
# (xmlid pattern, list of model identifiers to move)
('model_fp_job_consumption',),
# ACL records (csv:id values get prefixed with the owning module)
('access_fp_job_consumption_%',),
]
for (pat,) in moves:
cr.execute(
"""
UPDATE ir_model_data
SET module = 'fusion_plating_jobs'
WHERE module = 'fusion_plating_bridge_mrp'
AND name LIKE %s
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d2
WHERE d2.module = 'fusion_plating_jobs'
AND d2.name = ir_model_data.name
)
""",
(pat,),
)
if cr.rowcount:
_logger.info(
"Sub 11: re-keyed %d ir.model.data rows matching %s -> fusion_plating_jobs",
cr.rowcount, pat,
)
# Views, actions, menus that the old module created
view_patterns = [
'view_fp_job_consumption_%',
'action_fp_job_consumption%',
'menu_fp_job_consumption%',
]
for pat in view_patterns:
cr.execute(
"""
UPDATE ir_model_data
SET module = 'fusion_plating_jobs'
WHERE module = 'fusion_plating_bridge_mrp'
AND name LIKE %s
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d2
WHERE d2.module = 'fusion_plating_jobs'
AND d2.name = ir_model_data.name
)
""",
(pat,),
)
if cr.rowcount:
_logger.info(
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating_jobs",
cr.rowcount, pat,
)
# Phase 1 swap: fp.job.consumption columns. Drop the legacy
# MRP-pointing columns (production_id, workorder_id) from the
# already-existing table — there are zero rows referencing MRP, and
# the new model declares job_id / step_id instead.
cr.execute(
"""
ALTER TABLE fp_job_consumption
DROP COLUMN IF EXISTS production_id,
DROP COLUMN IF EXISTS workorder_id
"""
)
_logger.info("Sub 11: dropped MRP columns on fp_job_consumption")

View File

@@ -27,3 +27,12 @@ from . import fusion_plating_kpi_value
# Phase 5 — Job Margin report. # Phase 5 — Job Margin report.
from . import report_fp_job_margin from . import report_fp_job_margin
# Phase 1 of MRP cut-out (Sub 11) — relocated from fusion_plating_bridge_mrp.
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
# back on jobs without a cycle.)
from . import fp_job_consumption
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the
# hr.employee shop-roles inherit live in fusion_plating core so every
# downstream module (cgp, bridge_mrp residue, etc.) sees them without a
# transitive dep on jobs.

View File

@@ -517,6 +517,12 @@ class FpJob(models.Model):
if self.env.context.get('fp_jobs_migration'): if self.env.context.get('fp_jobs_migration'):
return result return result
for job in self: for job in self:
# Auto-generate steps from the recipe — was previously only
# called by seed scripts, which meant real-life confirmed
# jobs sat with zero operations. Idempotent: the generator
# short-circuits when steps already exist.
if job.recipe_id and not job.step_ids:
job._generate_steps_from_recipe()
job._fp_create_portal_job() job._fp_create_portal_job()
job._fp_create_qc_check_if_needed() job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection() job._fp_create_racking_inspection()
@@ -526,36 +532,28 @@ class FpJob(models.Model):
def _fp_create_racking_inspection(self): def _fp_create_racking_inspection(self):
"""Auto-create a draft racking inspection on job confirm. """Auto-create a draft racking inspection on job confirm.
Mirrors bridge_mrp's behaviour for MO confirm. Best-effort: the Phase 9 — production_id is now optional on fp.racking.inspection,
legacy fp.racking.inspection model still requires a production_id so we always create one bound by `x_fc_job_id`. When the job is
(mrp.production), so we can only create one when this job is also linked to an MO (legacy bridge_mrp coexistence), populate
bound to an MO via bridge_mrp. Otherwise we skip cleanly — Phase production_id too so legacy reports keep working.
9 will flip the required-FK to fp.job.
Idempotent — if an inspection already exists for this job, skip.
""" """
self.ensure_one() self.ensure_one()
if 'fp.racking.inspection' not in self.env: if 'fp.racking.inspection' not in self.env:
return return
Inspection = self.env['fp.racking.inspection'].sudo() Inspection = self.env['fp.racking.inspection'].sudo()
# The model still requires production_id today. If the job has if 'x_fc_job_id' not in Inspection._fields:
# no MO link (which it won't in pure-native mode), skip rather # Schema not yet upgraded — skip.
# than crash. The link exists when fusion_plating_bridge_mrp is
# installed and a production was created in parallel.
production = False
if 'production_id' in self._fields and self.production_id:
production = self.production_id
elif 'mrp_production_id' in self._fields and getattr(
self, 'mrp_production_id', False):
production = self.mrp_production_id
if not production:
_logger.debug(
"Job %s: no MO link — skipping racking-inspection auto-create "
"(required production_id not yet on fp.job).", self.name,
)
return return
existing = Inspection.search([
('x_fc_job_id', '=', self.id),
], limit=1)
if existing:
return
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
vals = {'x_fc_job_id': self.id}
try: try:
vals = {'production_id': production.id}
if 'x_fc_job_id' in Inspection._fields:
vals['x_fc_job_id'] = self.id
Inspection.create(vals) Inspection.create(vals)
except Exception as e: except Exception as e:
_logger.warning( _logger.warning(

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# MRP-flavoured fields (production_id, workorder_id) replaced by their
# native fp.job / fp.job.step equivalents.
from odoo import api, fields, models, _
class FpJobConsumption(models.Model):
"""A single consumable drawdown charged to a plating job.
Sources include bath replenishment applied against a job, masking tape
rolls, PPE, nickel salts — anything that has a cost and should roll
into job costing.
Kept deliberately lightweight: one row per event, cost derived from
`product.standard_price` at log time (snapshot, not reactive).
"""
_name = 'fp.job.consumption'
_description = 'Fusion Plating — Job Consumption'
_order = 'logged_date desc, id desc'
job_id = fields.Many2one(
'fp.job', string='Plating Job',
required=True, ondelete='cascade', index=True,
)
step_id = fields.Many2one(
'fp.job.step', string='Job Step',
domain="[('job_id', '=', job_id)]",
ondelete='set null',
)
product_id = fields.Many2one(
'product.product', string='Product', required=True,
domain="[('sale_ok', '=', False)]",
)
product_name = fields.Char(
string='Product Name (snapshot)',
help='Free-text product label if no inventory product is linked.',
)
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
uom_id = fields.Many2one(
'uom.uom', string='UoM',
)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
unit_cost = fields.Monetary(
string='Unit Cost (snapshot)', currency_field='currency_id',
help='Taken from product.standard_price at log time.',
)
total_cost = fields.Monetary(
string='Total Cost', currency_field='currency_id',
compute='_compute_total_cost', store=True,
)
logged_date = fields.Datetime(
string='Logged', default=fields.Datetime.now,
)
logged_by_id = fields.Many2one(
'res.users', string='Logged By', default=lambda self: self.env.user,
)
source = fields.Selection(
[('replenishment', 'Bath Replenishment'),
('masking', 'Masking Material'),
('ppe', 'PPE / Consumables'),
('chemistry', 'Process Chemistry'),
('other', 'Other')],
string='Source', default='other', required=True,
)
replenishment_id = fields.Many2one(
'fusion.plating.bath.replenishment.suggestion',
string='Replenishment Suggestion',
ondelete='set null',
)
notes = fields.Char(string='Notes')
@api.depends('quantity', 'unit_cost')
def _compute_total_cost(self):
for rec in self:
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
@api.depends('product_id', 'product_name', 'quantity', 'job_id')
def _compute_display_name(self):
for rec in self:
label = rec.product_id.display_name or rec.product_name or 'Consumption'
qty = ('%g' % rec.quantity) if rec.quantity else ''
job = rec.job_id.name or ''
bits = [label]
if qty:
bits.append('×' + qty)
if job:
bits.append('(%s)' % job)
rec.display_name = ' '.join(bits)
@api.onchange('product_id')
def _onchange_product(self):
if self.product_id:
self.product_name = self.product_id.display_name
self.unit_cost = self.product_id.standard_price or 0.0
self.uom_id = self.product_id.uom_id or False

View File

@@ -9,7 +9,7 @@
# bridge_mrp keeps its version alive so legacy MO-flow keeps working. # bridge_mrp keeps its version alive so legacy MO-flow keeps working.
# Both coexist during the migration period. # Both coexist during the migration period.
from odoo import fields, models from odoo import api, fields, models
class FpJobNodeOverride(models.Model): class FpJobNodeOverride(models.Model):
@@ -35,6 +35,14 @@ class FpJobNodeOverride(models.Model):
help='When True, this opt-in/out node is included in step generation.', help='When True, this opt-in/out node is included in step generation.',
) )
@api.depends('job_id', 'node_id', 'included')
def _compute_display_name(self):
for rec in self:
job = rec.job_id.display_name or '(no job)'
node = rec.node_id.display_name or '(no node)'
tag = 'included' if rec.included else 'excluded'
rec.display_name = '%s · %s [%s]' % (job, node, tag)
_unique_job_node = models.Constraint( _unique_job_node = models.Constraint(
'unique(job_id, node_id)', 'unique(job_id, node_id)',
'A job can only have one override per recipe node.', 'A job can only have one override per recipe node.',

View File

@@ -13,6 +13,34 @@ from odoo.exceptions import UserError
class FpJobStep(models.Model): class FpJobStep(models.Model):
_inherit = 'fp.job.step' _inherit = 'fp.job.step'
def button_start(self):
"""Override — soft gate when parts haven't been received yet.
Doesn't block (parts could be in-transit late, manager wants
the shop to start prep regardless), but posts a chatter warning
on the job so the audit trail captures premature starts.
"""
result = super().button_start()
for step in self:
so = step.job_id.sale_order_id
if not so:
continue
recv = so.x_fc_receiving_status if (
'x_fc_receiving_status' in so._fields
) else None
if recv in (False, None, 'not_received'):
step.job_id.message_post(body=_(
'Step "%(step)s" started before parts were received '
'(SO %(so)s — receiving status: %(status)s). '
'Confirm the parts are physically on the floor before '
'continuing.'
) % {
'step': step.name,
'so': so.name or '',
'status': recv or 'unknown',
})
return result
def button_pause(self): def button_pause(self):
"""Pause an in-progress step (operator break, end of shift). """Pause an in-progress step (operator break, end of shift).

View File

@@ -2,18 +2,55 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# Phase 3 — parallel job link on fp.racking.inspection. # Phase 3 / Phase 9 — native-job link on fp.racking.inspection.
# Coexists with the legacy production_id (mrp.production) link. # Coexists with the legacy production_id (mrp.production) link; either
# (or both) may be set.
from odoo import fields, models from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class FpRackingInspection(models.Model): class FpRackingInspection(models.Model):
_inherit = 'fp.racking.inspection' _inherit = 'fp.racking.inspection'
x_fc_job_id = fields.Many2one( # x_fc_job_id is declared in the base receiving module so its views
'fp.job', # can reference it. We add help/depends here.
string='Plating Job',
index=True, @api.depends('x_fc_job_id.name', 'partner_id.name')
help='Native fp.job link. Coexists with the legacy production_id.', def _compute_name(self):
) for rec in self:
if rec.x_fc_job_id:
rec.name = _('Inspection — %s') % rec.x_fc_job_id.name
else:
rec.name = _('Racking Inspection')
@api.depends('x_fc_job_id.sale_order_id')
def _compute_sale_order(self):
for rec in self:
so = (rec.x_fc_job_id.sale_order_id
if rec.x_fc_job_id and rec.x_fc_job_id.sale_order_id
else False)
rec.sale_order_id = so or False
rec.partner_id = so.partner_id if so else False
@api.constrains('x_fc_job_id')
def _check_link_present(self):
for rec in self:
if not rec.x_fc_job_id:
raise ValidationError(_(
'Racking inspection must reference a plating job.'
))
@api.constrains('x_fc_job_id')
def _check_job_unique(self):
for rec in self:
if not rec.x_fc_job_id:
continue
dup = self.search_count([
('x_fc_job_id', '=', rec.x_fc_job_id.id),
('id', '!=', rec.id),
])
if dup:
raise ValidationError(_(
'Only one racking inspection per plating job.'
))

View File

@@ -10,6 +10,9 @@
# bridge_mrp's MO-creation hook handles the flow. # bridge_mrp's MO-creation hook handles the flow.
import logging import logging
from markupsafe import Markup
from odoo import _, api, fields, models from odoo import _, api, fields, models
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -18,6 +21,147 @@ _logger = logging.getLogger(__name__)
class SaleOrder(models.Model): class SaleOrder(models.Model):
_inherit = 'sale.order' _inherit = 'sale.order'
x_fc_fp_job_count = fields.Integer(
string='Plating Jobs',
compute='_compute_fp_job_count',
)
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
# relocated from fusion_plating_bridge_mrp. Field re-declared with
# the same selection + compute pointer; jobs is now the source of
# truth so Phase 5 can delete bridge_mrp without losing the field.
# ------------------------------------------------------------------
x_fc_workflow_stage = fields.Selection(
[
('draft', 'Quote'),
('awaiting_parts', 'Parts'),
('inspecting', 'Inspecting'),
('accept_parts', 'Accept'),
('assign_work', 'Assign'),
('in_production', 'Production'),
('ready_to_ship', 'Ready'),
('shipped', 'Shipped'),
('invoicing', 'Invoicing'),
('paid', 'Paid'),
('complete', 'Done'),
('cancelled', 'Cancelled'),
],
compute='_compute_workflow_stage',
string='Workflow Stage',
help='Current position in the SO → Ship → Invoice workflow. '
'Drives which next-step button is shown on the SO header.',
)
x_fc_assigned_manager_id = fields.Many2one(
'res.users', string='Assigned Manager',
help='The manager responsible for this job. Set when the job '
'is confirmed (falls back to the salesperson).',
tracking=True,
)
def _compute_fp_job_count(self):
Job = self.env['fp.job'].sudo()
for so in self:
so.x_fc_fp_job_count = Job.search_count(
[('sale_order_id', '=', so.id)]
)
def _compute_workflow_stage(self):
"""Native-jobs override — walks fp.job state instead of mrp.production.
When `use_native_jobs` is on, the SO is fulfilled by `fp.job`
records, not MRP MOs. The bridge_mrp compute reads `mrp.production`
and would falsely stall the banner. We branch at the top: native
mode → fp.job walker; legacy mode → super() (bridge_mrp).
"""
ICP = self.env['ir.config_parameter'].sudo()
native = ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True'
if not native:
return super()._compute_workflow_stage()
Job = self.env['fp.job']
Delivery = self.env.get('fusion.plating.delivery')
for so in self:
if so.state == 'cancel':
so.x_fc_workflow_stage = 'cancelled'
continue
if so.state in ('draft', 'sent'):
so.x_fc_workflow_stage = 'draft'
continue
jobs = Job.search([('sale_order_id', '=', so.id)])
all_jobs_done = bool(jobs) and all(
j.state == 'done' for j in jobs
)
shipped = False
if Delivery is not None and jobs:
if 'x_fc_job_id' in Delivery._fields:
shipped = bool(Delivery.search_count([
('x_fc_job_id', 'in', jobs.ids),
('state', '=', 'delivered'),
]))
posted_invoices = so.invoice_ids.filtered(
lambda i: i.state == 'posted'
)
has_posted_invoice = bool(posted_invoices)
all_paid = has_posted_invoice and all(
i.payment_state in ('paid', 'in_payment')
for i in posted_invoices
)
if shipped and all_paid:
so.x_fc_workflow_stage = 'complete'
continue
if all_paid and not shipped:
so.x_fc_workflow_stage = 'paid'
continue
if shipped and has_posted_invoice:
so.x_fc_workflow_stage = 'invoicing'
continue
if shipped:
so.x_fc_workflow_stage = 'shipped'
continue
if all_jobs_done:
so.x_fc_workflow_stage = 'ready_to_ship'
continue
recv_status = so.x_fc_receiving_status or 'not_received'
if recv_status == 'not_received':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status in ('partial', 'received'):
so.x_fc_workflow_stage = 'inspecting'
continue
if recv_status == 'inspected':
if not so.x_fc_assigned_manager_id and not jobs:
so.x_fc_workflow_stage = 'assign_work'
continue
so.x_fc_workflow_stage = 'in_production'
continue
so.x_fc_workflow_stage = (
'in_production' if jobs else 'awaiting_parts'
)
def action_view_fp_jobs(self):
self.ensure_one()
jobs = self.env['fp.job'].search([('sale_order_id', '=', self.id)])
action = {
'type': 'ir.actions.act_window',
'name': _('Plating Jobs'),
'res_model': 'fp.job',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
if len(jobs) == 1:
action.update({
'view_mode': 'form',
'res_id': jobs.id,
})
return action
def action_confirm(self): def action_confirm(self):
result = super().action_confirm() result = super().action_confirm()
# Only run when the native flag is on # Only run when the native flag is on
@@ -25,6 +169,22 @@ class SaleOrder(models.Model):
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True': if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
for so in self: for so in self:
so._fp_auto_create_job() so._fp_auto_create_job()
# Auto-confirm any draft jobs we just created so steps
# generate immediately (no manager click required).
# Best-effort: an exception in side-effects shouldn't
# block the SO confirm itself.
draft_jobs = self.env['fp.job'].sudo().search([
('sale_order_id', '=', so.id),
('state', '=', 'draft'),
])
for job in draft_jobs:
try:
job.action_confirm()
except Exception as exc:
so.message_post(body=_(
'Auto-confirm of fp.job %(job)s failed: %(err)s. '
'Confirm manually from the job form.'
) % {'job': job.name, 'err': exc})
return result return result
def _fp_auto_create_job(self): def _fp_auto_create_job(self):
@@ -121,3 +281,94 @@ class SaleOrder(models.Model):
self.name, job.name, qty, (recipe.name if recipe else '-'), self.name, job.name, qty, (recipe.name if recipe else '-'),
) )
return True return True
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow stage action buttons.
# Native versions of bridge_mrp's action_fp_* methods. Drop the
# mrp.production lookups; talk to fp.job and fp.receiving directly.
# ------------------------------------------------------------------
def action_fp_mark_inspected(self):
"""Flip open receivings from draft → inspecting."""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state == 'draft':
rec.state = 'inspecting'
self.message_post(body=_('Parts marked as inspecting.'))
return True
def action_fp_accept_parts(self):
"""Mark receiving accepted; flip SO receiving status to inspected."""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state in ('draft', 'inspecting'):
rec.state = 'accepted'
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'inspected'
self.message_post(body=_('Parts accepted — ready to assign manager.'))
return True
def action_fp_assign_to_me(self):
"""Manager claims the SO and confirms its draft fp.jobs."""
self.ensure_one()
user = self.env.user
self.x_fc_assigned_manager_id = user.id
Job = self.env['fp.job']
jobs = Job.search([
('sale_order_id', '=', self.id),
('state', '=', 'draft'),
])
for job in jobs:
try:
job.action_confirm()
except Exception as exc:
self.message_post(body=_(
'Auto-confirm of fp.job %s failed: %s'
) % (job.name, exc))
if 'manager_id' in job._fields and not job.manager_id:
job.manager_id = user.id
self.message_post(
body=Markup(_(
'Job assigned to <b>%s</b>. %d plating job(s) released to the floor.'
)) % (user.name, len(jobs)),
)
return True
def action_fp_mark_shipped(self):
"""Mark linked deliveries delivered (triggers auto-invoice)."""
self.ensure_one()
Delivery = self.env.get('fusion.plating.delivery')
if Delivery is None:
return False
Job = self.env['fp.job']
jobs = Job.search([('sale_order_id', '=', self.id)])
deliveries = Delivery.browse([])
if 'x_fc_job_id' in Delivery._fields:
deliveries = Delivery.search([
('x_fc_job_id', 'in', jobs.ids),
('state', '!=', 'delivered'),
])
for dlv in deliveries:
dlv.action_mark_delivered()
self.message_post(
body=_(
'%d delivery record(s) marked delivered. '
'Invoice flow triggered per invoice strategy.'
) % len(deliveries),
)
return True
def action_fp_open_shop_floor(self):
"""Jump to the Plant Overview filtered to this SO's jobs."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_plant_overview',
'name': _('Shop Floor — %s') % self.name,
'target': 'current',
}

View File

@@ -2,3 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_job_node_override_operator,fp.job.node.override.operator,model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fp_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fp_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_job_node_override_operator fp.job.node.override.operator model_fp_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_job_node_override_supervisor fp.job.node.override.supervisor model_fp_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_job_node_override_manager fp.job.node.override.manager model_fp_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_job_consumption_operator fp.job.consumption.operator model_fp_job_consumption fusion_plating.group_fusion_plating_operator 1 1 1 0
6 access_fp_job_consumption_supervisor fp.job.consumption.supervisor model_fp_job_consumption fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_job_consumption_manager fp.job.consumption.manager model_fp_job_consumption fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_consumption_list" model="ir.ui.view">
<field name="name">fp.job.consumption.list</field>
<field name="model">fp.job.consumption</field>
<field name="arch" type="xml">
<list editable="bottom" default_order="logged_date desc">
<field name="logged_date"/>
<field name="job_id"/>
<field name="step_id"/>
<field name="product_id"/>
<field name="quantity"/>
<field name="uom_id"/>
<field name="currency_id" column_invisible="1"/>
<field name="unit_cost" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="total_cost" widget="monetary"
options="{'currency_field': 'currency_id'}" sum="Total"/>
<field name="source"/>
<field name="logged_by_id" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_job_consumption_form" model="ir.ui.view">
<field name="name">fp.job.consumption.form</field>
<field name="model">fp.job.consumption</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="job_id"/>
<field name="step_id"/>
<field name="product_id"/>
<field name="product_name"/>
<field name="source"/>
</group>
<group>
<field name="quantity"/>
<field name="uom_id"/>
<field name="currency_id"/>
<field name="unit_cost" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="total_cost" widget="monetary"
options="{'currency_field': 'currency_id'}" readonly="1"/>
<field name="logged_date"/>
<field name="logged_by_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_job_consumption" model="ir.actions.act_window">
<field name="name">Job Consumables Log</field>
<field name="res_model">fp.job.consumption</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Phase 3 (Sub 11) — native Production Priorities kanban / list on
fp.job.step. Replaces the bridge_mrp version that bound to
mrp.workorder. Same UX (drag-drop ordering across work centres,
list with handle, badges by state).
-->
<odoo>
<record id="view_fp_step_priority_kanban" model="ir.ui.view">
<field name="name">fp.job.step.priority.kanban</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<kanban default_group_by="work_centre_id" default_order="sequence, id">
<field name="name"/>
<field name="work_centre_id"/>
<field name="job_id"/>
<field name="state"/>
<field name="sequence"/>
<field name="duration_actual"/>
<field name="duration_expected"/>
<field name="assigned_user_id"/>
<templates>
<t t-name="card">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_record_top mb-0">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="job_id"/>
</strong>
</div>
</div>
<div class="o_kanban_record_body">
<div><strong><field name="name"/></strong></div>
<div class="text-muted">
<t t-if="record.assigned_user_id.raw_value">
<field name="assigned_user_id"/>
</t>
<t t-if="record.duration_actual.raw_value">
<field name="duration_actual" widget="float_time"/> elapsed
</t>
</div>
</div>
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<field name="state" widget="badge"
decoration-info="state == 'ready'"
decoration-warning="state == 'in_progress'"
decoration-muted="state == 'paused'"
decoration-success="state == 'done'"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_fp_step_priority_list" model="ir.ui.view">
<field name="name">fp.job.step.priority.list</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<list default_order="sequence, id">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="job_id"/>
<field name="work_centre_id"/>
<field name="assigned_user_id"/>
<field name="duration_expected" widget="float_time"/>
<field name="duration_actual" widget="float_time"/>
<field name="state" widget="badge"
decoration-info="state == 'ready'"
decoration-warning="state == 'in_progress'"
decoration-muted="state == 'paused'"
decoration-success="state == 'done'"/>
</list>
</field>
</record>
<record id="action_fp_step_priority" model="ir.actions.act_window">
<field name="name">Production Priorities</field>
<field name="res_model">fp.job.step</field>
<field name="view_mode">kanban,list,form</field>
<field name="domain">[('state', 'in', ['pending', 'ready', 'in_progress', 'paused'])]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_fp_step_priority_kanban')}),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_fp_step_priority_list')})]"/>
</record>
<menuitem id="menu_fp_step_priority"
name="Production Priorities"
parent="fusion_plating.menu_fp_operations"
action="action_fp_step_priority"
sequence="10"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</odoo>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Adds the SO → fp.job smart button so the SO form is a hub for the
native job lifecycle (replaces the legacy MO smart button when the
use_native_jobs flag is on).
-->
<odoo>
<record id="view_sale_order_form_fp_jobs" model="ir.ui.view">
<field name="name">sale.order.form.fp.jobs</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- After the legacy Manufacturing button (added by bridge_mrp).
Always visible (no invisible-on-zero) so users can navigate
from the SO even before jobs exist. -->
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_fp_jobs" type="object"
class="oe_stat_button" icon="fa-cogs">
<field name="x_fc_fp_job_count" widget="statinfo"
string="Plating Jobs"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Notifications', 'name': 'Fusion Plating — Notifications',
'version': '19.0.5.0.0', 'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.', 'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',
@@ -20,12 +20,10 @@
'fusion_plating_certificates', 'fusion_plating_certificates',
'fusion_plating_receiving', 'fusion_plating_receiving',
'fusion_plating_invoicing', 'fusion_plating_invoicing',
'fusion_plating_bridge_mrp',
'fusion_plating_logistics', 'fusion_plating_logistics',
'fusion_plating_reports', 'fusion_plating_reports',
'sale_management', 'sale_management',
'account', 'account',
'mrp',
'mail', 'mail',
], ],
'data': [ 'data': [

View File

@@ -179,58 +179,10 @@
</field> </field>
</record> </record>
<!-- ============================================================= --> <!-- Phase 5 (Sub 11) — fp_mail_template_mo_complete removed.
<!-- 4. Manufacturing Complete (Info, #2B6CB0) --> The native equivalent fires from fp.job.button_mark_done via
<!-- ============================================================= --> fp.notification.template's `job_complete` trigger, defined
<record id="fp_mail_template_mo_complete" model="mail.template"> in fp_notification_template_data.xml. -->
<field name="name">FP: Manufacturing Complete</field>
<field name="model_id" ref="mrp.model_mrp_production"/>
<field name="subject">Job Complete — {{ object.x_fc_portal_job_id.name or object.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.x_fc_portal_job_id.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Manufacturing Complete — Ready to Ship</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Hi <t t-out="object.x_fc_portal_job_id.partner_id.name or ''"/>, your job has cleared production and quality. We are preparing it for shipment.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Value</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Job Reference</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.x_fc_portal_job_id.name or object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Sale Order</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.origin or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Quantity</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="int(object.product_qty)"/></td>
</tr>
</table>
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>Next:</strong> Your Certificate of Conformance will be issued with the shipment. Delivery scheduling to follow.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= --> <!-- ============================================================= -->
<!-- 5. Shipped / Delivered (Success, #38a169) --> <!-- 5. Shipped / Delivered (Success, #38a169) -->

View File

@@ -10,5 +10,7 @@ from . import sale_order
from . import fp_receiving from . import fp_receiving
from . import account_move from . import account_move
from . import account_payment from . import account_payment
from . import mrp_production # Phase 5 (Sub 11) — mrp.production hook retired. The native equivalent
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
# from . import mrp_production
from . import fp_delivery from . import fp_delivery

View File

@@ -12,19 +12,18 @@ class FpDelivery(models.Model):
def action_mark_delivered(self): def action_mark_delivered(self):
res = super().action_mark_delivered() res = super().action_mark_delivered()
Dispatch = self.env['fp.notification.template'] Dispatch = self.env['fp.notification.template']
Job = self.env.get('fp.job')
for rec in self: for rec in self:
if not rec.partner_id: if not rec.partner_id:
continue continue
so = False so = False
if rec.job_ref: # Native: fp.job direct link.
# Delivery's job_ref is the MO name; find the SO via MO origin. if Job is not None and 'x_fc_job_id' in rec._fields and rec.x_fc_job_id:
mo = self.env['mrp.production'].search( so = rec.x_fc_job_id.sale_order_id or False
[('name', '=', rec.job_ref)], limit=1, elif Job is not None and rec.job_ref:
) job = Job.search([('name', '=', rec.job_ref)], limit=1)
if mo and mo.origin: if job:
so = self.env['sale.order'].search( so = job.sale_order_id or False
[('name', '=', mo.origin)], limit=1,
)
# Sub 6 — pass the delivery address so location-scoped # Sub 6 — pass the delivery address so location-scoped
# contacts receive the 'shipped' notification. # contacts receive the 'shipped' notification.
Dispatch._dispatch( Dispatch._dispatch(

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from odoo import fields, models from odoo import api, fields, models
from .fp_notification_template import TRIGGER_EVENTS from .fp_notification_template import TRIGGER_EVENTS
@@ -29,3 +29,20 @@ class FpNotificationLog(models.Model):
) )
error_message = fields.Text(string='Error Message') error_message = fields.Text(string='Error Message')
mail_mail_id = fields.Many2one('mail.mail', string='Mail Record') mail_mail_id = fields.Many2one('mail.mail', string='Mail Record')
@api.depends('template_id', 'partner_id', 'sent_date', 'status')
def _compute_display_name(self):
trigger_labels = dict(self._fields['trigger_event'].selection)
for rec in self:
bits = []
label = (
rec.template_id.display_name
or trigger_labels.get(rec.trigger_event)
or 'Notification'
)
bits.append(label)
if rec.partner_id:
bits.append('%s' % rec.partner_id.display_name)
if rec.sent_date:
bits.append(rec.sent_date.strftime('%Y-%m-%d %H:%M'))
rec.display_name = ' '.join(bits)

View File

@@ -4,3 +4,4 @@
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
from . import models from . import models
from . import controllers

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Quality (QMS)', 'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.2.3.0', 'version': '19.0.3.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.', 'internal audits, customer specs, document control. CE + EE compatible.',
@@ -67,6 +67,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'depends': [ 'depends': [
'fusion_plating', 'fusion_plating',
'fusion_plating_configurator', 'fusion_plating_configurator',
'fusion_plating_certificates', # fp.thickness.reading link from QC
'fusion_plating_shopfloor', # _fp_shopfloor_tokens.scss for QC tablet
'mail', 'mail',
], ],
'data': [ 'data': [
@@ -74,6 +76,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/fp_sequence_data.xml', 'data/fp_sequence_data.xml',
'data/fp_quality_hold_sequence_data.xml', 'data/fp_quality_hold_sequence_data.xml',
'data/fp_qc_data.xml',
'views/fp_qc_template_views.xml',
'views/fp_quality_hold_views.xml', 'views/fp_quality_hold_views.xml',
'views/fp_ncr_views.xml', 'views/fp_ncr_views.xml',
'views/fp_capa_views.xml', 'views/fp_capa_views.xml',
@@ -84,9 +88,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_fair_views.xml', 'views/fp_fair_views.xml',
'views/fp_doc_control_views.xml', 'views/fp_doc_control_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/res_partner_qc_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/fp_contract_review_views.xml', 'views/fp_contract_review_views.xml',
'views/fp_part_catalog_views.xml', 'views/fp_part_catalog_views.xml',
'views/fp_quality_check_views.xml',
'reports/fp_contract_review_report.xml', 'reports/fp_contract_review_report.xml',
'reports/fp_contract_review_template.xml', 'reports/fp_contract_review_template.xml',
'views/fp_menu.xml', 'views/fp_menu.xml',
@@ -97,6 +103,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
'fusion_plating_quality/static/src/scss/fusion_plating_quality.scss', 'fusion_plating_quality/static/src/scss/fusion_plating_quality.scss',
# Phase 2 (Sub 11) — QC tablet OWL relocated from bridge_mrp.
'fusion_plating_quality/static/src/scss/fp_qc_checklist.scss',
'fusion_plating_quality/static/src/xml/fp_qc_checklist.xml',
'fusion_plating_quality/static/src/js/fp_qc_checklist.js',
], ],
}, },
'installable': True, 'installable': True,

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import fp_qc_controller

View File

@@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""HTTP endpoints for the mobile QC checklist OWL client action.
Kept narrow (read state + mark-pass/fail + upload PDF + finalize). The
OWL component is purely a thin client over these endpoints so any
future native mobile app can reuse the same API.
"""
import base64
import logging
from odoo import http, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class FpQcController(http.Controller):
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _check(check_id):
"""Resolve and access-check a QC record."""
if not check_id:
return False
check = request.env['fusion.plating.quality.check'].browse(
int(check_id)
).exists()
if not check:
return False
check.check_access('read')
return check
@staticmethod
def _line_payload(line):
return {
'id': line.id,
'sequence': line.sequence,
'name': line.name,
'description': line.description or '',
'check_type': line.check_type,
'required': line.required,
'requires_value': line.requires_value,
'value': line.value,
'value_min': line.value_min,
'value_max': line.value_max,
'value_uom': line.value_uom or '',
'value_in_range': line.value_in_range,
'requires_photo': line.requires_photo,
'has_photo': bool(line.photo_attachment_id),
'photo_attachment_id': line.photo_attachment_id.id or False,
'result': line.result or 'pending',
'notes': line.notes or '',
'inspector_name': (
line.inspector_id.name if line.inspector_id else ''
),
}
@staticmethod
def _check_payload(check):
return {
'id': check.id,
'name': check.name,
'state': check.state,
'overall_result': check.overall_result or '',
'job_id': check.job_id.id if check.job_id else False,
'job_name': check.job_id.name if check.job_id else '',
'partner_name': (
check.partner_id.name if check.partner_id else ''
),
'template_name': (
check.template_id.name if check.template_id else ''
),
'inspector_name': (
check.inspector_id.name if check.inspector_id else ''
),
'line_count': check.line_count,
'lines_passed': check.lines_passed,
'lines_failed': check.lines_failed,
'lines_pending': check.lines_pending,
'require_thickness_readings': check.require_thickness_readings,
'require_thickness_report_pdf': check.require_thickness_report_pdf,
'has_thickness_pdf': bool(check.thickness_report_pdf_id),
'thickness_reading_count': check.thickness_reading_count,
'notes': check.notes or '',
}
# ------------------------------------------------------------------
# GET state — OWL calls this on mount + after every action
# ------------------------------------------------------------------
@http.route(
'/fp/qc/get', type='jsonrpc', auth='user', methods=['POST'],
)
def get_state(self, check_id=None, job_id=None, **kw):
check = self._check(check_id)
if not check and job_id:
# Resolve latest active QC for this fp.job
check = request.env['fusion.plating.quality.check'].search([
('job_id', '=', int(job_id)),
], order='create_date desc', limit=1)
if not check:
return {'ok': False, 'error': 'no_qc'}
if not check:
return {'ok': False, 'error': 'not_found'}
return {
'ok': True,
'check': self._check_payload(check),
'lines': [
self._line_payload(l)
for l in check.line_ids.sorted('sequence')
],
}
# ------------------------------------------------------------------
# Line actions
# ------------------------------------------------------------------
@http.route(
'/fp/qc/line/mark', type='jsonrpc', auth='user', methods=['POST'],
)
def line_mark(self, check_id=None, line_id=None, result=None,
value=None, notes=None, **kw):
check = self._check(check_id)
if not check:
return {'ok': False, 'error': 'not_found'}
Line = request.env['fusion.plating.quality.check.line']
line = Line.browse(int(line_id)).exists()
if not line or line.check_id.id != check.id:
return {'ok': False, 'error': 'invalid_line'}
# Start the check if it's still draft
if check.state == 'draft':
check.action_start()
# Numeric value handling — write before action to let
# _compute_value_in_range update the record.
vals = {}
if value is not None and line.requires_value:
try:
vals['value'] = float(value)
except (TypeError, ValueError):
return {'ok': False, 'error': 'invalid_value'}
if notes is not None:
vals['notes'] = notes
if vals:
line.write(vals)
try:
if result == 'pass':
line.action_mark_pass()
elif result == 'fail':
line.action_mark_fail()
elif result == 'na':
line.action_mark_na()
elif result == 'pending':
line.write({
'result': 'pending',
'inspector_id': False,
'completed_at': False,
})
except Exception as e:
return {'ok': False, 'error': str(e)}
return {
'ok': True,
'line': self._line_payload(line),
'check': self._check_payload(check),
}
# ------------------------------------------------------------------
# Photo upload for an individual line
# ------------------------------------------------------------------
@http.route(
'/fp/qc/line/photo', type='http', auth='user', methods=['POST'],
csrf=False,
)
def line_photo(self, line_id=None, **kw):
Line = request.env['fusion.plating.quality.check.line']
line = Line.browse(int(line_id)).exists()
if not line:
return request.make_json_response(
{'ok': False, 'error': 'invalid_line'},
)
upload = request.httprequest.files.get('file')
if not upload:
return request.make_json_response(
{'ok': False, 'error': 'no_file'},
)
data = upload.read()
if not data:
return request.make_json_response(
{'ok': False, 'error': 'empty_file'},
)
att = request.env['ir.attachment'].create({
'name': upload.filename or 'qc_photo.jpg',
'type': 'binary',
'datas': base64.b64encode(data),
'res_model': 'fusion.plating.quality.check.line',
'res_id': line.id,
'mimetype': upload.mimetype or 'image/jpeg',
})
line.write({'photo_attachment_id': att.id})
return request.make_json_response({
'ok': True,
'attachment_id': att.id,
})
# ------------------------------------------------------------------
# Fischerscope PDF upload
# ------------------------------------------------------------------
@http.route(
'/fp/qc/thickness_pdf', type='http', auth='user',
methods=['POST'], csrf=False,
)
def thickness_pdf(self, check_id=None, **kw):
check = self._check(check_id)
if not check:
return request.make_json_response(
{'ok': False, 'error': 'not_found'},
)
upload = request.httprequest.files.get('file')
if not upload:
return request.make_json_response(
{'ok': False, 'error': 'no_file'},
)
data = upload.read()
if not data:
return request.make_json_response(
{'ok': False, 'error': 'empty_file'},
)
att = request.env['ir.attachment'].create({
'name': upload.filename or 'thickness_report.pdf',
'type': 'binary',
'datas': base64.b64encode(data),
'res_model': 'fusion.plating.quality.check',
'res_id': check.id,
'mimetype': upload.mimetype or 'application/pdf',
})
# Triggers _on_thickness_pdf_uploaded via write() override.
check.write({'thickness_report_pdf_id': att.id})
return request.make_json_response({
'ok': True,
'attachment_id': att.id,
'reading_count': check.thickness_reading_count,
})
# ------------------------------------------------------------------
# Check-level actions
# ------------------------------------------------------------------
@http.route(
'/fp/qc/finalize', type='jsonrpc', auth='user', methods=['POST'],
)
def finalize(self, check_id=None, result=None, notes=None, **kw):
check = self._check(check_id)
if not check:
return {'ok': False, 'error': 'not_found'}
if notes is not None:
check.write({'notes': notes})
try:
if result == 'pass':
check.action_pass()
elif result == 'fail':
check.action_fail()
elif result == 'rework':
check.action_rework()
else:
return {'ok': False, 'error': 'invalid_result'}
except Exception as e:
return {'ok': False, 'error': str(e)}
return {'ok': True, 'check': self._check_payload(check)}
@http.route(
'/fp/qc/start', type='jsonrpc', auth='user', methods=['POST'],
)
def start(self, check_id=None, **kw):
check = self._check(check_id)
if not check:
return {'ok': False, 'error': 'not_found'}
if check.state == 'draft':
check.action_start()
return {'ok': True, 'check': self._check_payload(check)}

View File

@@ -0,0 +1,161 @@
<?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.
Sequence for QC checks + a starter default template so QC works
out of the box for any customer that has x_fc_requires_qc=True
but no per-customer template yet.
-->
<odoo noupdate="1">
<!-- ===== Sequence ===== -->
<record id="seq_fp_quality_check" model="ir.sequence">
<field name="name">Fusion Plating: Quality Check</field>
<field name="code">fusion.plating.quality.check</field>
<field name="prefix">QC/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<!-- ===== Default checklist template (global — partner_id blank) =====
sequence=5 so it wins over any other global template when
resolve_for_partner falls back from a missing per-customer match. -->
<record id="qc_template_default" model="fp.qc.checklist.template">
<field name="name">Standard Plating QC</field>
<field name="sequence">5</field>
<field name="active">True</field>
<field name="require_inspector_signoff">True</field>
<field name="require_thickness_readings">False</field>
<field name="require_thickness_report_pdf">False</field>
</record>
<record id="qc_tpl_line_visual" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">10</field>
<field name="name">Visual — no pits, burns, or bare spots</field>
<field name="description">Examine the entire plated surface under shop lighting. Look for pits, burns, dewetting, bare spots, or rough texture. Reject if any defect is visible to the naked eye.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_colour" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">20</field>
<field name="name">Colour — uniform finish across part</field>
<field name="description">Finish should be uniform with no streaking, blotching, or dull-vs-bright zones. Compare against the customer colour sample if one is on file.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_adhesion" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">30</field>
<field name="name">Adhesion — tape test pass</field>
<field name="description">Apply tape to an inconspicuous area, press firmly for 3 seconds, pull at 90°. No flaking permitted.</field>
<field name="check_type">adhesion</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_masking" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">40</field>
<field name="name">Masking — no plating in masked zones</field>
<field name="description">Areas that were masked per customer print must be free of plating deposit. Light staining acceptable; build-up is not.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_quantity" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">50</field>
<field name="name">Quantity — matches WO count</field>
<field name="description">Count the parts. Must equal the WO quantity minus any documented rework/scrap.</field>
<field name="check_type">functional</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_packaging" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">60</field>
<field name="name">Packaging — parts protected for shipping</field>
<field name="description">Parts individually bagged / padded, no direct metal-on-metal contact that could scratch the finish in transit.</field>
<field name="check_type">visual</field>
<field name="required">False</field>
</record>
<!-- ===== Aerospace checklist (stricter — used as a starter for
Nadcap customers; admin copies and reassigns to partner) ===== -->
<record id="qc_template_aerospace" model="fp.qc.checklist.template">
<field name="name">Aerospace / Nadcap QC</field>
<field name="sequence">100</field>
<field name="active">True</field>
<field name="require_inspector_signoff">True</field>
<field name="require_thickness_readings">True</field>
<field name="require_thickness_report_pdf">True</field>
</record>
<record id="qc_tpl_aero_visual" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">10</field>
<field name="name">Visual — 10× loupe, no discontinuities</field>
<field name="description">Inspect under 10× magnification. Reject any pit, crack, inclusion, or discontinuity visible at that power.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
<field name="requires_photo">True</field>
</record>
<record id="qc_tpl_aero_thickness_1" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">20</field>
<field name="name">Thickness — Fischerscope reading #1</field>
<field name="description">Fischerscope XDAL 600 XRF measurement at primary inspection point. Value must fall inside the customer spec range. Record the NiP mils reading.</field>
<field name="check_type">thickness</field>
<field name="required">True</field>
<field name="requires_value">True</field>
<field name="value_uom">mils</field>
</record>
<record id="qc_tpl_aero_thickness_2" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">30</field>
<field name="name">Thickness — Fischerscope reading #2</field>
<field name="description">Second XRF point — per customer print's secondary inspection location.</field>
<field name="check_type">thickness</field>
<field name="required">True</field>
<field name="requires_value">True</field>
<field name="value_uom">mils</field>
</record>
<record id="qc_tpl_aero_thickness_3" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">40</field>
<field name="name">Thickness — Fischerscope reading #3</field>
<field name="description">Third XRF point — per customer print's tertiary inspection location.</field>
<field name="check_type">thickness</field>
<field name="required">True</field>
<field name="requires_value">True</field>
<field name="value_uom">mils</field>
</record>
<record id="qc_tpl_aero_adhesion" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">50</field>
<field name="name">Adhesion — ASTM B571 tape test</field>
<field name="description">Apply ASTM B571 tape to freshly-scribed area, remove at 90° per standard. No flaking of plating permitted.</field>
<field name="check_type">adhesion</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_aero_dimensional" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">60</field>
<field name="name">Dimensional — critical feature verification</field>
<field name="description">Caliper / mic any feature marked critical on the customer print. Confirm plating did not push dimensions out of tolerance.</field>
<field name="check_type">dimensional</field>
<field name="required">True</field>
<field name="requires_value">False</field>
</record>
</odoo>

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
#
# Phase 1 (Sub 11) — relocate fusion.plating.quality.check (+line)
# from fusion_plating_bridge_mrp to fusion_plating_quality.
# Drop the legacy production_id column on the existing table.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install — nothing to migrate
patterns = [
'model_fusion_plating_quality_check',
'model_fusion_plating_quality_check_line',
'model_fp_qc_checklist_template',
'model_fp_qc_checklist_template_line',
'access_fp_qc_check_%',
'access_fp_qc_check_line_%',
'access_fp_qc_template_%',
'access_fp_qc_template_line_%',
'view_fp_quality_check%',
'view_fp_qc_template_%',
'fp_quality_check_%',
'action_fp_quality_check%',
'action_fp_qc_template%',
'menu_fp_quality_check%',
'menu_fp_qc_template%',
'qc_template_default',
'qc_template_aerospace',
'qc_tpl_%',
'seq_fp_quality_check',
]
for pat in patterns:
cr.execute(
"""
UPDATE ir_model_data
SET module = 'fusion_plating_quality'
WHERE module = 'fusion_plating_bridge_mrp'
AND name LIKE %s
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d2
WHERE d2.module = 'fusion_plating_quality'
AND d2.name = ir_model_data.name
)
""",
(pat,),
)
if cr.rowcount:
_logger.info(
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating_quality",
cr.rowcount, pat,
)
# Drop the legacy production_id column on fusion_plating_quality_check.
# Zero rows reference MRP (verified pre-cut). The new model declares
# job_id / sale_order_id / partner_id (related from job).
cr.execute(
"""
ALTER TABLE fusion_plating_quality_check
DROP COLUMN IF EXISTS production_id
"""
)
_logger.info("Sub 11: dropped production_id column on fusion_plating_quality_check")

View File

@@ -18,3 +18,8 @@ from . import res_company
from . import res_config_settings from . import res_config_settings
from . import res_partner from . import res_partner
from . import fp_part_catalog from . import fp_part_catalog
# Phase 1 of MRP cut-out (Sub 11) — relocated from fusion_plating_bridge_mrp.
from . import fp_qc_template
from . import fp_thickness_reading
from . import fp_quality_check

View File

@@ -0,0 +1,171 @@
# -*- 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 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# This model never had MRP fields; the bridge module was just its
# initial home. Now lives under fusion_plating_jobs.
"""QC Checklist Template — admin config for per-customer QC requirements.
Customers differ wildly in what they expect from quality control:
* commercial job-shop accounts often just want "did it plate?" — one
visual check
* aerospace / Nadcap customers expect visual, dimensional,
adhesion, and Fischerscope thickness readings — every part, every
lot, signed off
* internal rework jobs may have no QC requirement at all
Rather than coding that policy into the shop, each customer gets their
own checklist template. On job confirm, the active template is cloned
into a fresh `fusion.plating.quality.check` — the instance operators
actually fill in.
"""
from odoo import api, fields, models, _
class FpQcChecklistTemplate(models.Model):
_name = 'fp.qc.checklist.template'
_description = 'Fusion Plating — QC Checklist Template'
_inherit = ['mail.thread']
_order = 'partner_id, sequence, name'
name = fields.Char(
string='Template Name', required=True, tracking=True,
help='e.g. "Standard Aerospace CoC + Thickness" or '
'"Commercial — Visual Only".',
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
partner_id = fields.Many2one(
'res.partner', string='Customer',
domain="[('customer_rank', '>', 0)]",
help='Leave blank for the global default template. A customer-'
'specific template wins over the default when both exist.',
tracking=True,
)
notes = fields.Html(
string='Notes',
help='Context for QC inspectors — what this customer cares '
'about, common reject reasons, spec docs to reference.',
)
line_ids = fields.One2many(
'fp.qc.checklist.template.line', 'template_id',
string='Checklist Items', copy=True,
)
require_thickness_readings = fields.Boolean(
string='Require Thickness Readings', default=False, tracking=True,
help='Job cannot be marked done unless at least one '
'fp.thickness.reading is logged against it. Use for '
'aerospace / Nadcap accounts.',
)
require_thickness_report_pdf = fields.Boolean(
string='Require Thickness Report PDF', default=False, tracking=True,
help='Job cannot be marked done unless the operator has '
'uploaded the Fischerscope / XDAL 600 PDF report to the '
'quality check.',
)
require_inspector_signoff = fields.Boolean(
string='Require Inspector Sign-off', default=True, tracking=True,
help='The quality check itself must be in the "passed" state '
'(not just draft or in-progress).',
)
check_count = fields.Integer(
string='# QC Checks Created', compute='_compute_check_count',
)
def _compute_check_count(self):
Check = self.env['fusion.plating.quality.check']
for rec in self:
rec.check_count = Check.search_count([
('template_id', '=', rec.id),
])
@api.model
def resolve_for_partner(self, partner):
"""Return the best-matching template for a customer.
Order: active customer-specific template > active default template >
None (no QC required).
"""
if partner:
specific = self.search([
('partner_id', '=', partner.id),
('active', '=', True),
], limit=1)
if specific:
return specific
return self.search([
('partner_id', '=', False),
('active', '=', True),
], limit=1)
def action_view_checks(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('QC Checks — %s') % self.name,
'res_model': 'fusion.plating.quality.check',
'view_mode': 'list,form',
'domain': [('template_id', '=', self.id)],
'target': 'current',
}
class FpQcChecklistTemplateLine(models.Model):
_name = 'fp.qc.checklist.template.line'
_description = 'Fusion Plating — QC Checklist Template Line'
_order = 'sequence, id'
template_id = fields.Many2one(
'fp.qc.checklist.template', string='Template',
required=True, ondelete='cascade',
)
sequence = fields.Integer(default=10)
name = fields.Char(
string='Check Item', required=True, translate=True,
help='The operator-facing question, e.g. "No visible pits or '
'blemishes on surface", "Thickness within 0.00050.0010".',
)
description = fields.Text(
string='Inspection Guidance',
help='Extra detail shown on the tablet when the operator taps '
'the item. Use for photos-to-compare-against, acceptable-'
'colour ranges, how to position the part, etc.',
)
check_type = fields.Selection(
[
('visual', 'Visual Inspection'),
('dimensional', 'Dimensional'),
('thickness', 'Thickness'),
('adhesion', 'Adhesion'),
('hardness', 'Hardness'),
('salt_spray', 'Salt Spray'),
('functional', 'Functional'),
('other', 'Other'),
],
string='Check Type', default='visual', required=True,
)
required = fields.Boolean(
string='Required', default=True,
help='If off, the inspector can skip this item without blocking '
'the QC from passing.',
)
requires_value = fields.Boolean(
string='Requires Numeric Value', default=False,
help='Inspector must enter a measurement. If min/max are set, '
'the reading must fall inside to count as pass.',
)
value_min = fields.Float(string='Min Value', digits=(12, 4))
value_max = fields.Float(string='Max Value', digits=(12, 4))
value_uom = fields.Char(
string='Unit',
help='Free text. e.g. "mils", "microns", "HV", "µm".',
)
requires_photo = fields.Boolean(
string='Requires Photo', default=False,
help='Inspector must attach a photo of the part.',
)

View File

@@ -0,0 +1,549 @@
# -*- 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 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# Now binds to fp.job (native) instead of mrp.production.
"""Per-job QC instance.
When a plating job confirms and the customer requires QC, we clone
the active checklist template into a `fusion.plating.quality.check`
with one line per template line. The inspector picks it up on the
tablet, walks the checks, and signs off — which unblocks the job's
`button_mark_done`.
The QC also owns the Fischerscope / XDAL 600 thickness report PDF.
When the operator uploads one, we extract per-reading data server-side
and auto-create `fp.thickness.reading` rows so the CoC PDF picks them up.
"""
import base64
import logging
import re
import subprocess
import tempfile
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpQualityCheck(models.Model):
_name = 'fusion.plating.quality.check'
_description = 'Fusion Plating — Quality Check'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
name = fields.Char(
string='Reference', required=True, copy=False, readonly=True,
default=lambda self: self._default_name(), tracking=True,
)
job_id = fields.Many2one(
'fp.job', string='Plating Job',
required=True, ondelete='cascade', tracking=True,
index=True,
)
sale_order_id = fields.Many2one(
'sale.order', related='job_id.sale_order_id',
store=True, readonly=True,
)
partner_id = fields.Many2one(
'res.partner', related='job_id.partner_id',
store=True, readonly=True,
)
template_id = fields.Many2one(
'fp.qc.checklist.template', string='Template',
help='The checklist template these lines were cloned from.',
)
state = fields.Selection(
[
('draft', 'Draft'),
('in_progress', 'In Progress'),
('passed', 'Passed'),
('failed', 'Failed'),
('rework', 'Rework Required'),
],
string='Status', default='draft', required=True, tracking=True,
)
overall_result = fields.Selection(
[('pass', 'Pass'), ('fail', 'Fail'), ('partial', 'Partial Pass')],
string='Result', tracking=True,
help='Summary outcome — set when inspector signs off.',
)
line_ids = fields.One2many(
'fusion.plating.quality.check.line', 'check_id',
string='Check Items',
)
line_count = fields.Integer(compute='_compute_line_stats', store=True)
lines_passed = fields.Integer(compute='_compute_line_stats', store=True)
lines_failed = fields.Integer(compute='_compute_line_stats', store=True)
lines_pending = fields.Integer(compute='_compute_line_stats', store=True)
inspector_id = fields.Many2one(
'res.users', string='Inspector',
help='Whoever signed the QC off. Filled when state moves to '
'passed/failed.',
tracking=True,
)
started_at = fields.Datetime(
string='Started', help='First time inspector opened this check.',
)
completed_at = fields.Datetime(
string='Completed', help='When the check was signed off.',
tracking=True,
)
notes = fields.Html(string='Inspector Notes')
thickness_report_pdf_id = fields.Many2one(
'ir.attachment', string='Thickness Report PDF',
help='Upload the Fischerscope / XDAL 600 export. On upload we '
'parse the PDF and auto-create fp.thickness.reading rows.',
)
thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'quality_check_id',
string='Thickness Readings',
)
thickness_reading_count = fields.Integer(
compute='_compute_thickness_count',
)
require_thickness_readings = fields.Boolean(
related='template_id.require_thickness_readings',
store=True, readonly=True,
)
require_thickness_report_pdf = fields.Boolean(
related='template_id.require_thickness_report_pdf',
store=True, readonly=True,
)
require_inspector_signoff = fields.Boolean(
related='template_id.require_inspector_signoff',
store=True, readonly=True,
)
company_id = fields.Many2one(
'res.company', related='job_id.company_id',
store=True, readonly=True,
)
@api.depends('line_ids.result')
def _compute_line_stats(self):
for rec in self:
rec.line_count = len(rec.line_ids)
rec.lines_passed = len(rec.line_ids.filtered(
lambda l: l.result == 'pass'
))
rec.lines_failed = len(rec.line_ids.filtered(
lambda l: l.result == 'fail'
))
rec.lines_pending = len(rec.line_ids.filtered(
lambda l: l.result in (False, 'pending')
))
@api.depends('thickness_reading_ids')
def _compute_thickness_count(self):
for rec in self:
rec.thickness_reading_count = len(rec.thickness_reading_ids)
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code(
'fusion.plating.quality.check',
)
return seq or 'QC/NEW'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
@api.model
def create_for_job(self, job, template=None):
"""Spin up a QC record for a plating job, cloning lines from the
template.
If no template is passed, we resolve one from the job's customer.
Returns the created check, or an empty recordset if no template
matches (=> no QC required for this customer).
"""
self = self.sudo()
if template is None:
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
job.partner_id,
)
if not template:
return self.browse()
existing = self.search([
('job_id', '=', job.id),
('state', '!=', 'failed'),
], limit=1)
if existing:
return existing
check = self.create({
'job_id': job.id,
'template_id': template.id,
'state': 'draft',
})
Line = self.env['fusion.plating.quality.check.line']
for tline in template.line_ids.sorted('sequence'):
Line.create({
'check_id': check.id,
'sequence': tline.sequence,
'name': tline.name,
'description': tline.description,
'check_type': tline.check_type,
'required': tline.required,
'requires_value': tline.requires_value,
'value_min': tline.value_min,
'value_max': tline.value_max,
'value_uom': tline.value_uom,
'requires_photo': tline.requires_photo,
'result': 'pending',
})
job.message_post(
body=_('QC checklist "%s" created — %d items to inspect.') % (
template.name, len(template.line_ids),
),
)
return check
def action_start(self):
for rec in self:
if rec.state == 'draft':
rec.write({
'state': 'in_progress',
'started_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=_('QC started.'))
def action_pass(self):
for rec in self:
rec._ensure_all_required_complete()
rec.write({
'state': 'passed',
'overall_result': 'pass',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=Markup(
'<b>QC PASSED</b> — inspector %s.'
) % self.env.user.name)
def action_fail(self):
for rec in self:
rec.write({
'state': 'failed',
'overall_result': 'fail',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=Markup(
'<b>QC FAILED</b> — inspector %s.'
) % self.env.user.name)
def action_rework(self):
for rec in self:
rec.write({
'state': 'rework',
'overall_result': 'partial',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=_('QC flagged for rework.'))
def action_reset_to_draft(self):
for rec in self:
rec.write({
'state': 'draft',
'overall_result': False,
'completed_at': False,
})
def action_spawn_retry(self):
"""Spin up a fresh QC instance for the same job after a failure."""
self.ensure_one()
if self.state != 'failed':
return
new_check = self.sudo().create_for_job(
self.job_id, template=self.template_id,
)
if not new_check:
return False
self.message_post(body=_('Retry QC created: %s') % new_check.name)
new_check.message_post(body=_('Retry of failed QC %s') % self.name)
return new_check.action_open_tablet()
def _ensure_all_required_complete(self):
for rec in self:
pending = rec.line_ids.filtered(
lambda l: l.required and l.result in (False, 'pending')
)
if pending:
raise UserError(_(
'Cannot pass QC "%(name)s"%(n)d required check '
'item(s) still pending:\n%(items)s'
) % {
'name': rec.name,
'n': len(pending),
'items': '\n'.join(pending.mapped('name')),
})
failed = rec.line_ids.filtered(lambda l: l.result == 'fail')
if failed:
raise UserError(_(
'Cannot pass QC "%(name)s"%(n)d check item(s) '
'failed. Fail the QC instead, or reset those '
'items to pass.'
) % {'name': rec.name, 'n': len(failed)})
def _on_thickness_pdf_uploaded(self):
"""Parse the attached PDF and create fp.thickness.reading rows."""
ThicknessReading = self.env['fp.thickness.reading']
for rec in self:
if not rec.thickness_report_pdf_id:
continue
try:
text = rec._extract_pdf_text(rec.thickness_report_pdf_id)
except Exception:
_logger.exception(
'QC %s: pdftotext extraction failed', rec.name,
)
continue
readings = rec._parse_fischerscope_text(text)
if not readings:
rec.message_post(body=_(
'Thickness report PDF attached but no readings '
'could be extracted automatically. Please enter '
'readings manually.'
))
continue
auto = rec.thickness_reading_ids.filtered(
lambda r: r.auto_extracted
)
auto.unlink()
for idx, row in enumerate(readings, start=1):
vals = {
'quality_check_id': rec.id,
'reading_number': idx,
'nip_mils': row.get('nip_mils', 0.0),
'ni_percent': row.get('ni_percent', 0.0),
'p_percent': row.get('p_percent', 0.0),
'position_label': row.get('position', ''),
'auto_extracted': True,
}
ThicknessReading.create(vals)
rec.message_post(body=_(
'Extracted %d thickness reading(s) from "%s".'
) % (len(readings), rec.thickness_report_pdf_id.name))
@staticmethod
def _extract_pdf_text(attachment):
raw = base64.b64decode(attachment.datas or b'')
if not raw:
return ''
with tempfile.NamedTemporaryFile(
suffix='.pdf', delete=True,
) as tmp:
tmp.write(raw)
tmp.flush()
try:
result = subprocess.run(
['pdftotext', '-layout', tmp.name, '-'],
capture_output=True, text=True, timeout=30,
)
return result.stdout or ''
except FileNotFoundError:
_logger.warning(
'pdftotext not installed — cannot auto-extract '
'Fischerscope PDF data. Install poppler-utils on '
'the Odoo host.',
)
return ''
@staticmethod
def _parse_fischerscope_text(text):
readings = []
row_re = re.compile(
r'^\s*(?:n\s*=\s*|N\s*)?(\d{1,3})[\s.:]+'
r'([0-9]*\.[0-9]+|\d+)'
r'(?:\s*(?:mils|microns|µm|um))?'
r'[\s|]+'
r'([0-9]*\.?[0-9]+)'
r'[\s|%]+'
r'([0-9]*\.?[0-9]+)'
r'[\s|%]*'
r'(.*)$',
re.IGNORECASE,
)
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
m = row_re.match(line)
if not m:
continue
try:
idx = int(m.group(1))
nip = float(m.group(2))
ni = float(m.group(3))
p = float(m.group(4))
except (TypeError, ValueError):
continue
if not (0 < nip < 1) and not (0 < nip < 30):
continue
if not (0 < ni < 100):
continue
if not (0 < p < 30):
continue
if idx < 1 or idx > 500:
continue
position = (m.group(5) or '').strip()[:60]
readings.append({
'index': idx,
'nip_mils': nip,
'ni_percent': ni,
'p_percent': p,
'position': position,
})
seen = set()
dedup = []
for r in readings:
if r['index'] in seen:
continue
seen.add(r['index'])
dedup.append(r)
return dedup
def write(self, vals):
trigger = 'thickness_report_pdf_id' in vals and vals.get(
'thickness_report_pdf_id'
)
res = super().write(vals)
if trigger:
self._on_thickness_pdf_uploaded()
return res
def action_open_tablet(self):
"""Launch the mobile QC checklist OWL client action."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_qc_checklist',
'name': _('QC — %s') % (self.job_id.name or ''),
'params': {'check_id': self.id},
'target': 'current',
}
class FpQualityCheckLine(models.Model):
_name = 'fusion.plating.quality.check.line'
_description = 'Fusion Plating — Quality Check Line'
_order = 'sequence, id'
check_id = fields.Many2one(
'fusion.plating.quality.check', string='Check',
required=True, ondelete='cascade', index=True,
)
sequence = fields.Integer(default=10)
name = fields.Char(string='Check Item', required=True)
description = fields.Text(string='Guidance')
check_type = fields.Selection(
selection=lambda self: self.env[
'fp.qc.checklist.template.line'
]._fields['check_type'].selection,
string='Type', default='visual',
)
required = fields.Boolean(default=True)
requires_value = fields.Boolean()
value = fields.Float(digits=(12, 4))
value_min = fields.Float(digits=(12, 4))
value_max = fields.Float(digits=(12, 4))
value_uom = fields.Char(string='Unit')
requires_photo = fields.Boolean()
photo_attachment_id = fields.Many2one(
'ir.attachment', string='Photo',
)
result = fields.Selection(
[
('pending', 'Pending'),
('pass', 'Pass'),
('fail', 'Fail'),
('na', 'N/A'),
],
string='Result', default='pending', required=True,
)
notes = fields.Text(string='Note')
inspector_id = fields.Many2one('res.users', string='Inspector')
completed_at = fields.Datetime(string='Completed At')
value_in_range = fields.Boolean(
compute='_compute_value_in_range', store=True,
)
@api.depends('value', 'value_min', 'value_max', 'requires_value')
def _compute_value_in_range(self):
for rec in self:
if not rec.requires_value:
rec.value_in_range = True
continue
vmin = rec.value_min
vmax = rec.value_max
if vmin and rec.value < vmin:
rec.value_in_range = False
elif vmax and rec.value > vmax:
rec.value_in_range = False
else:
rec.value_in_range = True
def action_mark_pass(self):
for rec in self:
if rec.requires_value and not rec.value_in_range:
raise UserError(_(
'Cannot pass "%(item)s" — value %(val)s is outside '
'the acceptance range (%(min)s %(max)s %(uom)s).'
) % {
'item': rec.name,
'val': rec.value,
'min': rec.value_min,
'max': rec.value_max,
'uom': rec.value_uom or '',
})
if rec.requires_photo and not rec.photo_attachment_id:
raise UserError(_(
'Cannot pass "%(item)s" — a photo is required.'
) % {'item': rec.name})
rec.write({
'result': 'pass',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})
def action_mark_fail(self):
for rec in self:
rec.write({
'result': 'fail',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})
def action_mark_na(self):
for rec in self:
if rec.required:
raise UserError(_(
'"%(item)s" is a required check and cannot be '
'marked N/A.'
) % {'item': rec.name})
rec.write({
'result': 'na',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})

View File

@@ -30,10 +30,19 @@ class FpQualityHold(models.Model):
) )
# ----- What's on hold ----- # ----- What's on hold -----
# NOTE: workorder_id, production_id, and portal_job_id live in # Phase 1 (Sub 11) — native plating-job link replaces the legacy
# fusion_plating_bridge_mrp (which depends on mrp and # workorder_id / production_id pair that lived in bridge_mrp.
# fusion_plating_portal). Keeping them here would force hard # The bridge fields stay during the migration window so existing
# dependencies and break minimal CE-only installs. # records keep their FKs; Phase 5 removes bridge_mrp entirely.
job_id = fields.Many2one(
'fp.job', string='Plating Job',
index=True, ondelete='set null',
)
step_id = fields.Many2one(
'fp.job.step', string='Job Step',
domain="[('job_id', '=', job_id)]",
ondelete='set null',
)
part_ref = fields.Char(string='Part Number') part_ref = fields.Char(string='Part Number')
# ----- Hold details ----- # ----- Hold details -----

View File

@@ -0,0 +1,29 @@
# -*- 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 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# Adds the back-reference from fp.thickness.reading to the QC record
# that produced it. Lives here (not in fusion_plating_certificates)
# because the link target is fusion.plating.quality.check, owned by
# fusion_plating_quality.
from odoo import fields, models
class FpThicknessReading(models.Model):
_inherit = 'fp.thickness.reading'
quality_check_id = fields.Many2one(
'fusion.plating.quality.check', string='Quality Check',
ondelete='set null', index=True,
help='The QC record the reading belongs to (populated when '
'readings are logged from the mobile QC checklist).',
)
auto_extracted = fields.Boolean(
string='Auto-Extracted',
help='True for readings parsed out of a Fischerscope PDF. '
'These are replaced when the PDF is re-uploaded; '
'manually-entered readings are preserved.',
)

View File

@@ -17,3 +17,17 @@ class ResPartner(models.Model):
'fully optional — the reminder can be dismissed and never ' 'fully optional — the reminder can be dismissed and never '
'blocks production.', 'blocks production.',
) )
# Phase 4 (Sub 11) — relocated from fusion_plating_bridge_mrp.
x_fc_requires_qc = fields.Boolean(
string='Require QC Sign-off',
default=False, tracking=True,
help='When enabled, a job for this customer cannot be marked '
'complete until a QC inspector has signed off on the '
'quality checklist.',
)
x_fc_qc_template_id = fields.Many2one(
'fp.qc.checklist.template', string='QC Checklist Template',
help='Override the auto-resolved template for this customer. '
'Leave blank to use any active customer-specific template, '
'falling back to the global default.',
)

View File

@@ -32,3 +32,15 @@ access_fp_quality_hold_manager,fp.quality.hold.manager,model_fusion_plating_qual
access_fp_contract_review_operator,fp.contract.review.operator,model_fp_contract_review,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_contract_review_operator,fp.contract.review.operator,model_fp_contract_review,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_contract_review_supervisor,fp.contract.review.supervisor,model_fp_contract_review,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_contract_review_supervisor,fp.contract.review.supervisor,model_fp_contract_review,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_contract_review_manager,fp.contract.review.manager,model_fp_contract_review,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_contract_review_manager,fp.contract.review.manager,model_fp_contract_review,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
32 access_fp_contract_review_operator fp.contract.review.operator model_fp_contract_review fusion_plating.group_fusion_plating_operator 1 0 0 0
33 access_fp_contract_review_supervisor fp.contract.review.supervisor model_fp_contract_review fusion_plating.group_fusion_plating_supervisor 1 1 1 0
34 access_fp_contract_review_manager fp.contract.review.manager model_fp_contract_review fusion_plating.group_fusion_plating_manager 1 1 1 1
35 access_fp_qc_check_operator fusion.plating.quality.check.operator model_fusion_plating_quality_check fusion_plating.group_fusion_plating_operator 1 1 1 0
36 access_fp_qc_check_supervisor fusion.plating.quality.check.supervisor model_fusion_plating_quality_check fusion_plating.group_fusion_plating_supervisor 1 1 1 0
37 access_fp_qc_check_manager fusion.plating.quality.check.manager model_fusion_plating_quality_check fusion_plating.group_fusion_plating_manager 1 1 1 1
38 access_fp_qc_check_line_operator fusion.plating.quality.check.line.operator model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_operator 1 1 1 0
39 access_fp_qc_check_line_supervisor fusion.plating.quality.check.line.supervisor model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
40 access_fp_qc_check_line_manager fusion.plating.quality.check.line.manager model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_manager 1 1 1 1
41 access_fp_qc_template_operator fp.qc.checklist.template.operator model_fp_qc_checklist_template fusion_plating.group_fusion_plating_operator 1 0 0 0
42 access_fp_qc_template_supervisor fp.qc.checklist.template.supervisor model_fp_qc_checklist_template fusion_plating.group_fusion_plating_supervisor 1 1 1 0
43 access_fp_qc_template_manager fp.qc.checklist.template.manager model_fp_qc_checklist_template fusion_plating.group_fusion_plating_manager 1 1 1 1
44 access_fp_qc_template_line_operator fp.qc.checklist.template.line.operator model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_operator 1 0 0 0
45 access_fp_qc_template_line_supervisor fp.qc.checklist.template.line.supervisor model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
46 access_fp_qc_template_line_manager fp.qc.checklist.template.line.manager model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,349 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Mobile QC Checklist (OWL backend client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Matches the existing Tablet Station / Plant Overview conventions:
// * `static template` + `static props = ["*"]`
// * Standalone rpc() from @web/core/network/rpc
// * Design tokens from _fp_shopfloor_tokens.scss (no borders, shadow
// elevation, 48 px touch targets)
//
// Invoked either via the MO "Open QC" smart-button (action_open_tablet)
// or directly with `ir.actions.client` tag `fp_qc_checklist` and the
// action's params.check_id.
// =============================================================================
import { Component, useState, onMounted, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class FpQcChecklist extends Component {
static template = "fusion_plating_quality.FpQcChecklist";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.fileInput = useRef("fileInput");
this.pdfInput = useRef("pdfInput");
this.photoLineId = null;
this.state = useState({
loading: true,
saving: false,
error: null,
check: null,
lines: [],
expandedLineId: null,
showFinalize: false,
finalizeNotes: "",
});
// action.params (from ir.actions.client) is the canonical
// source; fall back to URL query params for deep-linking.
const params = (this.props.action && this.props.action.params) || {};
this.checkId = params.check_id || null;
this.jobId = params.job_id || null;
onMounted(() => this.refresh());
}
// ------------------------------------------------------------------
// Data
// ------------------------------------------------------------------
async refresh() {
this.state.loading = true;
this.state.error = null;
try {
const res = await rpc("/fp/qc/get", {
check_id: this.checkId,
job_id: this.jobId,
});
if (!res.ok) {
this.state.error = res.error === "no_qc"
? "No QC checklist exists for this MO yet."
: (res.error || "QC not found");
return;
}
this.state.check = res.check;
this.state.lines = res.lines || [];
this.checkId = res.check.id;
} catch (err) {
this.state.error = err && err.message ? err.message : String(err);
} finally {
this.state.loading = false;
}
}
// ------------------------------------------------------------------
// Line actions
// ------------------------------------------------------------------
async markLine(line, result) {
if (this.state.saving) return;
this.state.saving = true;
try {
const payload = {
check_id: this.checkId,
line_id: line.id,
result,
};
if (line.requires_value) {
payload.value = line.value;
}
if (line.notes !== undefined) payload.notes = line.notes;
const res = await rpc("/fp/qc/line/mark", payload);
if (!res.ok) {
this.notification.add(res.error || "Mark failed", {
type: "danger",
title: line.name,
});
return;
}
// Merge updated line into state
const idx = this.state.lines.findIndex((l) => l.id === line.id);
if (idx >= 0) this.state.lines[idx] = res.line;
this.state.check = res.check;
this.notification.add(
result === "pass" ? "Passed" : result === "fail" ? "Failed" : "Marked",
{ type: result === "fail" ? "danger" : "success" },
);
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.state.saving = false;
}
}
// Value input — debounced write on blur. Pending result stays until
// operator taps pass/fail.
onValueInput(line, ev) {
const v = parseFloat(ev.target.value);
line.value = isNaN(v) ? 0 : v;
if (line.requires_value) {
const inRange =
(!line.value_min || line.value >= line.value_min) &&
(!line.value_max || line.value <= line.value_max);
line.value_in_range = inRange;
}
}
onNotesInput(line, ev) {
line.notes = ev.target.value;
}
toggleExpanded(line) {
this.state.expandedLineId =
this.state.expandedLineId === line.id ? null : line.id;
}
// ------------------------------------------------------------------
// Photo upload
// ------------------------------------------------------------------
triggerPhoto(line) {
this.photoLineId = line.id;
if (this.fileInput.el) {
this.fileInput.el.value = "";
this.fileInput.el.click();
}
}
async onPhotoSelected(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file || !this.photoLineId) return;
const fd = new FormData();
fd.append("file", file);
fd.append("line_id", this.photoLineId);
try {
const resp = await fetch("/fp/qc/line/photo", {
method: "POST",
body: fd,
credentials: "same-origin",
});
const json = await resp.json();
if (!json.ok) {
this.notification.add(json.error || "Upload failed", {
type: "danger",
});
return;
}
this.notification.add("Photo uploaded", { type: "success" });
await this.refresh();
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.photoLineId = null;
}
}
// ------------------------------------------------------------------
// Fischerscope PDF upload
// ------------------------------------------------------------------
triggerPdfUpload() {
if (this.pdfInput.el) {
this.pdfInput.el.value = "";
this.pdfInput.el.click();
}
}
async onPdfSelected(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
fd.append("check_id", this.checkId);
try {
this.state.saving = true;
const resp = await fetch("/fp/qc/thickness_pdf", {
method: "POST",
body: fd,
credentials: "same-origin",
});
const json = await resp.json();
if (!json.ok) {
this.notification.add(json.error || "Upload failed", {
type: "danger",
});
return;
}
this.notification.add(
`Uploaded — ${json.reading_count || 0} reading(s) extracted`,
{ type: "success" },
);
await this.refresh();
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.state.saving = false;
}
}
// ------------------------------------------------------------------
// Finalize
// ------------------------------------------------------------------
openFinalize() {
this.state.showFinalize = true;
this.state.finalizeNotes = this.state.check
? this.state.check.notes || ""
: "";
}
closeFinalize() {
this.state.showFinalize = false;
}
async finalize(result) {
try {
this.state.saving = true;
const res = await rpc("/fp/qc/finalize", {
check_id: this.checkId,
result,
notes: this.state.finalizeNotes,
});
if (!res.ok) {
this.notification.add(res.error || "Finalize failed", {
type: "danger",
});
return;
}
this.state.check = res.check;
this.state.showFinalize = false;
this.notification.add(
result === "pass"
? "QC passed. MO can now be marked Done."
: result === "fail"
? "QC failed. Go to the MO to decide scrap/rework."
: "QC flagged for rework.",
{ type: result === "pass" ? "success" : "warning" },
);
await this.refresh();
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.state.saving = false;
}
}
// ------------------------------------------------------------------
// Navigation
// ------------------------------------------------------------------
async openJob() {
if (!this.state.check || !this.state.check.job_id) return;
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job",
res_id: this.state.check.job_id,
views: [[false, "form"]],
target: "current",
});
}
// ------------------------------------------------------------------
// Helpers used by the template
// ------------------------------------------------------------------
resultBadgeClass(result) {
return {
pass: "o_fp_qc_badge_pass",
fail: "o_fp_qc_badge_fail",
na: "o_fp_qc_badge_na",
pending: "o_fp_qc_badge_pending",
}[result || "pending"] || "o_fp_qc_badge_pending";
}
checkTypeIcon(type) {
return {
visual: "fa-eye",
dimensional: "fa-arrows-h",
thickness: "fa-bar-chart",
adhesion: "fa-link",
hardness: "fa-diamond",
salt_spray: "fa-tint",
functional: "fa-cogs",
other: "fa-circle-o",
}[type] || "fa-circle-o";
}
get progressPercent() {
if (!this.state.check || !this.state.check.line_count) return 0;
const done = this.state.check.lines_passed +
this.state.check.lines_failed;
return Math.round((done / this.state.check.line_count) * 100);
}
get canFinalize() {
if (!this.state.check) return false;
if (["passed", "failed"].includes(this.state.check.state)) return false;
// Required items must be resolved
const pendingRequired = this.state.lines.filter(
(l) => l.required && (l.result === "pending" || !l.result),
);
if (pendingRequired.length > 0) return false;
// Thickness PDF requirement
if (this.state.check.require_thickness_report_pdf &&
!this.state.check.has_thickness_pdf) return false;
// Thickness readings requirement
if (this.state.check.require_thickness_readings &&
this.state.check.thickness_reading_count === 0) return false;
return true;
}
get anyFailed() {
return this.state.lines.some((l) => l.result === "fail");
}
}
registry.category("actions").add("fp_qc_checklist", FpQcChecklist);

View File

@@ -0,0 +1,518 @@
// =============================================================================
// Fusion Plating — Mobile QC Checklist styles
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Built on the shop-floor design system tokens (_fp_shopfloor_tokens.scss).
// Same language as Tablet Station / Plant Overview: no borders, shadow-
// based elevation, 48 px touch targets, three-layer contrast.
// =============================================================================
.o_fp_qc {
background-color: $fp-page;
color: $fp-ink;
min-height: 100vh;
padding: $fp-space-4;
font-family: $fp-font-stack;
font-size: $fp-text-base;
// ---------- State ----------
.o_fp_qc_state_loading,
.o_fp_qc_state_error {
max-width: 480px;
margin: $fp-space-10 auto;
@include fp-card($fp-elev-2);
padding: $fp-space-7;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: $fp-space-3;
.fa {
font-size: $fp-text-2xl;
color: $fp-ink-mute;
}
p { color: $fp-ink-soft; margin: 0; }
}
.o_fp_qc_state_error .fa { color: $fp-bad; }
// ---------- Header ----------
.o_fp_qc_header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $fp-space-4;
margin-bottom: $fp-space-5;
.o_fp_qc_header_left {
display: flex;
gap: $fp-space-3;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.o_fp_qc_back {
width: $fp-touch-min;
height: $fp-touch-min;
border-radius: $fp-radius-md;
background-color: $fp-card;
box-shadow: $fp-elev-1;
border: none;
color: $fp-ink-soft;
font-size: $fp-text-md;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow $fp-dur $fp-ease;
@include fp-hover-only {
&:hover { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_title_block {
min-width: 0;
flex: 1;
}
.o_fp_qc_breadcrumb {
color: $fp-ink-mute;
font-size: $fp-text-sm;
margin-bottom: $fp-space-1;
}
.o_fp_qc_title {
font-size: $fp-text-2xl;
font-weight: $fp-weight-semibold;
margin: 0 0 $fp-space-1 0;
color: $fp-ink;
line-height: 1.2;
}
.o_fp_qc_sub {
color: $fp-ink-mute;
font-size: $fp-text-sm;
}
.o_fp_qc_sep {
margin: 0 $fp-space-2;
color: $fp-ink-faint;
}
.o_fp_qc_ref { font-weight: $fp-weight-medium; }
}
.o_fp_qc_state_chip {
padding: $fp-space-2 $fp-space-4;
border-radius: $fp-radius-pill;
font-size: $fp-text-sm;
font-weight: $fp-weight-semibold;
letter-spacing: 0.02em;
white-space: nowrap;
&.o_fp_qc_chip_draft { @include fp-pill('--bs-info'); }
&.o_fp_qc_chip_in_progress { @include fp-pill('--bs-warning'); }
&.o_fp_qc_chip_passed { @include fp-pill('--bs-success'); }
&.o_fp_qc_chip_failed { @include fp-pill('--bs-danger'); }
&.o_fp_qc_chip_rework { @include fp-pill('--bs-secondary'); }
}
// ---------- Progress card ----------
.o_fp_qc_progress_card {
@include fp-card($fp-elev-2);
padding: $fp-space-5 $fp-space-6;
margin-bottom: $fp-space-5;
}
.o_fp_qc_progress_numbers {
display: flex;
justify-content: space-between;
align-items: center;
gap: $fp-space-6;
flex-wrap: wrap;
margin-bottom: $fp-space-4;
}
.o_fp_qc_progress_big {
font-size: $fp-text-3xl;
font-weight: $fp-weight-bold;
color: $fp-accent;
line-height: 1;
}
.o_fp_qc_progress_break {
display: flex;
gap: $fp-space-6;
flex-wrap: wrap;
}
.o_fp_qc_counter {
display: flex;
flex-direction: column;
align-items: flex-end;
.o_fp_qc_counter_n {
font-size: $fp-text-xl;
font-weight: $fp-weight-bold;
line-height: 1.1;
}
.o_fp_qc_counter_l {
font-size: $fp-text-xs;
color: $fp-ink-mute;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&.o_fp_qc_counter_pass .o_fp_qc_counter_n { color: $fp-ok; }
&.o_fp_qc_counter_fail .o_fp_qc_counter_n { color: $fp-bad; }
&.o_fp_qc_counter_pending .o_fp_qc_counter_n { color: $fp-ink-mute; }
}
.o_fp_qc_progress_bar {
height: 6px;
background-color: $fp-card-soft;
border-radius: $fp-radius-pill;
overflow: hidden;
}
.o_fp_qc_progress_fill {
height: 100%;
background-color: $fp-accent;
border-radius: $fp-radius-pill;
transition: width $fp-dur $fp-ease;
}
// ---------- Thickness card ----------
.o_fp_qc_thickness_card {
@include fp-card($fp-elev-1);
padding: $fp-space-4 $fp-space-5;
margin-bottom: $fp-space-5;
}
.o_fp_qc_thickness_head {
display: flex;
justify-content: space-between;
align-items: center;
gap: $fp-space-4;
flex-wrap: wrap;
}
.o_fp_qc_thickness_title {
font-size: $fp-text-md;
font-weight: $fp-weight-semibold;
.fa { color: $fp-accent; margin-right: $fp-space-2; }
}
.o_fp_qc_thickness_sub {
font-size: $fp-text-sm;
color: $fp-ink-mute;
margin-top: $fp-space-1;
}
// ---------- Checklist ----------
.o_fp_qc_list {
display: flex;
flex-direction: column;
gap: $fp-space-3;
margin-bottom: $fp-space-6;
}
.o_fp_qc_item {
@include fp-card($fp-elev-1);
overflow: hidden;
transition: box-shadow $fp-dur $fp-ease,
transform $fp-dur $fp-ease;
&.o_fp_qc_item_pass {
// Left accent strip — subtle indicator that doesn't scream at you
background:
linear-gradient(to right, $fp-ok 4px, transparent 4px) $fp-card;
}
&.o_fp_qc_item_fail {
background:
linear-gradient(to right, $fp-bad 4px, transparent 4px) $fp-card;
}
&.o_fp_qc_item_na {
background:
linear-gradient(to right, $fp-ink-faint 4px, transparent 4px) $fp-card;
}
&.o_fp_qc_item_open { box-shadow: $fp-elev-2; }
}
.o_fp_qc_item_row {
display: flex;
align-items: center;
gap: $fp-space-4;
padding: $fp-space-4 $fp-space-5;
min-height: $fp-touch-min + $fp-space-3;
cursor: pointer;
@include fp-hover-only {
&:hover { background-color: color-mix(in srgb, #{$fp-accent} 4%, transparent); }
}
}
.o_fp_qc_item_icon {
width: 40px;
height: 40px;
border-radius: $fp-radius-md;
background-color: $fp-card-soft;
display: flex;
align-items: center;
justify-content: center;
color: $fp-ink-soft;
font-size: $fp-text-md;
flex-shrink: 0;
}
.o_fp_qc_item_body {
flex: 1;
min-width: 0;
}
.o_fp_qc_item_name {
font-size: $fp-text-md;
font-weight: $fp-weight-medium;
color: $fp-ink;
line-height: 1.3;
}
.o_fp_qc_item_optional {
margin-left: $fp-space-2;
font-size: $fp-text-xs;
color: $fp-ink-mute;
font-weight: normal;
}
.o_fp_qc_item_meta {
display: flex;
gap: $fp-space-3;
align-items: center;
margin-top: $fp-space-1;
flex-wrap: wrap;
}
.o_fp_qc_item_value {
font-size: $fp-text-sm;
color: $fp-ink-soft;
font-variant-numeric: tabular-nums;
}
.o_fp_qc_item_photo_ind {
color: $fp-accent;
font-size: $fp-text-sm;
}
.o_fp_qc_badge {
display: inline-block;
padding: 2px $fp-space-2;
font-size: $fp-text-xs;
font-weight: $fp-weight-semibold;
border-radius: $fp-radius-sm;
letter-spacing: 0.04em;
}
.o_fp_qc_badge_pass { @include fp-pill('--bs-success'); }
.o_fp_qc_badge_fail { @include fp-pill('--bs-danger'); }
.o_fp_qc_badge_na { @include fp-pill('--bs-secondary'); }
.o_fp_qc_badge_pending { @include fp-pill('--bs-info'); }
.o_fp_qc_chevron {
color: $fp-ink-mute;
font-size: $fp-text-sm;
flex-shrink: 0;
}
// ---------- Expanded detail ----------
.o_fp_qc_item_detail {
padding: $fp-space-4 $fp-space-5 $fp-space-5;
border-top: 1px solid color-mix(in srgb, #{$fp-border} 60%, transparent);
display: flex;
flex-direction: column;
gap: $fp-space-4;
}
.o_fp_qc_guidance {
background-color: $fp-card-soft;
padding: $fp-space-3 $fp-space-4;
border-radius: $fp-radius-md;
color: $fp-ink-soft;
font-size: $fp-text-sm;
line-height: 1.5;
white-space: pre-wrap;
}
.o_fp_qc_value_row,
.o_fp_qc_notes_row,
.o_fp_qc_photo_row {
display: flex;
flex-direction: column;
gap: $fp-space-2;
label {
font-size: $fp-text-xs;
font-weight: $fp-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $fp-ink-mute;
}
}
.o_fp_qc_value_input {
display: flex;
align-items: center;
gap: $fp-space-3;
input {
flex: 1;
height: $fp-touch-min;
padding: 0 $fp-space-4;
font-size: $fp-text-lg;
font-variant-numeric: tabular-nums;
background-color: $fp-card-soft;
border: none;
border-radius: $fp-radius-md;
color: $fp-ink;
&:focus { @include fp-focus-ring; }
}
.o_fp_qc_uom {
color: $fp-ink-mute;
font-size: $fp-text-md;
min-width: 40px;
}
}
.o_fp_qc_range {
font-size: $fp-text-xs;
color: $fp-ink-mute;
}
.o_fp_qc_notes_row textarea {
width: 100%;
padding: $fp-space-3 $fp-space-4;
font-size: $fp-text-base;
background-color: $fp-card-soft;
border: none;
border-radius: $fp-radius-md;
color: $fp-ink;
font-family: inherit;
resize: vertical;
&:focus { @include fp-focus-ring; }
}
.o_fp_qc_actions_row {
display: flex;
gap: $fp-space-3;
flex-wrap: wrap;
}
// ---------- Buttons ----------
.o_fp_qc_btn {
display: inline-flex;
align-items: center;
gap: $fp-space-2;
min-height: $fp-touch-min;
padding: 0 $fp-space-5;
font-size: $fp-text-md;
font-weight: $fp-weight-semibold;
border: none;
border-radius: $fp-radius-md;
cursor: pointer;
transition: transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease,
background-color $fp-dur $fp-ease;
&:active:not([disabled]) { transform: scale(0.97); }
&[disabled] { opacity: 0.5; cursor: not-allowed; }
.fa { font-size: $fp-text-md; }
}
.o_fp_qc_btn_primary {
background-color: $fp-accent;
color: white;
box-shadow: $fp-elev-1;
@include fp-hover-only {
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_btn_pass,
.o_fp_qc_btn_pass_lg {
background-color: $fp-ok;
color: white;
box-shadow: $fp-elev-1;
@include fp-hover-only {
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_btn_fail,
.o_fp_qc_btn_fail_lg {
background-color: $fp-bad;
color: white;
box-shadow: $fp-elev-1;
@include fp-hover-only {
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_btn_ghost,
.o_fp_qc_btn_ghost_lg {
background-color: $fp-card-soft;
color: $fp-ink-soft;
@include fp-hover-only {
&:hover:not([disabled]) {
background-color: color-mix(in srgb, #{$fp-ink-soft} 10%, $fp-card-soft);
}
}
}
.o_fp_qc_btn_pass_lg,
.o_fp_qc_btn_fail_lg,
.o_fp_qc_btn_ghost_lg {
flex: 1;
min-height: 60px;
font-size: $fp-text-lg;
justify-content: center;
}
// ---------- Sign-off footer ----------
.o_fp_qc_footer {
position: sticky;
bottom: $fp-space-4;
background: color-mix(in srgb, $fp-page 85%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: $fp-space-4;
border-radius: $fp-radius-lg;
box-shadow: $fp-elev-2;
display: flex;
gap: $fp-space-3;
flex-wrap: wrap;
}
// ---------- Responsive ----------
@media (max-width: 640px) {
padding: $fp-space-3;
.o_fp_qc_header .o_fp_qc_title { font-size: $fp-text-xl; }
.o_fp_qc_progress_big { font-size: $fp-text-2xl; }
.o_fp_qc_footer {
flex-direction: column;
.o_fp_qc_btn_pass_lg,
.o_fp_qc_btn_fail_lg,
.o_fp_qc_btn_ghost_lg { width: 100%; }
}
}
}

View File

@@ -0,0 +1,285 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_quality.FpQcChecklist">
<div class="o_fp_qc">
<!-- ===== Loading / error ===== -->
<t t-if="state.loading">
<div class="o_fp_qc_state_loading">
<i class="fa fa-spinner fa-spin"/>
<span>Loading QC…</span>
</div>
</t>
<t t-elif="state.error">
<div class="o_fp_qc_state_error">
<i class="fa fa-exclamation-triangle"/>
<p><t t-esc="state.error"/></p>
<button class="btn btn-primary" t-on-click="refresh">Retry</button>
</div>
</t>
<t t-elif="state.check">
<!-- ===== Header ===== -->
<div class="o_fp_qc_header">
<div class="o_fp_qc_header_left">
<button class="o_fp_qc_back" t-on-click="openJob"
t-if="state.check.job_id"
title="Back to Job">
<i class="fa fa-arrow-left"/>
</button>
<div class="o_fp_qc_title_block">
<div class="o_fp_qc_breadcrumb">
<span><t t-esc="state.check.job_name"/></span>
<t t-if="state.check.partner_name">
<span class="o_fp_qc_sep">·</span>
<span><t t-esc="state.check.partner_name"/></span>
</t>
</div>
<h1 class="o_fp_qc_title">
<t t-esc="state.check.template_name or 'QC Checklist'"/>
</h1>
<div class="o_fp_qc_sub">
<span class="o_fp_qc_ref"><t t-esc="state.check.name"/></span>
<t t-if="state.check.inspector_name">
<span class="o_fp_qc_sep">·</span>
<span>Inspector: <t t-esc="state.check.inspector_name"/></span>
</t>
</div>
</div>
</div>
<div class="o_fp_qc_state_chip"
t-att-class="'o_fp_qc_chip_' + state.check.state">
<t t-esc="state.check.state.replace('_', ' ').toUpperCase()"/>
</div>
</div>
<!-- ===== Progress ===== -->
<div class="o_fp_qc_progress_card">
<div class="o_fp_qc_progress_numbers">
<div class="o_fp_qc_progress_big">
<t t-esc="progressPercent"/>%
</div>
<div class="o_fp_qc_progress_break">
<div class="o_fp_qc_counter o_fp_qc_counter_pass">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_passed"/>
</span>
<span class="o_fp_qc_counter_l">Pass</span>
</div>
<div class="o_fp_qc_counter o_fp_qc_counter_fail">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_failed"/>
</span>
<span class="o_fp_qc_counter_l">Fail</span>
</div>
<div class="o_fp_qc_counter o_fp_qc_counter_pending">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_pending"/>
</span>
<span class="o_fp_qc_counter_l">Pending</span>
</div>
</div>
</div>
<div class="o_fp_qc_progress_bar">
<div class="o_fp_qc_progress_fill"
t-att-style="'width:' + progressPercent + '%'"/>
</div>
</div>
<!-- ===== Thickness PDF (if required) ===== -->
<t t-if="state.check.require_thickness_report_pdf or state.check.require_thickness_readings">
<div class="o_fp_qc_thickness_card">
<div class="o_fp_qc_thickness_head">
<div>
<div class="o_fp_qc_thickness_title">
<i class="fa fa-bar-chart"/>
Thickness Report
</div>
<div class="o_fp_qc_thickness_sub">
<t t-if="state.check.has_thickness_pdf">
PDF uploaded · <t t-esc="state.check.thickness_reading_count"/> reading(s) extracted
</t>
<t t-else="">
Upload Fischerscope / XDAL 600 PDF export
</t>
</div>
</div>
<button class="o_fp_qc_btn o_fp_qc_btn_primary"
t-on-click="triggerPdfUpload"
t-att-disabled="state.saving">
<i class="fa fa-upload"/>
<t t-if="state.check.has_thickness_pdf">Replace PDF</t>
<t t-else="">Upload PDF</t>
</button>
</div>
</div>
</t>
<!-- ===== Checklist ===== -->
<div class="o_fp_qc_list">
<t t-foreach="state.lines" t-as="line" t-key="line.id">
<div class="o_fp_qc_item"
t-att-class="{
'o_fp_qc_item_pass': line.result == 'pass',
'o_fp_qc_item_fail': line.result == 'fail',
'o_fp_qc_item_na': line.result == 'na',
'o_fp_qc_item_pending': line.result == 'pending' or !line.result,
'o_fp_qc_item_open': state.expandedLineId == line.id,
}">
<div class="o_fp_qc_item_row"
t-on-click="() => this.toggleExpanded(line)">
<div class="o_fp_qc_item_icon">
<i class="fa" t-att-class="checkTypeIcon(line.check_type)"/>
</div>
<div class="o_fp_qc_item_body">
<div class="o_fp_qc_item_name">
<t t-esc="line.name"/>
<t t-if="!line.required">
<span class="o_fp_qc_item_optional">(optional)</span>
</t>
</div>
<div class="o_fp_qc_item_meta">
<span class="o_fp_qc_badge"
t-att-class="resultBadgeClass(line.result)">
<t t-esc="(line.result or 'pending').toUpperCase()"/>
</span>
<t t-if="line.requires_value and line.value">
<span class="o_fp_qc_item_value">
<t t-esc="line.value"/>
<t t-esc="line.value_uom"/>
</span>
</t>
<t t-if="line.requires_photo and line.has_photo">
<span class="o_fp_qc_item_photo_ind">
<i class="fa fa-camera"/>
</span>
</t>
</div>
</div>
<i class="o_fp_qc_chevron fa"
t-att-class="state.expandedLineId == line.id ? 'fa-chevron-up' : 'fa-chevron-down'"/>
</div>
<t t-if="state.expandedLineId == line.id">
<div class="o_fp_qc_item_detail">
<t t-if="line.description">
<div class="o_fp_qc_guidance">
<t t-esc="line.description"/>
</div>
</t>
<t t-if="line.requires_value">
<div class="o_fp_qc_value_row">
<label>Measured Value</label>
<div class="o_fp_qc_value_input">
<input type="number" step="0.0001"
t-att-value="line.value or ''"
t-att-placeholder="line.value_uom or ''"
t-on-input="(ev) => this.onValueInput(line, ev)"/>
<span class="o_fp_qc_uom"><t t-esc="line.value_uom"/></span>
</div>
<t t-if="line.value_min or line.value_max">
<div class="o_fp_qc_range">
Range: <t t-esc="line.value_min"/> <t t-esc="line.value_max"/>
<t t-esc="line.value_uom"/>
</div>
</t>
</div>
</t>
<t t-if="line.requires_photo">
<div class="o_fp_qc_photo_row">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.triggerPhoto(line)">
<i class="fa fa-camera"/>
<t t-if="line.has_photo">Replace photo</t>
<t t-else="">Add photo</t>
</button>
</div>
</t>
<div class="o_fp_qc_notes_row">
<label>Notes</label>
<textarea rows="2"
t-att-value="line.notes or ''"
t-on-input="(ev) => this.onNotesInput(line, ev)"
placeholder="Optional — anything the inspector saw that matters"/>
</div>
<div class="o_fp_qc_actions_row">
<button class="o_fp_qc_btn o_fp_qc_btn_pass"
t-on-click="() => this.markLine(line, 'pass')"
t-att-disabled="state.saving">
<i class="fa fa-check"/>
Pass
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_fail"
t-on-click="() => this.markLine(line, 'fail')"
t-att-disabled="state.saving">
<i class="fa fa-times"/>
Fail
</button>
<t t-if="!line.required">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.markLine(line, 'na')"
t-att-disabled="state.saving">
N/A
</button>
</t>
<t t-if="line.result != 'pending'">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.markLine(line, 'pending')"
t-att-disabled="state.saving">
Reset
</button>
</t>
</div>
</div>
</t>
</div>
</t>
</div>
<!-- ===== Sign-off bar ===== -->
<t t-if="state.check.state != 'passed' and state.check.state != 'failed'">
<div class="o_fp_qc_footer">
<button class="o_fp_qc_btn o_fp_qc_btn_pass_lg"
t-on-click="() => this.finalize('pass')"
t-att-disabled="!canFinalize or state.saving">
<i class="fa fa-check"/>
<span>Sign Off — PASS</span>
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_fail_lg"
t-on-click="() => this.finalize('fail')"
t-att-disabled="state.saving">
<i class="fa fa-times"/>
<span>Fail QC</span>
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_ghost_lg"
t-on-click="() => this.finalize('rework')"
t-att-disabled="state.saving or !anyFailed">
Send to Rework
</button>
</div>
</t>
<!-- ===== Hidden file inputs ===== -->
<input type="file" t-ref="fileInput"
accept="image/*" capture="environment"
style="display:none"
t-on-change="onPhotoSelected"/>
<input type="file" t-ref="pdfInput"
accept="application/pdf"
style="display:none"
t-on-change="onPdfSelected"/>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,141 @@
<?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.
Admin-facing views for QC checklist templates. Manager privilege
group required to create / edit templates.
-->
<odoo>
<record id="fp_qc_checklist_template_list" model="ir.ui.view">
<field name="name">fp.qc.checklist.template.list</field>
<field name="model">fp.qc.checklist.template</field>
<field name="arch" type="xml">
<list string="QC Checklist Templates" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="partner_id"/>
<field name="require_inspector_signoff" widget="boolean_toggle"/>
<field name="require_thickness_readings" widget="boolean_toggle"/>
<field name="require_thickness_report_pdf" widget="boolean_toggle"/>
<field name="check_count"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="fp_qc_checklist_template_form" model="ir.ui.view">
<field name="name">fp.qc.checklist.template.form</field>
<field name="model">fp.qc.checklist.template</field>
<field name="arch" type="xml">
<form string="QC Template">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_checks" type="object"
class="oe_stat_button" icon="fa-list">
<field name="check_count" widget="statinfo"
string="QC Instances"/>
</button>
</div>
<widget name="web_ribbon" title="Archived"
invisible="active" bg_color="text-bg-danger"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Aerospace / Nadcap QC"/></h1>
</div>
<group>
<group string="Scope">
<field name="partner_id"
options="{'no_create': True}"
placeholder="Global default (leave blank)"/>
<field name="sequence"/>
<field name="active"/>
</group>
<group string="Gate Policy">
<field name="require_inspector_signoff"/>
<field name="require_thickness_readings"/>
<field name="require_thickness_report_pdf"/>
</group>
</group>
<notebook>
<page string="Checklist Items" name="items">
<field name="line_ids">
<list string="Items" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="check_type"/>
<field name="required" widget="boolean_toggle"/>
<field name="requires_value" widget="boolean_toggle"/>
<field name="value_min" optional="hide"/>
<field name="value_max" optional="hide"/>
<field name="value_uom" optional="hide"/>
<field name="requires_photo" widget="boolean_toggle"/>
</list>
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="check_type"/>
<field name="sequence"/>
</group>
<group>
<field name="required"/>
<field name="requires_photo"/>
<field name="requires_value"/>
</group>
</group>
<group string="Acceptance Range"
invisible="not requires_value">
<field name="value_min"/>
<field name="value_max"/>
<field name="value_uom"/>
</group>
<group string="Guidance">
<field name="description" nolabel="1"
placeholder="Inspection guidance shown to the operator on tap..."/>
</group>
</sheet>
</form>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="fp_qc_checklist_template_search" model="ir.ui.view">
<field name="name">fp.qc.checklist.template.search</field>
<field name="model">fp.qc.checklist.template</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<filter name="global_default" string="Global Default"
domain="[('partner_id', '=', False)]"/>
<filter name="per_customer" string="Per Customer"
domain="[('partner_id', '!=', False)]"/>
<filter name="inactive" string="Archived"
domain="[('active', '=', False)]"/>
<group>
<filter name="by_customer" string="Customer"
context="{'group_by': 'partner_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_qc_checklist_template" model="ir.actions.act_window">
<field name="name">QC Checklist Templates</field>
<field name="res_model">fp.qc.checklist.template</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="fp_qc_checklist_template_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,235 @@
<?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="fp_quality_check_list" model="ir.ui.view">
<field name="name">fusion.plating.quality.check.list</field>
<field name="model">fusion.plating.quality.check</field>
<field name="arch" type="xml">
<list string="QC Checks"
decoration-info="state == 'draft'"
decoration-warning="state == 'in_progress'"
decoration-success="state == 'passed'"
decoration-danger="state == 'failed'"
decoration-muted="state == 'rework'">
<field name="name"/>
<field name="job_id"/>
<field name="partner_id"/>
<field name="template_id"/>
<field name="lines_passed"/>
<field name="lines_failed"/>
<field name="lines_pending"/>
<field name="line_count" optional="hide"/>
<field name="inspector_id"/>
<field name="completed_at"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<record id="fp_quality_check_form" model="ir.ui.view">
<field name="name">fusion.plating.quality.check.form</field>
<field name="model">fusion.plating.quality.check</field>
<field name="arch" type="xml">
<form string="Quality Check">
<header>
<button name="action_open_tablet" type="object"
string="Open Mobile Checklist" class="btn-primary"
invisible="state in ('passed', 'failed')"/>
<button name="action_start" type="object"
string="Start Inspection" class="btn-secondary"
invisible="state != 'draft'"/>
<button name="action_pass" type="object"
string="Mark Passed" class="btn-success"
invisible="state in ('passed', 'failed', 'draft')"/>
<button name="action_fail" type="object"
string="Mark Failed" class="btn-danger"
invisible="state in ('passed', 'failed', 'draft')"
confirm="This marks the QC as FAILED and blocks the MO from closing. Continue?"/>
<button name="action_rework" type="object"
string="Send to Rework" class="btn-warning"
invisible="state in ('passed', 'failed', 'draft')"/>
<button name="action_reset_to_draft" type="object"
string="Reset to Draft"
invisible="state == 'draft'"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<button name="action_spawn_retry" type="object"
string="Create Retry QC" class="btn-secondary"
invisible="state != 'failed'"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,in_progress,passed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_tablet" type="object"
class="oe_stat_button" icon="fa-tablet">
<div class="o_stat_info">
<span class="o_stat_text">Tablet</span>
</div>
</button>
</div>
<widget name="web_ribbon" title="PASSED"
invisible="state != 'passed'" bg_color="text-bg-success"/>
<widget name="web_ribbon" title="FAILED"
invisible="state != 'failed'" bg_color="text-bg-danger"/>
<widget name="web_ribbon" title="REWORK"
invisible="state != 'rework'" bg_color="text-bg-warning"/>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Job">
<field name="job_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="template_id"
options="{'no_create': True}"
readonly="state != 'draft'"/>
</group>
<group string="Sign-off">
<field name="inspector_id" readonly="1"/>
<field name="started_at" readonly="1"/>
<field name="completed_at" readonly="1"/>
<field name="overall_result" readonly="1"/>
</group>
</group>
<group string="Progress">
<group>
<field name="line_count" readonly="1"/>
<field name="lines_passed" readonly="1"/>
</group>
<group>
<field name="lines_failed" readonly="1"/>
<field name="lines_pending" readonly="1"/>
</group>
</group>
<notebook>
<page string="Checklist" name="lines">
<field name="line_ids">
<list editable="bottom"
decoration-success="result == 'pass'"
decoration-danger="result == 'fail'"
decoration-muted="result == 'na'">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="check_type"/>
<field name="required" widget="boolean_toggle"/>
<field name="requires_value" column_invisible="1"/>
<field name="value" optional="show"
invisible="not requires_value"/>
<field name="value_uom" optional="hide"/>
<field name="value_min" optional="hide"/>
<field name="value_max" optional="hide"/>
<field name="value_in_range" widget="boolean_toggle"
optional="hide"/>
<field name="requires_photo" column_invisible="1"/>
<field name="photo_attachment_id"
invisible="not requires_photo"
widget="many2one_binary" optional="show"/>
<field name="notes" optional="hide"/>
<field name="result" widget="badge"
decoration-success="result == 'pass'"
decoration-danger="result == 'fail'"
decoration-muted="result == 'na'"
decoration-info="result == 'pending'"/>
<button name="action_mark_pass" type="object"
icon="fa-check" title="Pass"
invisible="result == 'pass'"/>
<button name="action_mark_fail" type="object"
icon="fa-times" title="Fail"
invisible="result == 'fail'"/>
</list>
</field>
</page>
<page string="Thickness Report" name="thickness">
<group>
<field name="thickness_report_pdf_id"
widget="many2one_binary"
help="Upload the Fischerscope / XDAL 600 PDF — readings will be auto-extracted."/>
<field name="thickness_reading_count" readonly="1"/>
<field name="require_thickness_readings" readonly="1"/>
<field name="require_thickness_report_pdf" readonly="1"/>
</group>
<field name="thickness_reading_ids">
<list editable="bottom">
<field name="reading_number"/>
<field name="position_label"/>
<field name="nip_mils"/>
<field name="ni_percent"/>
<field name="p_percent"/>
<field name="auto_extracted" widget="boolean_toggle"/>
<field name="operator_id"/>
<field name="reading_datetime"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="fp_quality_check_search" model="ir.ui.view">
<field name="name">fusion.plating.quality.check.search</field>
<field name="model">fusion.plating.quality.check</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="job_id"/>
<field name="partner_id"/>
<field name="inspector_id"/>
<filter name="draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="in_progress" string="In Progress"
domain="[('state', '=', 'in_progress')]"/>
<filter name="passed" string="Passed"
domain="[('state', '=', 'passed')]"/>
<filter name="failed" string="Failed"
domain="[('state', '=', 'failed')]"/>
<filter name="rework" string="Rework"
domain="[('state', '=', 'rework')]"/>
<separator/>
<filter name="my_inspections" string="My Inspections"
domain="[('inspector_id', '=', uid)]"/>
<group>
<filter name="by_customer" string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="by_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="by_template" string="Template"
context="{'group_by': 'template_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_quality_check" model="ir.actions.act_window">
<field name="name">Quality Checks</field>
<field name="res_model">fusion.plating.quality.check</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="fp_quality_check_search"/>
</record>
<!-- ===== Menu — add QC Checks + QC Templates under Quality ===== -->
<menuitem id="menu_fp_quality_check"
name="Quality Checks"
parent="fusion_plating_quality.menu_fp_quality"
action="action_fp_quality_check"
sequence="7"/>
<menuitem id="menu_fp_config_qc_template"
name="QC Checklist Templates"
parent="fusion_plating.menu_fp_config"
action="action_fp_qc_checklist_template"
sequence="85"
groups="fusion_plating.group_fusion_plating_manager"/>
</odoo>

View File

@@ -0,0 +1,39 @@
<?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.
Extend res.partner with the QC requirement flag + template picker.
Adds a "Quality Control" group to the "Plating Documents" tab that
fusion_plating_certificates opens on the partner form.
-->
<odoo>
<record id="view_partner_form_fp_qc" model="ir.ui.view">
<field name="name">res.partner.form.fp.qc</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="fusion_plating_certificates.view_partner_form_fp_document_prefs"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='fp_document_prefs']" position="inside">
<group string="Quality Control"
name="fp_qc_prefs_group">
<p class="text-muted" colspan="2">
When QC sign-off is required, confirming a Manufacturing Order
auto-creates a checklist from the active template. The MO
cannot be marked Done until an inspector passes the QC.
</p>
<group>
<field name="x_fc_requires_qc" widget="boolean_toggle"/>
</group>
<group>
<field name="x_fc_qc_template_id"
options="{'no_create': True}"
invisible="not x_fc_requires_qc"/>
</group>
</group>
</xpath>
</field>
</record>
</odoo>

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