From d9f58b98519dfda2f377b0dd74df6bfb8aeb1df1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 26 Apr 2026 15:05:17 -0400 Subject: [PATCH] changes --- fusion_plating/CLAUDE.md | 276 ++++++++ fusion_plating/fusion_plating/__manifest__.py | 4 +- .../fusion_plating/data/fp_work_role_data.xml | 76 +++ .../migrations/19.0.9.0.0/__init__.py | 0 .../migrations/19.0.9.0.0/pre-migration.py | 48 ++ .../fusion_plating/models/__init__.py | 9 + .../models/fp_job_step_timelog.py | 12 + .../models/fp_process_node_inherit.py | 23 + .../fusion_plating/models/fp_proficiency.py | 163 +++++ .../fusion_plating/models/fp_work_role.py | 62 ++ .../fusion_plating/models/hr_employee.py | 161 +++++ .../security/ir.model.access.csv | 5 + .../views/fp_work_role_views.xml | 165 +++++ .../fusion_plating_batch/__manifest__.py | 2 +- .../migrations/19.0.2.0.0/__init__.py | 0 .../migrations/19.0.2.0.0/pre-migration.py | 13 + .../fusion_plating_batch/models/fp_batch.py | 12 +- .../fusion_plating_bridge_mrp/__manifest__.py | 31 +- .../controllers/__init__.py | 3 +- .../models/__init__.py | 35 +- .../models/fp_job_consumption.py | 79 +-- .../models/fp_job_node_override.py | 10 +- .../models/fp_quality_check.py | 621 +----------------- .../models/mrp_production.py | 54 +- .../models/mrp_workorder.py | 9 +- .../models/sale_order.py | 45 +- .../security/ir.model.access.csv | 26 +- .../views/mrp_workorder_views.xml | 1 - .../views/sale_order_views.xml | 17 +- .../__manifest__.py | 3 +- .../migrations/19.0.5.0.0/__init__.py | 0 .../migrations/19.0.5.0.0/pre-migration.py | 13 + .../models/fp_certificate.py | 36 +- .../models/fp_thickness_reading.py | 18 +- .../views/fp_certificate_views.xml | 1 - .../__manifest__.py | 5 +- .../fp_part_composer_controller.py | 156 ++++- .../data/fp_configurator_sequence_data.xml | 8 + .../migrations/19.0.15.0.0/__init__.py | 0 .../migrations/19.0.15.0.0/post-migration.py | 56 ++ .../models/fp_part_catalog.py | 52 +- .../models/fp_pricing_complexity_surcharge.py | 11 +- .../models/fp_process_node.py | 21 + .../models/fp_quote_configurator.py | 47 +- .../models/sale_order.py | 68 +- .../models/sale_order_line.py | 23 + .../security/ir.model.access.csv | 2 + .../static/src/js/fp_part_process_composer.js | 129 +++- .../src/xml/fp_part_process_composer.xml | 137 ++-- .../views/fp_configurator_menu.xml | 6 + .../views/fp_part_catalog_views.xml | 20 +- .../views/fp_quote_configurator_views.xml | 8 +- .../views/sale_order_views.xml | 20 +- .../wizard/__init__.py | 1 + .../wizard/fp_add_from_quote_wizard.py | 12 +- .../wizard/fp_direct_order_line.py | 109 ++- .../wizard/fp_direct_order_wizard.py | 185 +++++- .../wizard/fp_direct_order_wizard_views.xml | 177 ++++- .../wizard/fp_quote_promote_wizard.py | 112 ++++ .../wizard/fp_quote_promote_wizard_views.xml | 50 ++ .../models/fp_invoice_strategy_default.py | 13 +- .../fusion_plating_jobs/__manifest__.py | 5 +- .../migrations/19.0.6.0.0/__init__.py | 0 .../migrations/19.0.6.0.0/pre-migration.py | 89 +++ .../fusion_plating_jobs/models/__init__.py | 9 + .../fusion_plating_jobs/models/fp_job.py | 44 +- .../models/fp_job_consumption.py | 104 +++ .../models/fp_job_node_override.py | 10 +- .../fusion_plating_jobs/models/fp_job_step.py | 28 + .../models/fp_racking_inspection.py | 55 +- .../fusion_plating_jobs/models/sale_order.py | 251 +++++++ .../security/ir.model.access.csv | 3 + .../views/fp_job_consumption_views.xml | 66 ++ .../views/fp_step_priority_views.xml | 101 +++ .../views/sale_order_views.xml | 30 + .../__manifest__.py | 4 +- .../data/mail_template_data.xml | 56 +- .../models/__init__.py | 4 +- .../models/fp_delivery.py | 17 +- .../models/fp_notification_log.py | 19 +- .../fusion_plating_quality/__init__.py | 1 + .../fusion_plating_quality/__manifest__.py | 12 +- .../controllers/__init__.py | 2 + .../controllers/fp_qc_controller.py | 284 ++++++++ .../data/fp_qc_data.xml | 161 +++++ .../migrations/19.0.3.0.0/__init__.py | 0 .../migrations/19.0.3.0.0/pre-migration.py | 69 ++ .../fusion_plating_quality/models/__init__.py | 5 + .../models/fp_qc_template.py | 171 +++++ .../models/fp_quality_check.py | 549 ++++++++++++++++ .../models/fp_quality_hold.py | 17 +- .../models/fp_thickness_reading.py | 29 + .../models/res_partner.py | 14 + .../security/ir.model.access.csv | 12 + .../static/src/js/fp_qc_checklist.js | 349 ++++++++++ .../static/src/scss/fp_qc_checklist.scss | 518 +++++++++++++++ .../static/src/xml/fp_qc_checklist.xml | 285 ++++++++ .../views/fp_qc_template_views.xml | 141 ++++ .../views/fp_quality_check_views.xml | 235 +++++++ .../views/res_partner_qc_views.xml | 39 ++ .../fusion_plating_receiving/__manifest__.py | 3 +- .../migrations/19.0.3.3.0/__init__.py | 0 .../migrations/19.0.3.3.0/pre-migration.py | 12 + .../models/__init__.py | 3 +- .../models/fp_racking_inspection.py | 59 +- .../models/fp_receiving_damage.py | 15 +- .../models/fp_receiving_line.py | 9 +- .../views/fp_racking_inspection_views.xml | 6 +- .../fusion_plating_reports/__manifest__.py | 4 +- .../controllers/wo_scan.py | 32 +- 110 files changed, 6210 insertions(+), 1182 deletions(-) create mode 100644 fusion_plating/fusion_plating/data/fp_work_role_data.xml create mode 100644 fusion_plating/fusion_plating/migrations/19.0.9.0.0/__init__.py create mode 100644 fusion_plating/fusion_plating/migrations/19.0.9.0.0/pre-migration.py create mode 100644 fusion_plating/fusion_plating/models/fp_process_node_inherit.py create mode 100644 fusion_plating/fusion_plating/models/fp_proficiency.py create mode 100644 fusion_plating/fusion_plating/models/fp_work_role.py create mode 100644 fusion_plating/fusion_plating/models/hr_employee.py create mode 100644 fusion_plating/fusion_plating/views/fp_work_role_views.xml create mode 100644 fusion_plating/fusion_plating_batch/migrations/19.0.2.0.0/__init__.py create mode 100644 fusion_plating/fusion_plating_batch/migrations/19.0.2.0.0/pre-migration.py create mode 100644 fusion_plating/fusion_plating_certificates/migrations/19.0.5.0.0/__init__.py create mode 100644 fusion_plating/fusion_plating_certificates/migrations/19.0.5.0.0/pre-migration.py create mode 100644 fusion_plating/fusion_plating_configurator/migrations/19.0.15.0.0/__init__.py create mode 100644 fusion_plating/fusion_plating_configurator/migrations/19.0.15.0.0/post-migration.py create mode 100644 fusion_plating/fusion_plating_configurator/wizard/fp_quote_promote_wizard.py create mode 100644 fusion_plating/fusion_plating_configurator/wizard/fp_quote_promote_wizard_views.xml create mode 100644 fusion_plating/fusion_plating_jobs/migrations/19.0.6.0.0/__init__.py create mode 100644 fusion_plating/fusion_plating_jobs/migrations/19.0.6.0.0/pre-migration.py create mode 100644 fusion_plating/fusion_plating_jobs/models/fp_job_consumption.py create mode 100644 fusion_plating/fusion_plating_jobs/views/fp_job_consumption_views.xml create mode 100644 fusion_plating/fusion_plating_jobs/views/fp_step_priority_views.xml create mode 100644 fusion_plating/fusion_plating_jobs/views/sale_order_views.xml create mode 100644 fusion_plating/fusion_plating_quality/controllers/__init__.py create mode 100644 fusion_plating/fusion_plating_quality/controllers/fp_qc_controller.py create mode 100644 fusion_plating/fusion_plating_quality/data/fp_qc_data.xml create mode 100644 fusion_plating/fusion_plating_quality/migrations/19.0.3.0.0/__init__.py create mode 100644 fusion_plating/fusion_plating_quality/migrations/19.0.3.0.0/pre-migration.py create mode 100644 fusion_plating/fusion_plating_quality/models/fp_qc_template.py create mode 100644 fusion_plating/fusion_plating_quality/models/fp_quality_check.py create mode 100644 fusion_plating/fusion_plating_quality/models/fp_thickness_reading.py create mode 100644 fusion_plating/fusion_plating_quality/static/src/js/fp_qc_checklist.js create mode 100644 fusion_plating/fusion_plating_quality/static/src/scss/fp_qc_checklist.scss create mode 100644 fusion_plating/fusion_plating_quality/static/src/xml/fp_qc_checklist.xml create mode 100644 fusion_plating/fusion_plating_quality/views/fp_qc_template_views.xml create mode 100644 fusion_plating/fusion_plating_quality/views/fp_quality_check_views.xml create mode 100644 fusion_plating/fusion_plating_quality/views/res_partner_qc_views.xml create mode 100644 fusion_plating/fusion_plating_receiving/migrations/19.0.3.3.0/__init__.py create mode 100644 fusion_plating/fusion_plating_receiving/migrations/19.0.3.3.0/pre-migration.py diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 61e6915f..594771d2 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -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 | | 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 | +| 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 | | ∞ | 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) @@ -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-*-design.md`. 4. Read the implementation plan if one exists. 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** (D1–D8 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/` 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()` 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/` 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 A–F 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 A–F 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; +``` diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 7cddbac7..6baea10f 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.8.7.1', + 'version': '19.0.9.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ @@ -100,6 +100,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_job_views.xml', 'views/fp_job_step_views.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_steel_basic.xml', 'data/fp_recipe_enp_sp.xml', diff --git a/fusion_plating/fusion_plating/data/fp_work_role_data.xml b/fusion_plating/fusion_plating/data/fp_work_role_data.xml new file mode 100644 index 00000000..b50c5ca8 --- /dev/null +++ b/fusion_plating/fusion_plating/data/fp_work_role_data.xml @@ -0,0 +1,76 @@ + + + + + + Masking + masking + 10 + fa-scissors + Applies masking tape/lacquer before plating and removes after. + + + + Racking + racking + 20 + fa-cogs + Fixtures parts onto racks/barrels for processing. + + + + Plating Operator + plating_op + 30 + fa-flask + Runs the plating line — chemistry checks, dwell, thickness. + + + + De-Mask + demask + 40 + fa-scissors + Removes masking material after plating. + + + + Oven / Bake + oven + 50 + fa-fire + Loads and operates embrittlement-relief ovens. + + + + De-Rack + derack + 60 + fa-cogs + Removes parts from racks/barrels for inspection. + + + + Inspection / QA + inspection + 70 + fa-search + Post-plate inspection, Fischerscope, first-piece sign-off. + + + + Rework + rework + 80 + fa-wrench + Strips bad plating; routes parts back for re-processing. + + + diff --git a/fusion_plating/fusion_plating/migrations/19.0.9.0.0/__init__.py b/fusion_plating/fusion_plating/migrations/19.0.9.0.0/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_plating/fusion_plating/migrations/19.0.9.0.0/pre-migration.py b/fusion_plating/fusion_plating/migrations/19.0.9.0.0/pre-migration.py new file mode 100644 index 00000000..64e8c234 --- /dev/null +++ b/fusion_plating/fusion_plating/migrations/19.0.9.0.0/pre-migration.py @@ -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, + ) diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index 7ce307c6..90e6506a 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -23,3 +23,12 @@ from . import fp_operator_certification from . import fp_tz from . import res_company 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 diff --git a/fusion_plating/fusion_plating/models/fp_job_step_timelog.py b/fusion_plating/fusion_plating/models/fp_job_step_timelog.py index 978fd80b..78fb8b89 100644 --- a/fusion_plating/fusion_plating/models/fp_job_step_timelog.py +++ b/fusion_plating/fusion_plating/models/fp_job_step_timelog.py @@ -40,3 +40,15 @@ class FpJobStepTimeLog(models.Model): log.duration_minutes = delta.total_seconds() / 60.0 else: 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) diff --git a/fusion_plating/fusion_plating/models/fp_process_node_inherit.py b/fusion_plating/fusion_plating/models/fp_process_node_inherit.py new file mode 100644 index 00000000..58784235 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_process_node_inherit.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating/models/fp_proficiency.py b/fusion_plating/fusion_plating/models/fp_proficiency.py new file mode 100644 index 00000000..3a47b11b --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_proficiency.py @@ -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(_( + '%(name)s promoted — qualified for ' + '%(role)s after %(count)s successful ' + 'completions.' + )) % { + 'name': employee.name, + 'role': role.name, + 'count': rec.completed_count, + }, + subtype_xmlid='mail.mt_note', + ) diff --git a/fusion_plating/fusion_plating/models/fp_work_role.py b/fusion_plating/fusion_plating/models/fp_work_role.py new file mode 100644 index 00000000..c9960f92 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_work_role.py @@ -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 diff --git a/fusion_plating/fusion_plating/models/hr_employee.py b/fusion_plating/fusion_plating/models/hr_employee.py new file mode 100644 index 00000000..bea5d80a --- /dev/null +++ b/fusion_plating/fusion_plating/models/hr_employee.py @@ -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) diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index 39042737..3d0417b9 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -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_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_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 diff --git a/fusion_plating/fusion_plating/views/fp_work_role_views.xml b/fusion_plating/fusion_plating/views/fp_work_role_views.xml new file mode 100644 index 00000000..f374d58d --- /dev/null +++ b/fusion_plating/fusion_plating/views/fp_work_role_views.xml @@ -0,0 +1,165 @@ + + + + + + fp.work.role.list + fp.work.role + + + + + + + + + + + + + + fp.work.role.form + fp.work.role + +
+ +
+
+ + + + + + + + + + + + + + + +
+ +
+
+ + + Shop Roles + fp.work.role + list,form + +

+ Define the roles on your shop floor +

+

+ 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. +

+
+
+ + + + + + hr.employee.form.fp.roles + hr.employee + + + + + + + +
+ Work orders tagged with these roles auto-assign to + this employee (or to whoever has the same role and + the lighter open queue). +
+
+ + +
+ 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. +
+
+
+ + +

+ 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 Tasks This + Operator Can Do automatically. +

+ + + + + + + + + + + +
+
+
+
+ + + + fusion.plating.process.node.form.fp.roles + fusion.plating.process.node + + + + + + + + + + +
diff --git a/fusion_plating/fusion_plating_batch/__manifest__.py b/fusion_plating/fusion_plating_batch/__manifest__.py index 71b7a06d..1cead8bc 100644 --- a/fusion_plating/fusion_plating_batch/__manifest__.py +++ b/fusion_plating/fusion_plating_batch/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Batch Processing', - 'version': '19.0.1.0.0', + 'version': '19.0.2.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Group parts into rack or barrel loads for tank processing.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_batch/migrations/19.0.2.0.0/__init__.py b/fusion_plating/fusion_plating_batch/migrations/19.0.2.0.0/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_plating/fusion_plating_batch/migrations/19.0.2.0.0/pre-migration.py b/fusion_plating/fusion_plating_batch/migrations/19.0.2.0.0/pre-migration.py new file mode 100644 index 00000000..e220b68e --- /dev/null +++ b/fusion_plating/fusion_plating_batch/migrations/19.0.2.0.0/pre-migration.py @@ -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") diff --git a/fusion_plating/fusion_plating_batch/models/fp_batch.py b/fusion_plating/fusion_plating_batch/models/fp_batch.py index 7d001130..1555ced9 100644 --- a/fusion_plating/fusion_plating_batch/models/fp_batch.py +++ b/fusion_plating/fusion_plating_batch/models/fp_batch.py @@ -73,15 +73,9 @@ class FpBatch(models.Model): domain="[('state', '!=', 'retired')]", tracking=True, ) - workorder_id = fields.Many2one( - 'mrp.workorder', string='Work Order', - help='The WO this batch ran through. Used for material traceability.', - tracking=True, - ) - production_id = fields.Many2one( - 'mrp.production', string='Manufacturing Order', - related='workorder_id.production_id', store=True, readonly=True, - ) + # Phase 6 (Sub 11) — workorder_id / production_id retired (MRP gone). + # Native equivalents: x_fc_step_id (fp.job.step) + x_fc_job_id (fp.job) + # are added by fusion_plating_jobs and carry the same traceability. part_count = fields.Integer(string='Part Count') start_time = fields.Datetime(string='Process Start', tracking=True) end_time = fields.Datetime(string='Process End', tracking=True) diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index dbd24fc5..34305a95 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Fusion Plating — MRP Bridge", - 'version': '19.0.12.2.0', + 'version': '19.0.13.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ @@ -59,33 +59,30 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. ], 'data': [ '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_qc_data.xml', 'wizard/fp_recipe_config_wizard_views.xml', 'views/mrp_workcenter_views.xml', 'views/mrp_workorder_views.xml', - 'views/fp_qc_template_views.xml', - 'views/fp_quality_check_views.xml', + # Phase 1 (Sub 11) — relocated to fusion_plating_jobs / fusion_plating_quality. + # '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/sale_order_views.xml', 'views/fp_quality_hold_views.xml', 'views/fp_batch_views.xml', - 'views/fp_workorder_priority_views.xml', - 'views/fp_job_consumption_views.xml', - 'views/fp_work_role_views.xml', - 'views/res_partner_views.xml', + # Phase 3 (Sub 11) — replaced by native fp.job.step priority kanban + # in fusion_plating_jobs/views/fp_step_priority_views.xml. + # 'views/fp_workorder_priority_views.xml', + # Phase 4 (Sub 11) — relocated to fusion_plating_quality. + # 'views/res_partner_views.xml', 'views/fp_serial_views.xml', ], 'assets': { - 'web.assets_backend': [ - # 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', - ], + # Phase 2 (Sub 11) — QC tablet OWL relocated to fusion_plating_quality. }, 'installable': True, 'application': False, diff --git a/fusion_plating/fusion_plating_bridge_mrp/controllers/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/controllers/__init__.py index a375bf01..1afa76c6 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/controllers/__init__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/controllers/__init__.py @@ -2,4 +2,5 @@ # Copyright 2026 Nexa Systems Inc. # 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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py index ee70955b..55b527a0 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py @@ -11,16 +11,35 @@ from . import fp_portal_job from . import fp_quality_hold from . import fp_delivery 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_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 sale_order -from . import fp_work_role -from . import hr_employee -from . import fp_proficiency -from . import fp_process_node -from . import fp_qc_template +# Phase 1 (Sub 11) — relocated to fusion_plating_jobs. +# from . import fp_work_role +# Phase 1 (Sub 11) — relocated to fusion_plating_jobs. +# from . import hr_employee +# 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_thickness_reading -from . import res_partner +# Phase 1 (Sub 11) — relocated to fusion_plating_quality. +# from . import fp_thickness_reading +# Phase 4 (Sub 11) — relocated to fusion_plating_quality. +# from . import res_partner from . import fp_serial diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_consumption.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_consumption.py index 5842a32a..d4c99149 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_consumption.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_consumption.py @@ -2,85 +2,24 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # 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): - """A single consumable drawdown charged to a manufacturing order. - - 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' + _inherit = 'fp.job.consumption' production_id = fields.Many2one( 'mrp.production', string='Manufacturing Order', - required=True, ondelete='cascade', + ondelete='cascade', index=True, ) workorder_id = fields.Many2one( 'mrp.workorder', string='Work Order', 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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_node_override.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_node_override.py index 855831f3..a2dec5aa 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_node_override.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_job_node_override.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import fields, models +from odoo import api, fields, models class FpJobNodeOverride(models.Model): @@ -58,6 +58,14 @@ class FpJobNodeOverride(models.Model): 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 = [ ('unique_production_node', 'unique(production_id, node_id)', diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_quality_check.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_quality_check.py index 3b9684ff..35ae01c3 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/fp_quality_check.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_quality_check.py @@ -2,621 +2,22 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # 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 -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__) +from odoo import fields, models class FpQualityCheck(models.Model): - _name = 'fusion.plating.quality.check' - _description = 'Fusion Plating — Quality Check' - _inherit = ['mail.thread', 'mail.activity.mixin'] - _order = 'create_date desc' + _inherit = 'fusion.plating.quality.check' - name = fields.Char( - string='Reference', required=True, copy=False, readonly=True, - default=lambda self: self._default_name(), tracking=True, - ) production_id = fields.Many2one( 'mrp.production', string='Manufacturing Order', - required=True, ondelete='cascade', tracking=True, - index=True, + ondelete='cascade', 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( - 'QC PASSED — 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( - 'QC FAILED — 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 1–4 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: - # 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(), - }) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py index a86371c9..adfb9d66 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -170,10 +170,12 @@ class MrpProduction(models.Model): # T3.3 — Actuals vs quoted margin # T3.4 — Consumables tied to jobs # ------------------------------------------------------------------ - x_fc_consumption_ids = fields.One2many( - 'fp.job.consumption', 'production_id', - string='Consumables Log', - ) + # Phase 1 (Sub 11) — fp.job.consumption relocated to + # fusion_plating_jobs. The MO-side O2M would create a circular + # 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( string='Consumables Cost', compute='_compute_job_costs', store=True, currency_field='x_fc_currency_id', @@ -228,7 +230,7 @@ class MrpProduction(models.Model): def _compute_consumption_count(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') def _compute_sale_order_id(self): @@ -305,7 +307,6 @@ class MrpProduction(models.Model): } @api.depends( - 'x_fc_consumption_ids.total_cost', 'workorder_ids.duration', 'workorder_ids.workcenter_id.costs_hour', 'origin', @@ -314,7 +315,8 @@ class MrpProduction(models.Model): SO = self.env['sale.order'] for mo in self: 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 for wo in mo.workorder_ids: rate = wo.workcenter_id.costs_hour or 0.0 @@ -1218,29 +1220,35 @@ class MrpProduction(models.Model): def _resolve_mo_process_tree(self): """Resolve which process-tree root to walk for this MO. - Sub 3 — prefers the linked part's cloned tree - (SO line's x_fc_part_catalog_id.default_process_id); falls back - to the legacy x_fc_recipe_id for MOs without a linked part or - without a composed part tree. + Resolution priority (Sub 9 — process variants): + 1. SO line's `x_fc_process_variant_id` (per-order variant pick) + 2. Linked part's `default_process_id` (the part's default variant) + 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() - # Resolve part via SO lines (MO's origin → sale.order → first - # line's part). mrp.production has no direct part link; the - # relationship lives on sale.order.line. - part = False - if self.origin: + line = False + if 'x_fc_sale_order_line_ids' in self._fields and self.x_fc_sale_order_line_ids: + line = self.x_fc_sale_order_line_ids[0] + elif self.origin: so = self.env['sale.order'].search( [('name', '=', self.origin)], limit=1, ) if so and so.order_line: - first_line = so.order_line[0] - if 'x_fc_part_catalog_id' in first_line._fields: - part = first_line.x_fc_part_catalog_id - if part and part.default_process_id: - return part.default_process_id - # Fallback — legacy recipe lookup (coating config / product match) + line = so.order_line[0] + + if line: + if ('x_fc_process_variant_id' in line._fields + and line.x_fc_process_variant_id): + 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 # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py index 67adc555..c256ff5e 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py @@ -159,11 +159,10 @@ class MrpWorkorder(models.Model): 'manager; the Tablet Station shows only WOs assigned to the ' 'logged-in user.', ) - x_fc_work_role_id = fields.Many2one( - 'fp.work.role', string='Role', - help='Shop role required to perform this step (copied from the ' - 'recipe operation on WO generation).', - ) + # Phase 1 (Sub 11) — fp.work.role relocated to fusion_plating_jobs. + # bridge_mrp can't depend on jobs (cycle through notifications → + # bridge_mrp), so the legacy WO field is gone. mrp.workorder has 0 + # rows in native mode, so nothing breaks. # ------------------------------------------------------------------ # Timer audit — surface the who / when of the timer on the WO header. diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py index 169a0a41..a9290754 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py @@ -45,17 +45,17 @@ class SaleOrder(models.Model): # ------------------------------------------------------------------ x_fc_workflow_stage = fields.Selection( [ - ('draft', 'Quotation — awaiting confirmation'), - ('awaiting_parts', 'Parts en route'), - ('inspecting', 'Inspecting received parts'), - ('accept_parts', 'Ready to accept parts'), - ('assign_work', 'Ready to assign manager'), - ('in_production', 'In production'), - ('ready_to_ship', 'Production complete — ready to ship'), - ('shipped', 'Shipped — awaiting invoice'), - ('invoicing', 'Awaiting invoice / payment'), + ('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', 'Complete'), + ('complete', 'Done'), ('cancelled', 'Cancelled'), ], compute='_compute_workflow_stage', @@ -199,13 +199,30 @@ class SaleOrder(models.Model): ) % (tag or 'single-line')) 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 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 + if ('x_fc_process_variant_id' in ln._fields + and ln.x_fc_process_variant_id): + recipe = ln.x_fc_process_variant_id 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: recipe = self.env['fusion.plating.process.node'].search( [('node_type', '=', 'recipe')], limit=1, diff --git a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv index b8cf7449..40eb8d20 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv @@ -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_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_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_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_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_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 -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 +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_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_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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml index d4b9e77a..9b68dc6a 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml +++ b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml @@ -95,7 +95,6 @@ string="Assigned To" required="1" options="{'no_create': True}"/> - sale.order - + @@ -53,12 +54,20 @@ + + + 1 + + - + - - -
- -
-

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.

- +
+

Process Variants

+

+ Add as many variants as you need (e.g. "Standard", "Selective Masking", "Rework"). + One variant is the default; order lines may pick another at entry time. +

+ +
+ +

No variants yet. Pick a template below and add the first one.

-
- -

No process composed yet.

-

- Pick a template above and click Load to get started. -

-
+ + + + + + + + + + + + + + + + + + + +
DefaultLabelRecipe NameNodesActions
+ + Default + + + + + + + + + + + + + +
+ +
+

Add Variant from Template

+
+ + + + +
+

+ Leave the label blank to use the template name. The first variant added becomes the default automatically. +

+
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml b/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml index 9339e6d0..3e9b7675 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml @@ -35,6 +35,12 @@ action="action_fp_direct_order_wizard" sequence="5"/> + + + help="The variant used by default when an order line does not pick another."/> +

- The Compose button opens the Process Composer where you can - load a shared template and customise it for this part. When a job runs for - this part, work orders are generated from the composed tree. + The Compose button opens the Process Composer where you can add + multiple process variants for this part — for example "Standard ENP", + "Selective Masking", "Rework". One variant is flagged as default; estimators + may pick a different variant on a per-order basis.

+ + + + + + + + diff --git a/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml index 5dda6fcb..53e2d4b9 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml @@ -13,12 +13,12 @@
-
+
+ +
-

New Direct Order

-

- Skip the quotation stage - create a confirmed order - when the customer has already sent a PO. +

+ +

+ Skip the quotation stage — create a confirmed order + when the customer has already sent a PO. Drafts auto-save.

@@ -70,6 +102,8 @@ +