changes
This commit is contained in:
@@ -374,8 +374,13 @@ rewrite code as new requirements surface. Each sub-project has its own design do
|
|||||||
| 6 | Contact Profiles & Communication Routing (per-contact flags + per-location routing + global contact; single resolver helper) | **Shipped 2026-04-22** | client transcript A/B/C |
|
| 6 | Contact Profiles & Communication Routing (per-contact flags + per-location routing + global contact; single resolver helper) | **Shipped 2026-04-22** | client transcript A/B/C |
|
||||||
| 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D |
|
| 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D |
|
||||||
| 8 | Receiving / Inspection / QC flow restructure (fp.receiving = box count only; new fp.racking.inspection per MO; WO soft gate; delivery box-parity warning) | **Shipped 2026-04-22** | client transcript E |
|
| 8 | Receiving / Inspection / QC flow restructure (fp.receiving = box count only; new fp.racking.inspection per MO; WO soft gate; delivery box-parity warning) | **Shipped 2026-04-22** | client transcript E |
|
||||||
|
| 9 | Process variants per part + persistent draft order wizard + tax per line + payment terms wired + chatter + nicer breadcrumbs across plating models | **Shipped 2026-04-26** | various wizard/UX |
|
||||||
|
| 10 | Quote → Direct Order promotion (won quotes consolidate onto a single PO instead of spawning standalone 1-line SOs) | **Shipped 2026-04-26** | redundancy concern |
|
||||||
|
| 11 | **MRP cutout — bridge_mrp deletion + MRP module uninstall** (7-phase migration: relocate models, swap inherits, drop legacy FK columns, uninstall mrp + 10 cascade modules) | **Shipped 2026-04-26** | bridge_mrp removal |
|
||||||
|
| 12 | **Native Quality — full Odoo `quality_control` replacement + RMA + integration polish** | **In flight** (planned) | quality dependency removal |
|
||||||
| ∞ | First-off / last-off QC | Deferred | client transcript F |
|
| ∞ | First-off / last-off QC | Deferred | client transcript F |
|
||||||
| ∞ | VEC machine auto-ingest (Word-format thickness report from network-connected XRF; different machine from Fischerscope) | Deferred | client transcript G |
|
| ∞ | VEC machine auto-ingest (Word-format thickness report from network-connected XRF; different machine from Fischerscope) | Deferred | client transcript G |
|
||||||
|
| ∞ | RMA customer portal submission | Deferred (Sub 12 phase 2) | follow-on to Sub 12 |
|
||||||
|
|
||||||
### Sub 2 Locked Decisions (2026-04-21)
|
### Sub 2 Locked Decisions (2026-04-21)
|
||||||
|
|
||||||
@@ -435,3 +440,274 @@ rewrite code as new requirements surface. Each sub-project has its own design do
|
|||||||
3. Read the corresponding spec in `docs/superpowers/specs/YYYY-MM-DD-sub<N>-*-design.md`.
|
3. Read the corresponding spec in `docs/superpowers/specs/YYYY-MM-DD-sub<N>-*-design.md`.
|
||||||
4. Read the implementation plan if one exists.
|
4. Read the implementation plan if one exists.
|
||||||
5. Continue from the next un-checked step.
|
5. Continue from the next un-checked step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub 11 — MRP Cutout (shipped 2026-04-26)
|
||||||
|
|
||||||
|
The Odoo `mrp` module + 10 cascade dependents have been **uninstalled**. `fusion_plating_bridge_mrp` is gone. The plating shop runs entirely on `fp.job` / `fp.job.step`. Document this so a fresh session doesn't try to re-add MRP refs.
|
||||||
|
|
||||||
|
### Final state
|
||||||
|
- **0 rows** in `mrp_production`, `mrp_workorder`, `mrp_workcenter`
|
||||||
|
- **205+** `fp.job` rows, **1,800+** `fp.job.step` rows in production
|
||||||
|
- 0 custom-table FKs to MRP
|
||||||
|
- Modules uninstalled: `mrp`, `mrp_workorder`, `mrp_account`, `sale_mrp`, `purchase_mrp`, `quality_mrp`, `quality_mrp_workorder`, `project_mrp*`, `fusion_manufacturing`, `fusion_plating_bridge_mrp`
|
||||||
|
|
||||||
|
### Where things ended up after Sub 11
|
||||||
|
|
||||||
|
| Model / asset | Old home | New home |
|
||||||
|
|---|---|---|
|
||||||
|
| `fp.work.role`, `fp.operator.proficiency`, `hr.employee` shop-roles, `fusion.plating.process.node.x_fc_work_role_id` | `fusion_plating_bridge_mrp` | `fusion_plating` (core) |
|
||||||
|
| `fp.qc.checklist.template` (+line) | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||||
|
| `fusion.plating.quality.check` (+line) | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||||
|
| `fp.thickness.reading.quality_check_id` link + `auto_extracted` | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||||
|
| `res.partner.x_fc_requires_qc` + `x_fc_qc_template_id` | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||||
|
| `fp.job.consumption` | `fusion_plating_bridge_mrp` | `fusion_plating_jobs` |
|
||||||
|
| `sale.order.x_fc_workflow_stage` + `x_fc_assigned_manager_id` + workflow buttons | `fusion_plating_bridge_mrp` | `fusion_plating_jobs` |
|
||||||
|
| QC tablet OWL (`fp_qc_checklist.js/.xml/.scss`) + `/fp/qc/*` controller | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||||
|
| Production Priorities kanban | `fusion_plating_bridge_mrp` (mrp.workorder) | `fusion_plating_jobs` (fp.job.step) |
|
||||||
|
|
||||||
|
### Hard rules going forward
|
||||||
|
1. **Never re-introduce `'mrp'` as a manifest dep.** Use `fp.job` for jobs, `fp.job.step` for operations.
|
||||||
|
2. **`x_fc_job_id` is the canonical job link**, not `production_id`. Drop legacy MO refs as you find them.
|
||||||
|
3. **`fusion_plating_quality` depends on `fusion_plating_shopfloor`** for SCSS tokens (`$fp-page`, `$fp-card`, `$fp-accent`). Don't strip that dep — the QC tablet bundle breaks without it.
|
||||||
|
4. **The QC tablet OWL template namespace is `fusion_plating_quality.FpQcChecklist`** (was `fusion_plating_bridge_mrp.FpQcChecklist`). Don't rename back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub 12 — Native Quality Module (in flight, ~4 working days)
|
||||||
|
|
||||||
|
**Goal**: Build a complete native quality stack matching Odoo `quality_control` functionality plus plating-specific extensions (RMA, CAPA effectiveness, holds, 8D reports), with **zero dependency** on Odoo's `quality` / `quality_control`. After Sub 12 lands, those modules + `fusion_plating_bridge_quality` get uninstalled.
|
||||||
|
|
||||||
|
### Module choice
|
||||||
|
**Enrich `fusion_plating_quality`** — no new modules. Existing module already owns NCR / CAPA / Hold / Check / Calibration / AVL / FAIR / Audit / Doc Control / Customer Spec / Contract Review.
|
||||||
|
|
||||||
|
### Locked decisions (don't re-ask in fresh session)
|
||||||
|
| Q | Decision |
|
||||||
|
|---|---|
|
||||||
|
| RMA portal submission | **Deferred to phase 2.** Internal-only RMA in Sub 12. |
|
||||||
|
| 8D format | **Full 8D** (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/<id>` for the authorisation PDF |
|
||||||
|
| `auto_spawn_ncr` | Boolean | Default True. Manager can toggle off before saving. |
|
||||||
|
| `auto_spawn_hold` | Boolean | Default True. |
|
||||||
|
| `tag_ids` | M2M `fp.quality.tag` | (Sub 12 Phase B) |
|
||||||
|
| `reason_id` | M2O `fp.quality.reason` | (Sub 12 Phase B) |
|
||||||
|
| `team_id` | M2O `fp.quality.team` | (Sub 12 Phase B) |
|
||||||
|
| `chatter` | mail.thread | mandatory |
|
||||||
|
|
||||||
|
#### Lifecycle hooks
|
||||||
|
- **`action_authorise`**: state `draft → authorised`. Generate the RMA authorisation PDF + email link/QR to customer (using `fp.notification.template` if installed; falls back to standard mail.template).
|
||||||
|
- **`action_mark_shipped_to_us`**: customer-driven; updates state when carrier scan logged.
|
||||||
|
- **On `fp.receiving` create with `rma_id` set**: state `→ received`. If `auto_spawn_ncr`, create an `fusion.plating.ncr` pre-filled (description, severity, customer, parent SO line). If `auto_spawn_hold`, create `fusion.plating.quality.hold` for the returned qty.
|
||||||
|
- **`action_triage_complete`**: state `→ triaged`. Requires `resolution_type` set.
|
||||||
|
- **`action_resolve`**: state `→ resolved`. Triggers resolution-specific actions:
|
||||||
|
- `replace` → spawn new `fp.job` cloned from original
|
||||||
|
- `rework` → spawn new `fp.job` referencing the returned units (linked to inbound `fp.receiving`)
|
||||||
|
- `refund` → open `account.move.refund` wizard, link result to `refund_invoice_id`
|
||||||
|
- `scrap` → create `fp.job.consumption` row tagged 'rma_scrap' + post chatter
|
||||||
|
- **`action_close`**: state `→ closed`. Locks editing.
|
||||||
|
- **`action_cancel`**: any state → `cancelled` (manager only).
|
||||||
|
|
||||||
|
#### Smart buttons
|
||||||
|
RMA form gets buttons to: original SO, original Jobs, inbound Receiving, replacement Job, refund Invoice, NCRs (count), CAPAs (count), Holds (count). Per-target button visibility based on resolution_type / state.
|
||||||
|
|
||||||
|
#### Sequence
|
||||||
|
Create `ir.sequence` `fp.rma` with prefix `RMA/%(year)s/`, padding 4. Data file `fp_rma_sequence.xml`.
|
||||||
|
|
||||||
|
#### Reports
|
||||||
|
`fusion_plating_reports/report/report_fp_rma_authorisation.xml` — single-page customer-facing PDF with QR code. Branded "EN Technologies".
|
||||||
|
|
||||||
|
### Phase B — Categorisation & kanban infra (~half day)
|
||||||
|
**Files**: `fusion_plating_quality/models/fp_quality_tag.py`, `fp_quality_reason.py`, `fp_quality_team.py`, `fp_quality_alert_stage.py`
|
||||||
|
|
||||||
|
#### `fp.quality.tag`
|
||||||
|
- `name` (Char, required, translate)
|
||||||
|
- `color` (Integer, kanban color)
|
||||||
|
- `active` (Boolean)
|
||||||
|
- Reused by NCR / CAPA / Hold / RMA / Check via `M2M tag_ids`
|
||||||
|
|
||||||
|
#### `fp.quality.reason`
|
||||||
|
- `name`, `description`, `category` (selection: `process / supplier / equipment / human / material / other`)
|
||||||
|
- Curated reason library so root-cause classification is consistent
|
||||||
|
|
||||||
|
#### `fp.quality.team`
|
||||||
|
- `name`, `lead_user_id` (M2O res.users), `member_ids` (M2M res.users)
|
||||||
|
- `escalation_user_id` (manager who gets notified on missed deadlines)
|
||||||
|
- Used by NCR / RMA — primary owner team
|
||||||
|
|
||||||
|
#### `fp.quality.alert.stage`
|
||||||
|
- `name`, `sequence`, `fold` (Boolean — collapsed-by-default in kanban)
|
||||||
|
- Default stages seeded: New / Investigating / Containment / Disposition / Awaiting Sign-off / Closed / Cancelled
|
||||||
|
- Add `stage_id = fields.Many2one('fp.quality.alert.stage')` to `fusion.plating.ncr` AND `fusion.plating.rma`. Map state ↔ stage_id via `_inverse_*` so legacy code paths keep working.
|
||||||
|
|
||||||
|
#### Apply tag/reason/team M2M/M2O fields to: NCR, CAPA, Hold, Check, RMA
|
||||||
|
Each model gets `tag_ids`, `reason_id`, `team_id`. NCR + RMA additionally get `stage_id`.
|
||||||
|
|
||||||
|
### Phase C — Trigger-based quality points (~half day)
|
||||||
|
**File**: `fusion_plating_quality/models/fp_quality_point.py`
|
||||||
|
|
||||||
|
#### `fp.quality.point`
|
||||||
|
- `name`, `active`, `description`
|
||||||
|
- `trigger_type` (Selection): `manual / receiving_done / job_confirmed / job_step_done / job_done / so_confirmed`
|
||||||
|
- Filters (any combination): `partner_ids` (M2M), `part_catalog_ids` (M2M), `coating_config_ids` (M2M), `step_kind` (Selection — wet/bake/inspect/etc.)
|
||||||
|
- `template_id` (M2O `fp.qc.checklist.template`) — required, the checks to spawn
|
||||||
|
- `assignee_user_id` (M2O `res.users`) — optional default inspector
|
||||||
|
- Fires `_spawn_check_for(<source_record>)` which creates a `fusion.plating.quality.check` from the template + binds it to the source via `job_id` or `step_id`.
|
||||||
|
|
||||||
|
#### Hooks (already partly in place — extend)
|
||||||
|
- `fp.receiving.write` hook (existing): when state flips to `closed`, walk all `fp.quality.point` with trigger `receiving_done` matching the receiving's partner/parts → spawn checks.
|
||||||
|
- `fp.job.action_confirm` hook (existing — currently calls `_fp_create_qc_check_if_needed`): replace with quality.point lookup. Keep the existing partner-template fallback as a default point seeded by `fp_qc_data.xml`.
|
||||||
|
- `fp.job.button_mark_done`: trigger `job_done` points.
|
||||||
|
- `fp.job.step.button_finish`: trigger `job_step_done` points.
|
||||||
|
|
||||||
|
### Phase D — Integration polish (~1 day)
|
||||||
|
1. **`fp.job` form smart-button row**: add `Holds`, `Checks`, `NCRs`, `CAPAs`, `RMAs` buttons with badge counts. Always-visible (zero is OK).
|
||||||
|
2. **`sale.order` form smart-button row**: same five, rolled up across all linked jobs.
|
||||||
|
3. **`res.partner` form**: customer-level "Quality History" smart button that opens a kanban filtered to that partner across all 5 record types.
|
||||||
|
4. **One-click cross-creation**:
|
||||||
|
- Hold form → `Open NCR` button — pre-fills NCR with hold's part / customer / quantity / linked job.
|
||||||
|
- NCR form → `Spawn CAPA` button — visible when state ∈ {disposition, closed} and severity ≥ medium.
|
||||||
|
- CAPA form → `Verify Effectiveness` button — schedules a follow-up check on the originating NCR.
|
||||||
|
5. **Unified Quality Dashboard** (`fp_quality_dashboard` client action):
|
||||||
|
- 5 tabs: Holds / Checks / NCRs / CAPAs / RMAs
|
||||||
|
- Each tab is a kanban grouped by `stage_id` (NCRs/RMAs) or `state` (Holds/Checks/CAPAs)
|
||||||
|
- Header summary card: open count + overdue count across all 5 types
|
||||||
|
- Filters: my team / my customer / overdue / high-severity
|
||||||
|
- Menu: Plating → Quality → Dashboard
|
||||||
|
6. **CAPA closure-loop linkage**: when CAPA effectiveness verification fails, auto-spawn a new NCR linked back to the original. Closes the loop "we said we fixed it but it happened again."
|
||||||
|
|
||||||
|
### Phase E — Reports (~half day)
|
||||||
|
**Files**: `fusion_plating_reports/report/report_fp_rma_authorisation.xml`, `report_fp_8d.xml`, `report_fp_quality_monthly.xml`
|
||||||
|
|
||||||
|
1. **RMA Authorisation PDF**: single-page customer-facing. Header with our logo + customer info, RMA number, parts listed (table), return-to address, QR code linking to `/fp/rma/<id>` for status tracking, carrier instructions.
|
||||||
|
2. **8D Report (NCR + CAPA combined)**:
|
||||||
|
- D1: Team (from `team_id` + member_ids)
|
||||||
|
- D2: Problem description (NCR description + scope)
|
||||||
|
- D3: Containment (NCR containment narrative)
|
||||||
|
- D4: Root cause analysis (CAPA root_cause + reason_id)
|
||||||
|
- D5: Permanent corrective action (CAPA action_plan)
|
||||||
|
- D6: Implement & verify (CAPA implementation_date + verification_evidence)
|
||||||
|
- D7: Prevent recurrence (CAPA preventive_actions)
|
||||||
|
- D8: Congratulate the team (CAPA closure notes + team sign-offs)
|
||||||
|
- Auto-renders when both NCR and CAPA exist; degraded mode if CAPA missing.
|
||||||
|
3. **Monthly Quality Summary** (`fp_quality_monthly` report):
|
||||||
|
- Counts by record type / severity / customer / month
|
||||||
|
- Overdue ageing buckets
|
||||||
|
- CAPA effectiveness rate (verified / total closed)
|
||||||
|
- Repeat-customer-issue flag (>2 NCRs same customer in 90 days)
|
||||||
|
- Run via cron monthly + on-demand from dashboard.
|
||||||
|
|
||||||
|
### Phase F — Test + verify (~half day)
|
||||||
|
End-to-end smoke flow on a fresh DB:
|
||||||
|
1. Customer reports issue → create RMA → authorise → email PDF
|
||||||
|
2. Customer ships → carrier delivers → `fp.receiving` auto-created → RMA receives → NCR + Hold auto-spawn
|
||||||
|
3. QA triages NCR → finds root cause → spawns CAPA (auto via severity rule)
|
||||||
|
4. CAPA assigned to engineering → action plan written → implemented → effectiveness check scheduled
|
||||||
|
5. Effectiveness verified → CAPA closes → NCR closes → RMA resolves (rework path) → replacement job created from original → ships → CoC issued → invoice
|
||||||
|
6. Run 8D report on the closed NCR/CAPA pair
|
||||||
|
7. Verify dashboard counts update at every state transition
|
||||||
|
8. Confirm legacy NCR/CAPA/Hold/Check forms still work (no regressions)
|
||||||
|
9. ACL drilldown: operator sees what they should, supervisor more, manager all
|
||||||
|
|
||||||
|
### Phase G — Drop Odoo quality cascade (~30 min)
|
||||||
|
Pre-conditions: Phases 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;
|
||||||
|
```
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.8.7.1',
|
'version': '19.0.9.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -100,6 +100,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/fp_job_views.xml',
|
'views/fp_job_views.xml',
|
||||||
'views/fp_job_step_views.xml',
|
'views/fp_job_step_views.xml',
|
||||||
'views/fp_jobs_menu.xml',
|
'views/fp_jobs_menu.xml',
|
||||||
|
'data/fp_work_role_data.xml',
|
||||||
|
'views/fp_work_role_views.xml',
|
||||||
'data/fp_recipe_enp_alum_basic.xml',
|
'data/fp_recipe_enp_alum_basic.xml',
|
||||||
'data/fp_recipe_enp_steel_basic.xml',
|
'data/fp_recipe_enp_steel_basic.xml',
|
||||||
'data/fp_recipe_enp_sp.xml',
|
'data/fp_recipe_enp_sp.xml',
|
||||||
|
|||||||
76
fusion_plating/fusion_plating/data/fp_work_role_data.xml
Normal file
76
fusion_plating/fusion_plating/data/fp_work_role_data.xml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Default shop roles. noupdate="1" so shops can rename/prune freely
|
||||||
|
without upgrades clobbering their changes.
|
||||||
|
-->
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="work_role_masking" model="fp.work.role">
|
||||||
|
<field name="name">Masking</field>
|
||||||
|
<field name="code">masking</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="icon">fa-scissors</field>
|
||||||
|
<field name="description">Applies masking tape/lacquer before plating and removes after.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="work_role_racking" model="fp.work.role">
|
||||||
|
<field name="name">Racking</field>
|
||||||
|
<field name="code">racking</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="icon">fa-cogs</field>
|
||||||
|
<field name="description">Fixtures parts onto racks/barrels for processing.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="work_role_plating" model="fp.work.role">
|
||||||
|
<field name="name">Plating Operator</field>
|
||||||
|
<field name="code">plating_op</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="icon">fa-flask</field>
|
||||||
|
<field name="description">Runs the plating line — chemistry checks, dwell, thickness.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="work_role_demask" model="fp.work.role">
|
||||||
|
<field name="name">De-Mask</field>
|
||||||
|
<field name="code">demask</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="icon">fa-scissors</field>
|
||||||
|
<field name="description">Removes masking material after plating.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="work_role_oven" model="fp.work.role">
|
||||||
|
<field name="name">Oven / Bake</field>
|
||||||
|
<field name="code">oven</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="icon">fa-fire</field>
|
||||||
|
<field name="description">Loads and operates embrittlement-relief ovens.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="work_role_derack" model="fp.work.role">
|
||||||
|
<field name="name">De-Rack</field>
|
||||||
|
<field name="code">derack</field>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="icon">fa-cogs</field>
|
||||||
|
<field name="description">Removes parts from racks/barrels for inspection.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="work_role_inspection" model="fp.work.role">
|
||||||
|
<field name="name">Inspection / QA</field>
|
||||||
|
<field name="code">inspection</field>
|
||||||
|
<field name="sequence">70</field>
|
||||||
|
<field name="icon">fa-search</field>
|
||||||
|
<field name="description">Post-plate inspection, Fischerscope, first-piece sign-off.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="work_role_rework" model="fp.work.role">
|
||||||
|
<field name="name">Rework</field>
|
||||||
|
<field name="code">rework</field>
|
||||||
|
<field name="sequence">80</field>
|
||||||
|
<field name="icon">fa-wrench</field>
|
||||||
|
<field name="description">Strips bad plating; routes parts back for re-processing.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -23,3 +23,12 @@ from . import fp_operator_certification
|
|||||||
from . import fp_tz
|
from . import fp_tz
|
||||||
from . import res_company
|
from . import res_company
|
||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
|
|
||||||
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp via
|
||||||
|
# fusion_plating_jobs to core, so other downstream modules
|
||||||
|
# (fusion_plating_cgp, etc.) that touch hr.employee can see the
|
||||||
|
# shop-roles fields without a transitive dep on jobs.
|
||||||
|
from . import fp_work_role
|
||||||
|
from . import fp_proficiency
|
||||||
|
from . import hr_employee
|
||||||
|
from . import fp_process_node_inherit
|
||||||
|
|||||||
@@ -40,3 +40,15 @@ class FpJobStepTimeLog(models.Model):
|
|||||||
log.duration_minutes = delta.total_seconds() / 60.0
|
log.duration_minutes = delta.total_seconds() / 60.0
|
||||||
else:
|
else:
|
||||||
log.duration_minutes = 0.0
|
log.duration_minutes = 0.0
|
||||||
|
|
||||||
|
@api.depends('user_id', 'date_started', 'duration_minutes')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for log in self:
|
||||||
|
user = log.user_id.name or 'User'
|
||||||
|
when = log.date_started.strftime('%Y-%m-%d %H:%M') if log.date_started else ''
|
||||||
|
mins = ('%.0f min' % log.duration_minutes) if log.duration_minutes else 'open'
|
||||||
|
rec_bits = [user]
|
||||||
|
if when:
|
||||||
|
rec_bits.append(when)
|
||||||
|
rec_bits.append(mins)
|
||||||
|
log.display_name = ' · '.join(rec_bits)
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
)
|
||||||
163
fusion_plating/fusion_plating/models/fp_proficiency.py
Normal file
163
fusion_plating/fusion_plating/models/fp_proficiency.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp. The model
|
||||||
|
# never had MRP fields; the bridge module was just its initial home.
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpOperatorProficiency(models.Model):
|
||||||
|
"""Operator proficiency tracker — counts successful step completions
|
||||||
|
per (employee, role) pair and auto-promotes the employee once the
|
||||||
|
role's mastery threshold is crossed.
|
||||||
|
|
||||||
|
The promotion mechanic lets managers casually train workers on the job:
|
||||||
|
they assign someone a task they've never done, the worker finishes it
|
||||||
|
successfully, and after N successes the role is added to the
|
||||||
|
employee's Shop Roles automatically. The operator never has to fill
|
||||||
|
in a form; their growing skill set just unlocks itself.
|
||||||
|
"""
|
||||||
|
_name = 'fp.operator.proficiency'
|
||||||
|
_description = 'Fusion Plating — Operator Task Proficiency'
|
||||||
|
_rec_name = 'display_name'
|
||||||
|
_order = 'employee_id, role_id'
|
||||||
|
|
||||||
|
employee_id = fields.Many2one(
|
||||||
|
'hr.employee', string='Operator',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
role_id = fields.Many2one(
|
||||||
|
'fp.work.role', string='Role',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
completed_count = fields.Integer(
|
||||||
|
string='Completions',
|
||||||
|
default=0,
|
||||||
|
help='Number of times this operator has successfully finished a '
|
||||||
|
'step that required this role.',
|
||||||
|
)
|
||||||
|
first_completed_at = fields.Datetime(
|
||||||
|
string='First Success',
|
||||||
|
help='When the operator finished their first step for this role.',
|
||||||
|
)
|
||||||
|
last_completed_at = fields.Datetime(
|
||||||
|
string='Last Success',
|
||||||
|
help='Most recent step completion against this role.',
|
||||||
|
)
|
||||||
|
promoted = fields.Boolean(
|
||||||
|
string='Promoted',
|
||||||
|
default=False,
|
||||||
|
index=True,
|
||||||
|
help='True once the role has been added to the operator\'s Shop '
|
||||||
|
'Roles automatically. Stays True even if a manager removes '
|
||||||
|
'the role afterwards — the count and promotion history are '
|
||||||
|
'preserved as a training record.',
|
||||||
|
)
|
||||||
|
promoted_at = fields.Datetime(
|
||||||
|
string='Promoted On',
|
||||||
|
help='When the auto-promotion fired (count crossed the role\'s '
|
||||||
|
'mastery threshold).',
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = fields.Char(
|
||||||
|
compute='_compute_display_name', store=True,
|
||||||
|
)
|
||||||
|
progress_label = fields.Char(
|
||||||
|
compute='_compute_progress_label',
|
||||||
|
help='"3 / 5" style indicator of how close this operator is to '
|
||||||
|
'mastery.',
|
||||||
|
)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('fp_proficiency_uniq',
|
||||||
|
'unique(employee_id, role_id)',
|
||||||
|
'There is already a proficiency record for this operator and role.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('employee_id.name', 'role_id.name')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.display_name = (
|
||||||
|
f'{rec.employee_id.name or "?"} — {rec.role_id.name or "?"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('completed_count', 'role_id.mastery_required')
|
||||||
|
def _compute_progress_label(self):
|
||||||
|
for rec in self:
|
||||||
|
target = rec.role_id.mastery_required or 0
|
||||||
|
rec.progress_label = (
|
||||||
|
f'{rec.completed_count} / {target}' if target
|
||||||
|
else str(rec.completed_count)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _record_completion(self, employee, role):
|
||||||
|
"""Increment the (employee, role) tally and promote if at threshold.
|
||||||
|
|
||||||
|
Idempotent for the (employee, role) pair — if no record exists,
|
||||||
|
we create one. Always uses sudo() because the worker may not
|
||||||
|
have write access to their own profile.
|
||||||
|
"""
|
||||||
|
if not employee or not role:
|
||||||
|
return self.browse()
|
||||||
|
|
||||||
|
rec = self.sudo().search([
|
||||||
|
('employee_id', '=', employee.id),
|
||||||
|
('role_id', '=', role.id),
|
||||||
|
], limit=1)
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
if rec:
|
||||||
|
new_count = rec.completed_count + 1
|
||||||
|
rec.write({
|
||||||
|
'completed_count': new_count,
|
||||||
|
'last_completed_at': now,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
rec = self.sudo().create({
|
||||||
|
'employee_id': employee.id,
|
||||||
|
'role_id': role.id,
|
||||||
|
'completed_count': 1,
|
||||||
|
'first_completed_at': now,
|
||||||
|
'last_completed_at': now,
|
||||||
|
})
|
||||||
|
rec._maybe_promote()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
def _maybe_promote(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.promoted:
|
||||||
|
continue
|
||||||
|
target = rec.role_id.mastery_required or 0
|
||||||
|
if target <= 0:
|
||||||
|
continue
|
||||||
|
if rec.completed_count < target:
|
||||||
|
continue
|
||||||
|
employee = rec.employee_id
|
||||||
|
role = rec.role_id
|
||||||
|
already_assigned = role in employee.x_fc_work_role_ids
|
||||||
|
rec.sudo().write({
|
||||||
|
'promoted': True,
|
||||||
|
'promoted_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
if already_assigned:
|
||||||
|
continue
|
||||||
|
employee.sudo().write({
|
||||||
|
'x_fc_work_role_ids': [(4, role.id)],
|
||||||
|
})
|
||||||
|
employee.message_post(
|
||||||
|
body=Markup(_(
|
||||||
|
'<b>%(name)s promoted</b> — qualified for '
|
||||||
|
'<b>%(role)s</b> after %(count)s successful '
|
||||||
|
'completions.'
|
||||||
|
)) % {
|
||||||
|
'name': employee.name,
|
||||||
|
'role': role.name,
|
||||||
|
'count': rec.completed_count,
|
||||||
|
},
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
62
fusion_plating/fusion_plating/models/fp_work_role.py
Normal file
62
fusion_plating/fusion_plating/models/fp_work_role.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp. The model
|
||||||
|
# never had MRP fields; the bridge module was just its initial home.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpWorkRole(models.Model):
|
||||||
|
"""A shop role assigned to a recipe step and to the employees who
|
||||||
|
can perform it.
|
||||||
|
|
||||||
|
Shops run the same part with different staffing models:
|
||||||
|
- One employee does every step (small shop): give them every role.
|
||||||
|
- Specialists per operation (masking person, racker, plater): one
|
||||||
|
role each.
|
||||||
|
- Cross-trained workers: multiple roles per worker.
|
||||||
|
|
||||||
|
The model is intentionally flat — no hierarchy, no workflow. Roles
|
||||||
|
are just tags that the step auto-assignment compares.
|
||||||
|
"""
|
||||||
|
_name = 'fp.work.role'
|
||||||
|
_description = 'Fusion Plating — Shop Work Role'
|
||||||
|
_order = 'sequence, code'
|
||||||
|
|
||||||
|
name = fields.Char(string='Role Name', required=True, translate=True)
|
||||||
|
code = fields.Char(string='Code', required=True,
|
||||||
|
help='Short stable identifier used in auto-assignment.')
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
description = fields.Char(
|
||||||
|
string='Description',
|
||||||
|
help='Short operator-facing description of what this role covers.',
|
||||||
|
)
|
||||||
|
icon = fields.Selection(
|
||||||
|
[('fa-scissors', 'Scissors (masking)'),
|
||||||
|
('fa-cogs', 'Cogs (racking)'),
|
||||||
|
('fa-flask', 'Flask (plating)'),
|
||||||
|
('fa-fire', 'Fire (oven)'),
|
||||||
|
('fa-search', 'Inspection'),
|
||||||
|
('fa-wrench', 'Wrench (rework)'),
|
||||||
|
('fa-user', 'Generic worker')],
|
||||||
|
string='Icon', default='fa-user',
|
||||||
|
)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
mastery_required = fields.Integer(
|
||||||
|
string='Mastery Threshold',
|
||||||
|
default=lambda self: self._default_mastery_required(),
|
||||||
|
help='Number of successful step completions a worker needs on this '
|
||||||
|
"role before they're added to its qualified-operators list "
|
||||||
|
'automatically. 1 = promote on first success; 3 = solid '
|
||||||
|
"default for everyday roles; 5+ for tasks that need real "
|
||||||
|
'practice. Defaults from Settings > Fusion Plating > '
|
||||||
|
'Default Mastery Threshold.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _default_mastery_required(self):
|
||||||
|
return self.env.company.x_fc_default_mastery_threshold or 3
|
||||||
161
fusion_plating/fusion_plating/models/hr_employee.py
Normal file
161
fusion_plating/fusion_plating/models/hr_employee.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class HrEmployee(models.Model):
|
||||||
|
"""Tag employees with the shop roles they can perform.
|
||||||
|
|
||||||
|
An employee with role 'masking' receives the masking steps when WOs
|
||||||
|
are generated; an employee with multiple roles receives WOs for all
|
||||||
|
of them. A small shop where the owner wears every hat just tags
|
||||||
|
themselves with every role.
|
||||||
|
|
||||||
|
Lead hands are a separate per-role list — they don't have to be
|
||||||
|
primary owners of those roles, but they're authorised to step in
|
||||||
|
when the regular owner is absent or behind. The Manager Desk
|
||||||
|
promotes lead hands above other workers in its dropdown for any
|
||||||
|
role they cover.
|
||||||
|
"""
|
||||||
|
_inherit = 'hr.employee'
|
||||||
|
|
||||||
|
x_fc_work_role_ids = fields.Many2many(
|
||||||
|
'fp.work.role', 'fp_employee_work_role_rel',
|
||||||
|
'employee_id', 'role_id', string='Shop Roles',
|
||||||
|
help='Which shop roles this employee performs. Used by the '
|
||||||
|
'Manager Desk and auto-assignment on WO generation. '
|
||||||
|
'Roles are added automatically when an employee completes '
|
||||||
|
'a task that meets the role mastery threshold.',
|
||||||
|
)
|
||||||
|
# Per-role lead-hand list. Sarah might be a lead hand for masking +
|
||||||
|
# racking but not for plating; Mike might cover everything during
|
||||||
|
# a graveyard shift. Stored on a separate relation table so the
|
||||||
|
# primary "Shop Roles" list stays distinct from the cover-anything
|
||||||
|
# authority.
|
||||||
|
x_fc_lead_hand_role_ids = fields.Many2many(
|
||||||
|
'fp.work.role', 'fp_employee_lead_hand_role_rel',
|
||||||
|
'employee_id', 'role_id', string='Lead Hand For',
|
||||||
|
help='Roles where this employee is authorised to lead or cover '
|
||||||
|
'for an absent operator. Lead hands are surfaced first in '
|
||||||
|
'the Manager Desk worker picker for these roles.',
|
||||||
|
)
|
||||||
|
|
||||||
|
x_fc_proficiency_ids = fields.One2many(
|
||||||
|
'fp.operator.proficiency', 'employee_id',
|
||||||
|
string='Task Proficiency',
|
||||||
|
help='Per-role completion tally. Workers earn one count per WO '
|
||||||
|
'they finish on a given role. Once the count crosses the '
|
||||||
|
"role's mastery threshold the role is added to their "
|
||||||
|
'Shop Roles list automatically.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Attendance helpers — used by the Manager Desk to show who is
|
||||||
|
# currently clocked in. Works with vanilla hr_attendance or the
|
||||||
|
# full fusion_clock module — both store an open record (no
|
||||||
|
# check_out) for as long as the employee is on shift.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
x_fc_is_clocked_in = fields.Boolean(
|
||||||
|
string='Clocked In',
|
||||||
|
compute='_compute_x_fc_is_clocked_in',
|
||||||
|
search='_search_x_fc_is_clocked_in',
|
||||||
|
help='True if this employee currently has an open hr.attendance '
|
||||||
|
'record (clocked in but not clocked out).',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_x_fc_is_clocked_in(self):
|
||||||
|
"""Compute attendance status from hr.attendance.
|
||||||
|
|
||||||
|
Batched so the manager dashboard doesn't issue one query per
|
||||||
|
employee — important when the shop has dozens of operators.
|
||||||
|
"""
|
||||||
|
if not self:
|
||||||
|
return
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
for emp in self:
|
||||||
|
emp.x_fc_is_clocked_in = False
|
||||||
|
return
|
||||||
|
# One read for the whole recordset.
|
||||||
|
open_emp_ids = set(Att.sudo().search([
|
||||||
|
('employee_id', 'in', self.ids),
|
||||||
|
('check_out', '=', False),
|
||||||
|
]).mapped('employee_id').ids)
|
||||||
|
for emp in self:
|
||||||
|
emp.x_fc_is_clocked_in = emp.id in open_emp_ids
|
||||||
|
|
||||||
|
def _search_x_fc_is_clocked_in(self, *args):
|
||||||
|
"""Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain.
|
||||||
|
|
||||||
|
Two compounding gotchas surfaced after fusion_clock auto-closed
|
||||||
|
the demo open attendances:
|
||||||
|
|
||||||
|
1. Odoo 19 normalises ``('=', True)`` into
|
||||||
|
``('in', OrderedSet([True]))`` before invoking the search
|
||||||
|
method. The previous code only handled ``=`` / ``!=`` and
|
||||||
|
fell through to ``return []`` for ``in`` / ``not in`` —
|
||||||
|
which Odoo treats as "no constraint" and matches every
|
||||||
|
row.
|
||||||
|
|
||||||
|
2. ``('id', 'in', [])`` is also treated as no-constraint in
|
||||||
|
some Odoo versions; replaced with a ``[0]`` sentinel so
|
||||||
|
the empty-open-list case correctly matches nothing.
|
||||||
|
|
||||||
|
Strategy: reduce caller intent to a *match_set* of booleans
|
||||||
|
(which values of ``x_fc_is_clocked_in`` should match), flip on
|
||||||
|
negative operators, then translate into ``id IN`` / ``NOT IN``
|
||||||
|
on the cached open-attendance employee ids. Variable signature
|
||||||
|
future-proofs against Odoo's compute-field API shifting again.
|
||||||
|
"""
|
||||||
|
# Variable signature — Odoo 19 may pass (records, op, val).
|
||||||
|
if len(args) == 3:
|
||||||
|
_records, operator, value = args
|
||||||
|
elif len(args) == 2:
|
||||||
|
operator, value = args
|
||||||
|
else:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
if operator in ('=', '!='):
|
||||||
|
match_set = {bool(value)}
|
||||||
|
elif operator in ('in', 'not in'):
|
||||||
|
match_set = set(map(bool, value))
|
||||||
|
else:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
|
||||||
|
# Negated operators flip the match set.
|
||||||
|
if operator in ('!=', 'not in'):
|
||||||
|
match_set = {True, False} - match_set
|
||||||
|
|
||||||
|
if not match_set:
|
||||||
|
return [('id', '=', False)]
|
||||||
|
if match_set == {True, False}:
|
||||||
|
return [] # every row matches
|
||||||
|
|
||||||
|
open_emp_ids = Att.sudo().search(
|
||||||
|
[('check_out', '=', False)]
|
||||||
|
).employee_id.ids
|
||||||
|
ids_term = open_emp_ids or [0]
|
||||||
|
return [('id', 'in' if True in match_set else 'not in', ids_term)]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fp_clocked_in_user_ids(self):
|
||||||
|
"""Return the set of res.users.ids whose linked employee is on shift.
|
||||||
|
|
||||||
|
Used by the Manager Desk controller to short-circuit the worker
|
||||||
|
dropdown to "present today" without an N+1 attendance query
|
||||||
|
per worker.
|
||||||
|
"""
|
||||||
|
Att = self.env.get('hr.attendance')
|
||||||
|
if Att is None:
|
||||||
|
return set()
|
||||||
|
emps = Att.sudo().search([
|
||||||
|
('check_out', '=', False),
|
||||||
|
]).mapped('employee_id')
|
||||||
|
return set(emps.user_id.ids)
|
||||||
@@ -56,3 +56,8 @@ access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.
|
|||||||
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||||
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
165
fusion_plating/fusion_plating/views/fp_work_role_views.xml
Normal file
165
fusion_plating/fusion_plating/views/fp_work_role_views.xml
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_work_role_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.work.role.list</field>
|
||||||
|
<field name="model">fp.work.role</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="icon" optional="show"/>
|
||||||
|
<field name="description"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_work_role_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.work.role.form</field>
|
||||||
|
<field name="model">fp.work.role</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name"/>
|
||||||
|
<h1><field name="name" placeholder="e.g. Plating Operator"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="code" placeholder="plating_op"/>
|
||||||
|
<field name="icon"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
<field name="mastery_required"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="description"
|
||||||
|
placeholder="Short operator-facing description of what this role covers."/>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
<strong>Mastery Threshold</strong> controls auto-promotion: when an
|
||||||
|
operator has finished this many WOs against this role, the role is
|
||||||
|
added to their Shop Roles automatically and a chatter line is
|
||||||
|
posted to their employee record. Defaults from
|
||||||
|
<em>Settings > Fusion Plating > Default Mastery Threshold</em>.
|
||||||
|
</div>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_work_role" model="ir.actions.act_window">
|
||||||
|
<field name="name">Shop Roles</field>
|
||||||
|
<field name="res_model">fp.work.role</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Define the roles on your shop floor
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Tag each employee with the roles they can perform and tag each
|
||||||
|
recipe step with the role that performs it. Work orders will
|
||||||
|
auto-route to the right worker when an MO is confirmed.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_work_roles"
|
||||||
|
name="Shop Roles"
|
||||||
|
parent="fusion_plating.menu_fp_config"
|
||||||
|
action="action_fp_work_role"
|
||||||
|
sequence="55"
|
||||||
|
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
|
<!-- Employee form — Shop Roles + Lead Hand For + Proficiency tracker -->
|
||||||
|
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
|
||||||
|
<field name="name">hr.employee.form.fp.roles</field>
|
||||||
|
<field name="model">hr.employee</field>
|
||||||
|
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//notebook" position="inside">
|
||||||
|
<page string="Shop Roles" name="fp_shop_roles"
|
||||||
|
groups="fusion_plating.group_fusion_plating_supervisor">
|
||||||
|
<group>
|
||||||
|
<group string="Tasks This Operator Can Do">
|
||||||
|
<field name="x_fc_work_role_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_create_edit': True}"
|
||||||
|
placeholder="Tag the shop roles this employee performs..."/>
|
||||||
|
<div class="text-muted small" colspan="2">
|
||||||
|
Work orders tagged with these roles auto-assign to
|
||||||
|
this employee (or to whoever has the same role and
|
||||||
|
the lighter open queue).
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
<group string="Lead Hand For"
|
||||||
|
groups="fusion_plating.group_fusion_plating_manager">
|
||||||
|
<field name="x_fc_lead_hand_role_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_create_edit': True}"
|
||||||
|
placeholder="Roles where this employee can cover for absent operators..."/>
|
||||||
|
<div class="text-muted small" colspan="2">
|
||||||
|
Lead hands appear at the top of the Manager Desk
|
||||||
|
worker dropdown for these roles, even when they
|
||||||
|
aren't the primary owner. Use for cross-trained
|
||||||
|
workers who can step in during absences.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<separator string="Task Proficiency"/>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Auto-tracked: every successfully completed WO bumps the
|
||||||
|
count for its role. When the count crosses the role's
|
||||||
|
mastery threshold the role is added to <em>Tasks This
|
||||||
|
Operator Can Do</em> automatically.
|
||||||
|
</p>
|
||||||
|
<field name="x_fc_proficiency_ids" nolabel="1"
|
||||||
|
readonly="1">
|
||||||
|
<list>
|
||||||
|
<field name="role_id"/>
|
||||||
|
<field name="completed_count"/>
|
||||||
|
<field name="progress_label" string="Progress"/>
|
||||||
|
<field name="promoted" widget="boolean_toggle"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="first_completed_at"/>
|
||||||
|
<field name="last_completed_at"/>
|
||||||
|
<field name="promoted_at"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Process node form — add role field -->
|
||||||
|
<record id="view_fp_process_node_form_fp_roles" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.plating.process.node.form.fp.roles</field>
|
||||||
|
<field name="model">fusion.plating.process.node</field>
|
||||||
|
<field name="inherit_id" ref="fusion_plating.view_fp_process_node_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='work_center_id']" position="after">
|
||||||
|
<field name="x_fc_work_role_id"
|
||||||
|
options="{'no_create_edit': True}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
NOTE: the WO form already shows x_fc_work_role_id + x_fc_assigned_user_id
|
||||||
|
via mrp_workorder_views.xml (after production_id). The earlier inherit
|
||||||
|
here would cause the fields to render twice.
|
||||||
|
-->
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Batch Processing',
|
'name': 'Fusion Plating — Batch Processing',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.2.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Group parts into rack or barrel loads for tank processing.',
|
'summary': 'Group parts into rack or barrel loads for tank processing.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -73,15 +73,9 @@ class FpBatch(models.Model):
|
|||||||
domain="[('state', '!=', 'retired')]",
|
domain="[('state', '!=', 'retired')]",
|
||||||
tracking=True,
|
tracking=True,
|
||||||
)
|
)
|
||||||
workorder_id = fields.Many2one(
|
# Phase 6 (Sub 11) — workorder_id / production_id retired (MRP gone).
|
||||||
'mrp.workorder', string='Work Order',
|
# Native equivalents: x_fc_step_id (fp.job.step) + x_fc_job_id (fp.job)
|
||||||
help='The WO this batch ran through. Used for material traceability.',
|
# are added by fusion_plating_jobs and carry the same traceability.
|
||||||
tracking=True,
|
|
||||||
)
|
|
||||||
production_id = fields.Many2one(
|
|
||||||
'mrp.production', string='Manufacturing Order',
|
|
||||||
related='workorder_id.production_id', store=True, readonly=True,
|
|
||||||
)
|
|
||||||
part_count = fields.Integer(string='Part Count')
|
part_count = fields.Integer(string='Part Count')
|
||||||
start_time = fields.Datetime(string='Process Start', tracking=True)
|
start_time = fields.Datetime(string='Process Start', tracking=True)
|
||||||
end_time = fields.Datetime(string='Process End', tracking=True)
|
end_time = fields.Datetime(string='Process End', tracking=True)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"name": "Fusion Plating — MRP Bridge",
|
"name": "Fusion Plating — MRP Bridge",
|
||||||
'version': '19.0.12.2.0',
|
'version': '19.0.13.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -59,33 +59,30 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/fp_work_role_data.xml',
|
# Phase 1 (Sub 11) — fp_work_role_data + fp_qc_data relocated
|
||||||
|
# to fusion_plating_jobs.
|
||||||
'data/fp_cron_data.xml',
|
'data/fp_cron_data.xml',
|
||||||
'data/fp_qc_data.xml',
|
|
||||||
'wizard/fp_recipe_config_wizard_views.xml',
|
'wizard/fp_recipe_config_wizard_views.xml',
|
||||||
'views/mrp_workcenter_views.xml',
|
'views/mrp_workcenter_views.xml',
|
||||||
'views/mrp_workorder_views.xml',
|
'views/mrp_workorder_views.xml',
|
||||||
'views/fp_qc_template_views.xml',
|
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs / fusion_plating_quality.
|
||||||
'views/fp_quality_check_views.xml',
|
# 'views/fp_qc_template_views.xml',
|
||||||
|
# 'views/fp_quality_check_views.xml',
|
||||||
|
# 'views/fp_job_consumption_views.xml',
|
||||||
|
# 'views/fp_work_role_views.xml',
|
||||||
'views/mrp_production_views.xml',
|
'views/mrp_production_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/fp_quality_hold_views.xml',
|
'views/fp_quality_hold_views.xml',
|
||||||
'views/fp_batch_views.xml',
|
'views/fp_batch_views.xml',
|
||||||
'views/fp_workorder_priority_views.xml',
|
# Phase 3 (Sub 11) — replaced by native fp.job.step priority kanban
|
||||||
'views/fp_job_consumption_views.xml',
|
# in fusion_plating_jobs/views/fp_step_priority_views.xml.
|
||||||
'views/fp_work_role_views.xml',
|
# 'views/fp_workorder_priority_views.xml',
|
||||||
'views/res_partner_views.xml',
|
# Phase 4 (Sub 11) — relocated to fusion_plating_quality.
|
||||||
|
# 'views/res_partner_views.xml',
|
||||||
'views/fp_serial_views.xml',
|
'views/fp_serial_views.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
# Phase 2 (Sub 11) — QC tablet OWL relocated to fusion_plating_quality.
|
||||||
# Depends on _fp_shopfloor_tokens.scss being loaded first —
|
|
||||||
# shopfloor is in depends, so its tokens bundle-concatenate
|
|
||||||
# before this file and define $fp-card / $fp-accent / etc.
|
|
||||||
'fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss',
|
|
||||||
'fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml',
|
|
||||||
'fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
from . import fp_qc_controller
|
# Phase 2 (Sub 11) — QC controller relocated to fusion_plating_quality.
|
||||||
|
# from . import fp_qc_controller
|
||||||
|
|||||||
@@ -11,16 +11,35 @@ from . import fp_portal_job
|
|||||||
from . import fp_quality_hold
|
from . import fp_quality_hold
|
||||||
from . import fp_delivery
|
from . import fp_delivery
|
||||||
from . import fp_batch
|
from . import fp_batch
|
||||||
|
# fusion.plating.job.node.override (mrp.production-bound) — kept here
|
||||||
|
# until Phase 5 deletes the bridge module. The native fp.job-bound
|
||||||
|
# override is `fp.job.node.override` in fusion_plating_jobs (different
|
||||||
|
# model, different table).
|
||||||
from . import fp_job_node_override
|
from . import fp_job_node_override
|
||||||
from . import fp_job_consumption
|
# Phase 1 (Sub 11) — fp.job.consumption is now in fusion_plating_jobs.
|
||||||
|
# bridge_mrp can't depend on jobs (would create a cycle through
|
||||||
|
# notifications/reports), so the legacy production_id/workorder_id
|
||||||
|
# fields are gone for good. mrp.production has 0 rows in native mode
|
||||||
|
# so the loss of the back-link is data-safe.
|
||||||
|
# from . import fp_job_consumption
|
||||||
from . import account_move
|
from . import account_move
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
from . import fp_work_role
|
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||||
from . import hr_employee
|
# from . import fp_work_role
|
||||||
from . import fp_proficiency
|
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||||
from . import fp_process_node
|
# from . import hr_employee
|
||||||
from . import fp_qc_template
|
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||||
|
# from . import fp_proficiency
|
||||||
|
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs (fp.work.role lives there).
|
||||||
|
# from . import fp_process_node
|
||||||
|
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||||
|
# from . import fp_qc_template
|
||||||
|
# Phase 1 (Sub 11) — model relocated to fusion_plating_quality.
|
||||||
|
# This file now contains only a thin inherit that restores the
|
||||||
|
# legacy production_id back-link until Phase 5 retires the bridge.
|
||||||
from . import fp_quality_check
|
from . import fp_quality_check
|
||||||
from . import fp_thickness_reading
|
# Phase 1 (Sub 11) — relocated to fusion_plating_quality.
|
||||||
from . import res_partner
|
# from . import fp_thickness_reading
|
||||||
|
# Phase 4 (Sub 11) — relocated to fusion_plating_quality.
|
||||||
|
# from . import res_partner
|
||||||
from . import fp_serial
|
from . import fp_serial
|
||||||
|
|||||||
@@ -2,85 +2,24 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — the model proper now lives in
|
||||||
|
# fusion_plating_jobs. This file restores the legacy production_id +
|
||||||
|
# workorder_id back-links so bridge_mrp's mrp.production O2M
|
||||||
|
# (x_fc_consumption_ids) keeps resolving until Phase 5 deletes the
|
||||||
|
# bridge module.
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpJobConsumption(models.Model):
|
class FpJobConsumption(models.Model):
|
||||||
"""A single consumable drawdown charged to a manufacturing order.
|
_inherit = 'fp.job.consumption'
|
||||||
|
|
||||||
Sources include bath replenishment applied against a job, masking tape
|
|
||||||
rolls, PPE, nickel salts — anything that has a cost and should roll
|
|
||||||
into job costing.
|
|
||||||
|
|
||||||
Kept deliberately lightweight: one row per event, cost derived from
|
|
||||||
`product.standard_price` at log time (snapshot, not reactive).
|
|
||||||
"""
|
|
||||||
_name = 'fp.job.consumption'
|
|
||||||
_description = 'Fusion Plating — Job Consumption'
|
|
||||||
_order = 'logged_date desc, id desc'
|
|
||||||
|
|
||||||
production_id = fields.Many2one(
|
production_id = fields.Many2one(
|
||||||
'mrp.production', string='Manufacturing Order',
|
'mrp.production', string='Manufacturing Order',
|
||||||
required=True, ondelete='cascade',
|
ondelete='cascade', index=True,
|
||||||
)
|
)
|
||||||
workorder_id = fields.Many2one(
|
workorder_id = fields.Many2one(
|
||||||
'mrp.workorder', string='Work Order',
|
'mrp.workorder', string='Work Order',
|
||||||
domain="[('production_id', '=', production_id)]",
|
domain="[('production_id', '=', production_id)]",
|
||||||
)
|
)
|
||||||
product_id = fields.Many2one(
|
|
||||||
'product.product', string='Product', required=True,
|
|
||||||
domain="[('sale_ok', '=', False)]",
|
|
||||||
)
|
|
||||||
product_name = fields.Char(
|
|
||||||
string='Product Name (snapshot)',
|
|
||||||
help='Free-text product label if no inventory product is linked.',
|
|
||||||
)
|
|
||||||
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
|
|
||||||
uom_id = fields.Many2one(
|
|
||||||
'uom.uom', string='UoM',
|
|
||||||
)
|
|
||||||
currency_id = fields.Many2one(
|
|
||||||
'res.currency', required=True,
|
|
||||||
default=lambda self: self.env.company.currency_id,
|
|
||||||
)
|
|
||||||
unit_cost = fields.Monetary(
|
|
||||||
string='Unit Cost (snapshot)', currency_field='currency_id',
|
|
||||||
help='Taken from product.standard_price at log time.',
|
|
||||||
)
|
|
||||||
total_cost = fields.Monetary(
|
|
||||||
string='Total Cost', currency_field='currency_id',
|
|
||||||
compute='_compute_total_cost', store=True,
|
|
||||||
)
|
|
||||||
logged_date = fields.Datetime(
|
|
||||||
string='Logged', default=fields.Datetime.now,
|
|
||||||
)
|
|
||||||
logged_by_id = fields.Many2one(
|
|
||||||
'res.users', string='Logged By', default=lambda self: self.env.user,
|
|
||||||
)
|
|
||||||
source = fields.Selection(
|
|
||||||
[('replenishment', 'Bath Replenishment'),
|
|
||||||
('masking', 'Masking Material'),
|
|
||||||
('ppe', 'PPE / Consumables'),
|
|
||||||
('chemistry', 'Process Chemistry'),
|
|
||||||
('other', 'Other')],
|
|
||||||
string='Source', default='other', required=True,
|
|
||||||
)
|
|
||||||
replenishment_id = fields.Many2one(
|
|
||||||
'fusion.plating.bath.replenishment.suggestion',
|
|
||||||
string='Replenishment Suggestion',
|
|
||||||
ondelete='set null',
|
|
||||||
)
|
|
||||||
notes = fields.Char(string='Notes')
|
|
||||||
|
|
||||||
@api.depends('quantity', 'unit_cost')
|
|
||||||
def _compute_total_cost(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
|
|
||||||
|
|
||||||
@api.onchange('product_id')
|
|
||||||
def _onchange_product(self):
|
|
||||||
if self.product_id:
|
|
||||||
self.product_name = self.product_id.display_name
|
|
||||||
self.unit_cost = self.product_id.standard_price or 0.0
|
|
||||||
self.uom_id = self.product_id.uom_id or False
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpJobNodeOverride(models.Model):
|
class FpJobNodeOverride(models.Model):
|
||||||
@@ -58,6 +58,14 @@ class FpJobNodeOverride(models.Model):
|
|||||||
help='Whether this optional step is active for this job.',
|
help='Whether this optional step is active for this job.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends('production_id', 'node_id', 'included')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
mo = rec.production_id.name or '(no MO)'
|
||||||
|
node = rec.node_id.display_name or '(no node)'
|
||||||
|
tag = 'included' if rec.included else 'excluded'
|
||||||
|
rec.display_name = '%s · %s [%s]' % (mo, node, tag)
|
||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
('unique_production_node',
|
('unique_production_node',
|
||||||
'unique(production_id, node_id)',
|
'unique(production_id, node_id)',
|
||||||
|
|||||||
@@ -2,621 +2,22 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
"""Per-MO QC instance.
|
#
|
||||||
|
# Phase 1 (Sub 11) — the QC model proper now lives in
|
||||||
|
# fusion_plating_quality. This file restores the legacy production_id
|
||||||
|
# back-link on fusion.plating.quality.check so bridge_mrp's
|
||||||
|
# mrp.production O2M (x_fc_qc_check_ids) keeps resolving until Phase 5
|
||||||
|
# deletes the bridge module entirely.
|
||||||
|
|
||||||
When an MO confirms and the customer requires QC, we clone the active
|
from odoo import fields, models
|
||||||
checklist template into a `fusion.plating.quality.check` with one line
|
|
||||||
per template line. The inspector picks it up on the tablet, walks the
|
|
||||||
checks, and signs off — which unblocks `mrp.production.button_mark_done`.
|
|
||||||
|
|
||||||
The QC also owns the Fischerscope / XDAL 600 thickness report PDF.
|
|
||||||
When the operator uploads one, we extract per-reading data server-side
|
|
||||||
and auto-create `fp.thickness.reading` rows so the CoC PDF picks them up.
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from markupsafe import Markup
|
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
|
||||||
from odoo.exceptions import UserError
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class FpQualityCheck(models.Model):
|
class FpQualityCheck(models.Model):
|
||||||
_name = 'fusion.plating.quality.check'
|
_inherit = 'fusion.plating.quality.check'
|
||||||
_description = 'Fusion Plating — Quality Check'
|
|
||||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
||||||
_order = 'create_date desc'
|
|
||||||
|
|
||||||
name = fields.Char(
|
|
||||||
string='Reference', required=True, copy=False, readonly=True,
|
|
||||||
default=lambda self: self._default_name(), tracking=True,
|
|
||||||
)
|
|
||||||
production_id = fields.Many2one(
|
production_id = fields.Many2one(
|
||||||
'mrp.production', string='Manufacturing Order',
|
'mrp.production', string='Manufacturing Order',
|
||||||
required=True, ondelete='cascade', tracking=True,
|
ondelete='cascade', index=True,
|
||||||
index=True,
|
help='Legacy MRP back-link. Native flow uses job_id; this stays '
|
||||||
|
'for bridge_mrp until Phase 5 cuts the module.',
|
||||||
)
|
)
|
||||||
partner_id = fields.Many2one(
|
|
||||||
'res.partner', string='Customer',
|
|
||||||
compute='_compute_partner_id', store=True,
|
|
||||||
)
|
|
||||||
template_id = fields.Many2one(
|
|
||||||
'fp.qc.checklist.template', string='Template',
|
|
||||||
help='The checklist template these lines were cloned from.',
|
|
||||||
)
|
|
||||||
state = fields.Selection(
|
|
||||||
[
|
|
||||||
('draft', 'Draft'),
|
|
||||||
('in_progress', 'In Progress'),
|
|
||||||
('passed', 'Passed'),
|
|
||||||
('failed', 'Failed'),
|
|
||||||
('rework', 'Rework Required'),
|
|
||||||
],
|
|
||||||
string='Status', default='draft', required=True, tracking=True,
|
|
||||||
)
|
|
||||||
overall_result = fields.Selection(
|
|
||||||
[('pass', 'Pass'), ('fail', 'Fail'), ('partial', 'Partial Pass')],
|
|
||||||
string='Result', tracking=True,
|
|
||||||
help='Summary outcome — set when inspector signs off.',
|
|
||||||
)
|
|
||||||
line_ids = fields.One2many(
|
|
||||||
'fusion.plating.quality.check.line', 'check_id',
|
|
||||||
string='Check Items',
|
|
||||||
)
|
|
||||||
line_count = fields.Integer(compute='_compute_line_stats', store=True)
|
|
||||||
lines_passed = fields.Integer(compute='_compute_line_stats', store=True)
|
|
||||||
lines_failed = fields.Integer(compute='_compute_line_stats', store=True)
|
|
||||||
lines_pending = fields.Integer(compute='_compute_line_stats', store=True)
|
|
||||||
|
|
||||||
inspector_id = fields.Many2one(
|
|
||||||
'res.users', string='Inspector',
|
|
||||||
help='Whoever signed the QC off. Filled when state moves to '
|
|
||||||
'passed/failed.',
|
|
||||||
tracking=True,
|
|
||||||
)
|
|
||||||
started_at = fields.Datetime(
|
|
||||||
string='Started', help='First time inspector opened this check.',
|
|
||||||
)
|
|
||||||
completed_at = fields.Datetime(
|
|
||||||
string='Completed', help='When the check was signed off.',
|
|
||||||
tracking=True,
|
|
||||||
)
|
|
||||||
notes = fields.Html(string='Inspector Notes')
|
|
||||||
|
|
||||||
# Fischerscope / XDAL 600 PDF + auto-extracted readings
|
|
||||||
thickness_report_pdf_id = fields.Many2one(
|
|
||||||
'ir.attachment', string='Thickness Report PDF',
|
|
||||||
help='Upload the Fischerscope / XDAL 600 export. On upload we '
|
|
||||||
'parse the PDF and auto-create fp.thickness.reading rows.',
|
|
||||||
)
|
|
||||||
thickness_reading_ids = fields.One2many(
|
|
||||||
'fp.thickness.reading', 'quality_check_id',
|
|
||||||
string='Thickness Readings',
|
|
||||||
)
|
|
||||||
thickness_reading_count = fields.Integer(
|
|
||||||
compute='_compute_thickness_count',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cached gate-policy flags from the template (denormalized so
|
|
||||||
# button_mark_done doesn't have to reach through a potentially-null
|
|
||||||
# template).
|
|
||||||
require_thickness_readings = fields.Boolean(
|
|
||||||
related='template_id.require_thickness_readings',
|
|
||||||
store=True, readonly=True,
|
|
||||||
)
|
|
||||||
require_thickness_report_pdf = fields.Boolean(
|
|
||||||
related='template_id.require_thickness_report_pdf',
|
|
||||||
store=True, readonly=True,
|
|
||||||
)
|
|
||||||
require_inspector_signoff = fields.Boolean(
|
|
||||||
related='template_id.require_inspector_signoff',
|
|
||||||
store=True, readonly=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
company_id = fields.Many2one(
|
|
||||||
'res.company', related='production_id.company_id',
|
|
||||||
store=True, readonly=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Computed
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@api.depends('production_id.origin')
|
|
||||||
def _compute_partner_id(self):
|
|
||||||
SO = self.env['sale.order']
|
|
||||||
for rec in self:
|
|
||||||
partner = False
|
|
||||||
mo = rec.production_id
|
|
||||||
if mo and mo.origin:
|
|
||||||
so = SO.search([('name', '=', mo.origin)], limit=1)
|
|
||||||
if so:
|
|
||||||
partner = so.partner_id
|
|
||||||
rec.partner_id = partner
|
|
||||||
|
|
||||||
@api.depends('line_ids.result')
|
|
||||||
def _compute_line_stats(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.line_count = len(rec.line_ids)
|
|
||||||
rec.lines_passed = len(rec.line_ids.filtered(
|
|
||||||
lambda l: l.result == 'pass'
|
|
||||||
))
|
|
||||||
rec.lines_failed = len(rec.line_ids.filtered(
|
|
||||||
lambda l: l.result == 'fail'
|
|
||||||
))
|
|
||||||
rec.lines_pending = len(rec.line_ids.filtered(
|
|
||||||
lambda l: l.result in (False, 'pending')
|
|
||||||
))
|
|
||||||
|
|
||||||
@api.depends('thickness_reading_ids')
|
|
||||||
def _compute_thickness_count(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.thickness_reading_count = len(rec.thickness_reading_ids)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Create + sequence
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@api.model
|
|
||||||
def _default_name(self):
|
|
||||||
seq = self.env['ir.sequence'].next_by_code(
|
|
||||||
'fusion.plating.quality.check',
|
|
||||||
)
|
|
||||||
return seq or 'QC/NEW'
|
|
||||||
|
|
||||||
@api.model_create_multi
|
|
||||||
def create(self, vals_list):
|
|
||||||
for vals in vals_list:
|
|
||||||
if not vals.get('name') or vals.get('name') == '/':
|
|
||||||
vals['name'] = self._default_name()
|
|
||||||
return super().create(vals_list)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Factory — spawn a QC from a template
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@api.model
|
|
||||||
def create_for_production(self, production, template=None):
|
|
||||||
"""Spin up a QC record for an MO, cloning lines from the template.
|
|
||||||
|
|
||||||
If no template is passed, we try to resolve one from the MO's
|
|
||||||
customer. Returns the created check, or an empty recordset if
|
|
||||||
no template matches (=> no QC required for this customer).
|
|
||||||
"""
|
|
||||||
self = self.sudo()
|
|
||||||
if template is None:
|
|
||||||
partner = False
|
|
||||||
if production.origin:
|
|
||||||
so = self.env['sale.order'].search(
|
|
||||||
[('name', '=', production.origin)], limit=1,
|
|
||||||
)
|
|
||||||
if so:
|
|
||||||
partner = so.partner_id
|
|
||||||
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
|
|
||||||
partner,
|
|
||||||
)
|
|
||||||
if not template:
|
|
||||||
return self.browse() # empty — no QC required
|
|
||||||
|
|
||||||
# Avoid duplicates — one active (non-failed) check per MO
|
|
||||||
existing = self.search([
|
|
||||||
('production_id', '=', production.id),
|
|
||||||
('state', '!=', 'failed'),
|
|
||||||
], limit=1)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
check = self.create({
|
|
||||||
'production_id': production.id,
|
|
||||||
'template_id': template.id,
|
|
||||||
'state': 'draft',
|
|
||||||
})
|
|
||||||
Line = self.env['fusion.plating.quality.check.line']
|
|
||||||
for tline in template.line_ids.sorted('sequence'):
|
|
||||||
Line.create({
|
|
||||||
'check_id': check.id,
|
|
||||||
'sequence': tline.sequence,
|
|
||||||
'name': tline.name,
|
|
||||||
'description': tline.description,
|
|
||||||
'check_type': tline.check_type,
|
|
||||||
'required': tline.required,
|
|
||||||
'requires_value': tline.requires_value,
|
|
||||||
'value_min': tline.value_min,
|
|
||||||
'value_max': tline.value_max,
|
|
||||||
'value_uom': tline.value_uom,
|
|
||||||
'requires_photo': tline.requires_photo,
|
|
||||||
'result': 'pending',
|
|
||||||
})
|
|
||||||
production.message_post(
|
|
||||||
body=_('QC checklist "%s" created — %d items to inspect.') % (
|
|
||||||
template.name, len(template.line_ids),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return check
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# State actions
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def action_start(self):
|
|
||||||
for rec in self:
|
|
||||||
if rec.state == 'draft':
|
|
||||||
rec.write({
|
|
||||||
'state': 'in_progress',
|
|
||||||
'started_at': fields.Datetime.now(),
|
|
||||||
'inspector_id': self.env.user.id,
|
|
||||||
})
|
|
||||||
rec.message_post(body=_('QC started.'))
|
|
||||||
|
|
||||||
def action_pass(self):
|
|
||||||
for rec in self:
|
|
||||||
rec._ensure_all_required_complete()
|
|
||||||
rec.write({
|
|
||||||
'state': 'passed',
|
|
||||||
'overall_result': 'pass',
|
|
||||||
'completed_at': fields.Datetime.now(),
|
|
||||||
'inspector_id': self.env.user.id,
|
|
||||||
})
|
|
||||||
rec.message_post(body=Markup(
|
|
||||||
'<b>QC PASSED</b> — inspector %s.'
|
|
||||||
) % self.env.user.name)
|
|
||||||
|
|
||||||
def action_fail(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.write({
|
|
||||||
'state': 'failed',
|
|
||||||
'overall_result': 'fail',
|
|
||||||
'completed_at': fields.Datetime.now(),
|
|
||||||
'inspector_id': self.env.user.id,
|
|
||||||
})
|
|
||||||
rec.message_post(body=Markup(
|
|
||||||
'<b>QC FAILED</b> — inspector %s.'
|
|
||||||
) % self.env.user.name)
|
|
||||||
|
|
||||||
def action_rework(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.write({
|
|
||||||
'state': 'rework',
|
|
||||||
'overall_result': 'partial',
|
|
||||||
'completed_at': fields.Datetime.now(),
|
|
||||||
'inspector_id': self.env.user.id,
|
|
||||||
})
|
|
||||||
rec.message_post(body=_('QC flagged for rework.'))
|
|
||||||
|
|
||||||
def action_reset_to_draft(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.write({
|
|
||||||
'state': 'draft',
|
|
||||||
'overall_result': False,
|
|
||||||
'completed_at': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
def action_spawn_retry(self):
|
|
||||||
"""Spin up a fresh QC instance for the same MO.
|
|
||||||
|
|
||||||
Used after a failed QC — the original stays in history, the
|
|
||||||
new one gets the same template applied to a clean slate.
|
|
||||||
Manager-only via ACL.
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
if self.state != 'failed':
|
|
||||||
return # no-op; user can just finish the existing one
|
|
||||||
new_check = self.sudo().create_for_production(
|
|
||||||
self.production_id, template=self.template_id,
|
|
||||||
)
|
|
||||||
if not new_check:
|
|
||||||
return False
|
|
||||||
self.message_post(body=_(
|
|
||||||
'Retry QC created: %s'
|
|
||||||
) % new_check.name)
|
|
||||||
new_check.message_post(body=_(
|
|
||||||
'Retry of failed QC %s'
|
|
||||||
) % self.name)
|
|
||||||
return new_check.action_open_tablet()
|
|
||||||
|
|
||||||
def _ensure_all_required_complete(self):
|
|
||||||
"""Guard for action_pass — every required line must be resolved
|
|
||||||
to pass or n/a (fail would be handled by action_fail) and any
|
|
||||||
numeric-value / photo requirements must be honoured."""
|
|
||||||
for rec in self:
|
|
||||||
pending = rec.line_ids.filtered(
|
|
||||||
lambda l: l.required and l.result in (False, 'pending')
|
|
||||||
)
|
|
||||||
if pending:
|
|
||||||
raise UserError(_(
|
|
||||||
'Cannot pass QC "%(name)s" — %(n)d required check '
|
|
||||||
'item(s) still pending:\n • %(items)s'
|
|
||||||
) % {
|
|
||||||
'name': rec.name,
|
|
||||||
'n': len(pending),
|
|
||||||
'items': '\n • '.join(pending.mapped('name')),
|
|
||||||
})
|
|
||||||
failed = rec.line_ids.filtered(lambda l: l.result == 'fail')
|
|
||||||
if failed:
|
|
||||||
raise UserError(_(
|
|
||||||
'Cannot pass QC "%(name)s" — %(n)d check item(s) '
|
|
||||||
'failed. Fail the QC instead, or reset those '
|
|
||||||
'items to pass.'
|
|
||||||
) % {'name': rec.name, 'n': len(failed)})
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Fischerscope PDF upload → auto-extract readings
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _on_thickness_pdf_uploaded(self):
|
|
||||||
"""Parse the attached PDF with `pdftotext` and create
|
|
||||||
fp.thickness.reading rows.
|
|
||||||
|
|
||||||
Fischerscope XDAL 600 / WinFTM reports vary a bit in layout
|
|
||||||
but consistently print one line per reading with a column for
|
|
||||||
NiP thickness in mils and another for Ni / P percentages. The
|
|
||||||
parser is conservative: if a column isn't confidently found,
|
|
||||||
we skip that reading rather than write garbage.
|
|
||||||
"""
|
|
||||||
ThicknessReading = self.env['fp.thickness.reading']
|
|
||||||
for rec in self:
|
|
||||||
if not rec.thickness_report_pdf_id:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
text = rec._extract_pdf_text(rec.thickness_report_pdf_id)
|
|
||||||
except Exception:
|
|
||||||
_logger.exception(
|
|
||||||
'QC %s: pdftotext extraction failed', rec.name,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
readings = rec._parse_fischerscope_text(text)
|
|
||||||
if not readings:
|
|
||||||
rec.message_post(body=_(
|
|
||||||
'Thickness report PDF attached but no readings '
|
|
||||||
'could be extracted automatically. Please enter '
|
|
||||||
'readings manually.'
|
|
||||||
))
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Replace any prior auto-extracted readings so re-uploads
|
|
||||||
# don't stack duplicates.
|
|
||||||
auto = rec.thickness_reading_ids.filtered(
|
|
||||||
lambda r: r.auto_extracted
|
|
||||||
)
|
|
||||||
auto.unlink()
|
|
||||||
|
|
||||||
for idx, row in enumerate(readings, start=1):
|
|
||||||
ThicknessReading.create({
|
|
||||||
'quality_check_id': rec.id,
|
|
||||||
'production_id': rec.production_id.id,
|
|
||||||
'reading_number': idx,
|
|
||||||
'nip_mils': row.get('nip_mils', 0.0),
|
|
||||||
'ni_percent': row.get('ni_percent', 0.0),
|
|
||||||
'p_percent': row.get('p_percent', 0.0),
|
|
||||||
'position_label': row.get('position', ''),
|
|
||||||
'auto_extracted': True,
|
|
||||||
})
|
|
||||||
rec.message_post(body=_(
|
|
||||||
'Extracted %d thickness reading(s) from "%s".'
|
|
||||||
) % (len(readings), rec.thickness_report_pdf_id.name))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_pdf_text(attachment):
|
|
||||||
"""Run pdftotext on an ir.attachment and return the text."""
|
|
||||||
raw = base64.b64decode(attachment.datas or b'')
|
|
||||||
if not raw:
|
|
||||||
return ''
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
suffix='.pdf', delete=True,
|
|
||||||
) as tmp:
|
|
||||||
tmp.write(raw)
|
|
||||||
tmp.flush()
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
['pdftotext', '-layout', tmp.name, '-'],
|
|
||||||
capture_output=True, text=True, timeout=30,
|
|
||||||
)
|
|
||||||
return result.stdout or ''
|
|
||||||
except FileNotFoundError:
|
|
||||||
_logger.warning(
|
|
||||||
'pdftotext not installed — cannot auto-extract '
|
|
||||||
'Fischerscope PDF data. Install poppler-utils on '
|
|
||||||
'the Odoo host.',
|
|
||||||
)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_fischerscope_text(text):
|
|
||||||
"""Best-effort Fischerscope WinFTM table parser.
|
|
||||||
|
|
||||||
WinFTM single-reading export lines look like:
|
|
||||||
n=1 0.000843 mils 91.5% Ni 8.5% P 120s
|
|
||||||
or (with labels bleeding together from the PDF layout):
|
|
||||||
1 0.000843 91.5 8.5 Pos 1
|
|
||||||
|
|
||||||
We match any row that has 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: <index> <nip-mils-or-microns> <ni%> <p%>
|
|
||||||
# Indices may appear as "n=1", "1.", "1", "N1"
|
|
||||||
row_re = re.compile(
|
|
||||||
r'^\s*(?:n\s*=\s*|N\s*)?(\d{1,3})[\s.:]+'
|
|
||||||
r'([0-9]*\.[0-9]+|\d+)' # nip
|
|
||||||
r'(?:\s*(?:mils|microns|µm|um))?'
|
|
||||||
r'[\s|]+'
|
|
||||||
r'([0-9]*\.?[0-9]+)' # ni%
|
|
||||||
r'[\s|%]+'
|
|
||||||
r'([0-9]*\.?[0-9]+)' # p%
|
|
||||||
r'[\s|%]*'
|
|
||||||
r'(.*)$',
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
for raw_line in text.splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
m = row_re.match(line)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
idx = int(m.group(1))
|
|
||||||
nip = float(m.group(2))
|
|
||||||
ni = float(m.group(3))
|
|
||||||
p = float(m.group(4))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
# Sanity guards — NiP > 1 mil is unheard of on plating;
|
|
||||||
# Ni% and P% should sum to ~100.
|
|
||||||
if not (0 < nip < 1) and not (0 < nip < 30): # 30µm envelope
|
|
||||||
continue
|
|
||||||
if not (0 < ni < 100):
|
|
||||||
continue
|
|
||||||
if not (0 < p < 30):
|
|
||||||
continue
|
|
||||||
# Throw out rows where index is obviously wrong
|
|
||||||
if idx < 1 or idx > 500:
|
|
||||||
continue
|
|
||||||
position = (m.group(5) or '').strip()[:60]
|
|
||||||
readings.append({
|
|
||||||
'index': idx,
|
|
||||||
'nip_mils': nip,
|
|
||||||
'ni_percent': ni,
|
|
||||||
'p_percent': p,
|
|
||||||
'position': position,
|
|
||||||
})
|
|
||||||
# Keep only one reading per index (first wins)
|
|
||||||
seen = set()
|
|
||||||
dedup = []
|
|
||||||
for r in readings:
|
|
||||||
if r['index'] in seen:
|
|
||||||
continue
|
|
||||||
seen.add(r['index'])
|
|
||||||
dedup.append(r)
|
|
||||||
return dedup
|
|
||||||
|
|
||||||
def write(self, vals):
|
|
||||||
trigger = 'thickness_report_pdf_id' in vals and vals.get(
|
|
||||||
'thickness_report_pdf_id'
|
|
||||||
)
|
|
||||||
res = super().write(vals)
|
|
||||||
if trigger:
|
|
||||||
self._on_thickness_pdf_uploaded()
|
|
||||||
return res
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Navigation helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def action_open_tablet(self):
|
|
||||||
"""Launch the mobile QC checklist OWL client action."""
|
|
||||||
self.ensure_one()
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'fp_qc_checklist',
|
|
||||||
'name': _('QC — %s') % (self.production_id.name or ''),
|
|
||||||
'params': {'check_id': self.id},
|
|
||||||
'target': 'current',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FpQualityCheckLine(models.Model):
|
|
||||||
_name = 'fusion.plating.quality.check.line'
|
|
||||||
_description = 'Fusion Plating — Quality Check Line'
|
|
||||||
_order = 'sequence, id'
|
|
||||||
|
|
||||||
check_id = fields.Many2one(
|
|
||||||
'fusion.plating.quality.check', string='Check',
|
|
||||||
required=True, ondelete='cascade', index=True,
|
|
||||||
)
|
|
||||||
sequence = fields.Integer(default=10)
|
|
||||||
name = fields.Char(string='Check Item', required=True)
|
|
||||||
description = fields.Text(string='Guidance')
|
|
||||||
check_type = fields.Selection(
|
|
||||||
selection=lambda self: self.env[
|
|
||||||
'fp.qc.checklist.template.line'
|
|
||||||
]._fields['check_type'].selection,
|
|
||||||
string='Type', default='visual',
|
|
||||||
)
|
|
||||||
required = fields.Boolean(default=True)
|
|
||||||
requires_value = fields.Boolean()
|
|
||||||
value = fields.Float(digits=(12, 4))
|
|
||||||
value_min = fields.Float(digits=(12, 4))
|
|
||||||
value_max = fields.Float(digits=(12, 4))
|
|
||||||
value_uom = fields.Char(string='Unit')
|
|
||||||
requires_photo = fields.Boolean()
|
|
||||||
photo_attachment_id = fields.Many2one(
|
|
||||||
'ir.attachment', string='Photo',
|
|
||||||
)
|
|
||||||
result = fields.Selection(
|
|
||||||
[
|
|
||||||
('pending', 'Pending'),
|
|
||||||
('pass', 'Pass'),
|
|
||||||
('fail', 'Fail'),
|
|
||||||
('na', 'N/A'),
|
|
||||||
],
|
|
||||||
string='Result', default='pending', required=True,
|
|
||||||
)
|
|
||||||
notes = fields.Text(string='Note')
|
|
||||||
inspector_id = fields.Many2one('res.users', string='Inspector')
|
|
||||||
completed_at = fields.Datetime(string='Completed At')
|
|
||||||
|
|
||||||
value_in_range = fields.Boolean(
|
|
||||||
compute='_compute_value_in_range', store=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.depends('value', 'value_min', 'value_max', 'requires_value')
|
|
||||||
def _compute_value_in_range(self):
|
|
||||||
for rec in self:
|
|
||||||
if not rec.requires_value:
|
|
||||||
rec.value_in_range = True
|
|
||||||
continue
|
|
||||||
vmin = rec.value_min
|
|
||||||
vmax = rec.value_max
|
|
||||||
if vmin and rec.value < vmin:
|
|
||||||
rec.value_in_range = False
|
|
||||||
elif vmax and rec.value > vmax:
|
|
||||||
rec.value_in_range = False
|
|
||||||
else:
|
|
||||||
rec.value_in_range = True
|
|
||||||
|
|
||||||
def action_mark_pass(self):
|
|
||||||
for rec in self:
|
|
||||||
if rec.requires_value and not rec.value_in_range:
|
|
||||||
raise UserError(_(
|
|
||||||
'Cannot pass "%(item)s" — value %(val)s is outside '
|
|
||||||
'the acceptance range (%(min)s – %(max)s %(uom)s).'
|
|
||||||
) % {
|
|
||||||
'item': rec.name,
|
|
||||||
'val': rec.value,
|
|
||||||
'min': rec.value_min,
|
|
||||||
'max': rec.value_max,
|
|
||||||
'uom': rec.value_uom or '',
|
|
||||||
})
|
|
||||||
if rec.requires_photo and not rec.photo_attachment_id:
|
|
||||||
raise UserError(_(
|
|
||||||
'Cannot pass "%(item)s" — a photo is required.'
|
|
||||||
) % {'item': rec.name})
|
|
||||||
rec.write({
|
|
||||||
'result': 'pass',
|
|
||||||
'inspector_id': self.env.user.id,
|
|
||||||
'completed_at': fields.Datetime.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
def action_mark_fail(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.write({
|
|
||||||
'result': 'fail',
|
|
||||||
'inspector_id': self.env.user.id,
|
|
||||||
'completed_at': fields.Datetime.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
def action_mark_na(self):
|
|
||||||
for rec in self:
|
|
||||||
if rec.required:
|
|
||||||
raise UserError(_(
|
|
||||||
'"%(item)s" is a required check and cannot be '
|
|
||||||
'marked N/A.'
|
|
||||||
) % {'item': rec.name})
|
|
||||||
rec.write({
|
|
||||||
'result': 'na',
|
|
||||||
'inspector_id': self.env.user.id,
|
|
||||||
'completed_at': fields.Datetime.now(),
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -170,10 +170,12 @@ class MrpProduction(models.Model):
|
|||||||
# T3.3 — Actuals vs quoted margin
|
# T3.3 — Actuals vs quoted margin
|
||||||
# T3.4 — Consumables tied to jobs
|
# T3.4 — Consumables tied to jobs
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
x_fc_consumption_ids = fields.One2many(
|
# Phase 1 (Sub 11) — fp.job.consumption relocated to
|
||||||
'fp.job.consumption', 'production_id',
|
# fusion_plating_jobs. The MO-side O2M would create a circular
|
||||||
string='Consumables Log',
|
# dependency (bridge_mrp → jobs → notifications → bridge_mrp), and
|
||||||
)
|
# mrp.production has 0 rows in native mode, so the field is gone.
|
||||||
|
# The native fp.job analogue carries consumption via
|
||||||
|
# fp.job.consumption.job_id.
|
||||||
x_fc_consumables_cost = fields.Monetary(
|
x_fc_consumables_cost = fields.Monetary(
|
||||||
string='Consumables Cost', compute='_compute_job_costs',
|
string='Consumables Cost', compute='_compute_job_costs',
|
||||||
store=True, currency_field='x_fc_currency_id',
|
store=True, currency_field='x_fc_currency_id',
|
||||||
@@ -228,7 +230,7 @@ class MrpProduction(models.Model):
|
|||||||
|
|
||||||
def _compute_consumption_count(self):
|
def _compute_consumption_count(self):
|
||||||
for mo in self:
|
for mo in self:
|
||||||
mo.x_fc_consumption_count = len(mo.x_fc_consumption_ids)
|
mo.x_fc_consumption_count = 0
|
||||||
|
|
||||||
@api.depends('origin')
|
@api.depends('origin')
|
||||||
def _compute_sale_order_id(self):
|
def _compute_sale_order_id(self):
|
||||||
@@ -305,7 +307,6 @@ class MrpProduction(models.Model):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@api.depends(
|
@api.depends(
|
||||||
'x_fc_consumption_ids.total_cost',
|
|
||||||
'workorder_ids.duration',
|
'workorder_ids.duration',
|
||||||
'workorder_ids.workcenter_id.costs_hour',
|
'workorder_ids.workcenter_id.costs_hour',
|
||||||
'origin',
|
'origin',
|
||||||
@@ -314,7 +315,8 @@ class MrpProduction(models.Model):
|
|||||||
SO = self.env['sale.order']
|
SO = self.env['sale.order']
|
||||||
for mo in self:
|
for mo in self:
|
||||||
currency = mo.company_id.currency_id
|
currency = mo.company_id.currency_id
|
||||||
consumables = sum(mo.x_fc_consumption_ids.mapped('total_cost'))
|
# Phase 1 (Sub 11) — consumption now lives on fp.job, not MO.
|
||||||
|
consumables = 0.0
|
||||||
labour = 0.0
|
labour = 0.0
|
||||||
for wo in mo.workorder_ids:
|
for wo in mo.workorder_ids:
|
||||||
rate = wo.workcenter_id.costs_hour or 0.0
|
rate = wo.workcenter_id.costs_hour or 0.0
|
||||||
@@ -1218,29 +1220,35 @@ class MrpProduction(models.Model):
|
|||||||
def _resolve_mo_process_tree(self):
|
def _resolve_mo_process_tree(self):
|
||||||
"""Resolve which process-tree root to walk for this MO.
|
"""Resolve which process-tree root to walk for this MO.
|
||||||
|
|
||||||
Sub 3 — prefers the linked part's cloned tree
|
Resolution priority (Sub 9 — process variants):
|
||||||
(SO line's x_fc_part_catalog_id.default_process_id); falls back
|
1. SO line's `x_fc_process_variant_id` (per-order variant pick)
|
||||||
to the legacy x_fc_recipe_id for MOs without a linked part or
|
2. Linked part's `default_process_id` (the part's default variant)
|
||||||
without a composed part tree.
|
3. Legacy `x_fc_recipe_id` (coating config / product match)
|
||||||
|
|
||||||
Single entry point so Sub 4 / Sub 5 updates touch one method.
|
Multi-line MOs: first line wins. Variants are part-scoped, and a
|
||||||
|
single MO is bound to a single part group via x_fc_wo_group_tag,
|
||||||
|
so first-line semantics match how the WO walker batches.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
# Resolve part via SO lines (MO's origin → sale.order → first
|
line = False
|
||||||
# line's part). mrp.production has no direct part link; the
|
if 'x_fc_sale_order_line_ids' in self._fields and self.x_fc_sale_order_line_ids:
|
||||||
# relationship lives on sale.order.line.
|
line = self.x_fc_sale_order_line_ids[0]
|
||||||
part = False
|
elif self.origin:
|
||||||
if self.origin:
|
|
||||||
so = self.env['sale.order'].search(
|
so = self.env['sale.order'].search(
|
||||||
[('name', '=', self.origin)], limit=1,
|
[('name', '=', self.origin)], limit=1,
|
||||||
)
|
)
|
||||||
if so and so.order_line:
|
if so and so.order_line:
|
||||||
first_line = so.order_line[0]
|
line = so.order_line[0]
|
||||||
if 'x_fc_part_catalog_id' in first_line._fields:
|
|
||||||
part = first_line.x_fc_part_catalog_id
|
if line:
|
||||||
if part and part.default_process_id:
|
if ('x_fc_process_variant_id' in line._fields
|
||||||
return part.default_process_id
|
and line.x_fc_process_variant_id):
|
||||||
# Fallback — legacy recipe lookup (coating config / product match)
|
return line.x_fc_process_variant_id
|
||||||
|
if ('x_fc_part_catalog_id' in line._fields
|
||||||
|
and line.x_fc_part_catalog_id
|
||||||
|
and line.x_fc_part_catalog_id.default_process_id):
|
||||||
|
return line.x_fc_part_catalog_id.default_process_id
|
||||||
|
|
||||||
return self.x_fc_recipe_id
|
return self.x_fc_recipe_id
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -159,11 +159,10 @@ class MrpWorkorder(models.Model):
|
|||||||
'manager; the Tablet Station shows only WOs assigned to the '
|
'manager; the Tablet Station shows only WOs assigned to the '
|
||||||
'logged-in user.',
|
'logged-in user.',
|
||||||
)
|
)
|
||||||
x_fc_work_role_id = fields.Many2one(
|
# Phase 1 (Sub 11) — fp.work.role relocated to fusion_plating_jobs.
|
||||||
'fp.work.role', string='Role',
|
# bridge_mrp can't depend on jobs (cycle through notifications →
|
||||||
help='Shop role required to perform this step (copied from the '
|
# bridge_mrp), so the legacy WO field is gone. mrp.workorder has 0
|
||||||
'recipe operation on WO generation).',
|
# rows in native mode, so nothing breaks.
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Timer audit — surface the who / when of the timer on the WO header.
|
# Timer audit — surface the who / when of the timer on the WO header.
|
||||||
|
|||||||
@@ -45,17 +45,17 @@ class SaleOrder(models.Model):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
x_fc_workflow_stage = fields.Selection(
|
x_fc_workflow_stage = fields.Selection(
|
||||||
[
|
[
|
||||||
('draft', 'Quotation — awaiting confirmation'),
|
('draft', 'Quote'),
|
||||||
('awaiting_parts', 'Parts en route'),
|
('awaiting_parts', 'Parts'),
|
||||||
('inspecting', 'Inspecting received parts'),
|
('inspecting', 'Inspecting'),
|
||||||
('accept_parts', 'Ready to accept parts'),
|
('accept_parts', 'Accept'),
|
||||||
('assign_work', 'Ready to assign manager'),
|
('assign_work', 'Assign'),
|
||||||
('in_production', 'In production'),
|
('in_production', 'Production'),
|
||||||
('ready_to_ship', 'Production complete — ready to ship'),
|
('ready_to_ship', 'Ready'),
|
||||||
('shipped', 'Shipped — awaiting invoice'),
|
('shipped', 'Shipped'),
|
||||||
('invoicing', 'Awaiting invoice / payment'),
|
('invoicing', 'Invoicing'),
|
||||||
('paid', 'Paid'),
|
('paid', 'Paid'),
|
||||||
('complete', 'Complete'),
|
('complete', 'Done'),
|
||||||
('cancelled', 'Cancelled'),
|
('cancelled', 'Cancelled'),
|
||||||
],
|
],
|
||||||
compute='_compute_workflow_stage',
|
compute='_compute_workflow_stage',
|
||||||
@@ -199,13 +199,30 @@ class SaleOrder(models.Model):
|
|||||||
) % (tag or 'single-line'))
|
) % (tag or 'single-line'))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Recipe: first line's coating -> recipe_id.
|
# Recipe priority (Sub 9):
|
||||||
|
# 1. Line's explicit process variant
|
||||||
|
# 2. Line's part default variant
|
||||||
|
# 3. Line's coating recipe_id
|
||||||
|
# 4. Any recipe-type process node (last-ditch fallback)
|
||||||
recipe = False
|
recipe = False
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
cc = ln.x_fc_coating_config_id
|
if ('x_fc_process_variant_id' in ln._fields
|
||||||
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
|
and ln.x_fc_process_variant_id):
|
||||||
recipe = cc.recipe_id
|
recipe = ln.x_fc_process_variant_id
|
||||||
break
|
break
|
||||||
|
if not recipe:
|
||||||
|
for ln in lines:
|
||||||
|
pc = ln.x_fc_part_catalog_id
|
||||||
|
if (pc and 'default_process_id' in pc._fields
|
||||||
|
and pc.default_process_id):
|
||||||
|
recipe = pc.default_process_id
|
||||||
|
break
|
||||||
|
if not recipe:
|
||||||
|
for ln in lines:
|
||||||
|
cc = ln.x_fc_coating_config_id
|
||||||
|
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
|
||||||
|
recipe = cc.recipe_id
|
||||||
|
break
|
||||||
if not recipe:
|
if not recipe:
|
||||||
recipe = self.env['fusion.plating.process.node'].search(
|
recipe = self.env['fusion.plating.process.node'].search(
|
||||||
[('node_type', '=', 'recipe')], limit=1,
|
[('node_type', '=', 'recipe')], limit=1,
|
||||||
|
|||||||
@@ -5,30 +5,10 @@ access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_worko
|
|||||||
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||||
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||||
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||||
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
|
||||||
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
access_fp_job_node_override_legacy_operator,fusion.plating.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_node_override_legacy_supervisor,fusion.plating.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_job_node_override_legacy_manager,fusion.plating.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
|
||||||
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
|
||||||
access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
|
||||||
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
|
||||||
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
|
||||||
access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
|
||||||
access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
|
||||||
access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
|
||||||
access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
|
||||||
|
|||||||
|
@@ -95,7 +95,6 @@
|
|||||||
string="Assigned To"
|
string="Assigned To"
|
||||||
required="1"
|
required="1"
|
||||||
options="{'no_create': True}"/>
|
options="{'no_create': True}"/>
|
||||||
<field name="x_fc_work_role_id" readonly="1"/>
|
|
||||||
<field name="x_fc_wo_kind" widget="badge" readonly="1"
|
<field name="x_fc_wo_kind" widget="badge" readonly="1"
|
||||||
decoration-info="x_fc_wo_kind == 'wet'"
|
decoration-info="x_fc_wo_kind == 'wet'"
|
||||||
decoration-warning="x_fc_wo_kind == 'bake'"
|
decoration-warning="x_fc_wo_kind == 'bake'"
|
||||||
|
|||||||
@@ -14,11 +14,12 @@
|
|||||||
<field name="model">sale.order</field>
|
<field name="model">sale.order</field>
|
||||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<!-- Manufacturing: right after Transfers (from configurator). -->
|
<!-- Manufacturing: right after Transfers (from configurator).
|
||||||
|
Always visible (no invisible-on-zero) so users have a
|
||||||
|
navigation entry point even when the SO has no MO yet. -->
|
||||||
<xpath expr="//button[@name='action_view_pickings']" position="after">
|
<xpath expr="//button[@name='action_view_pickings']" position="after">
|
||||||
<button name="action_view_productions" type="object"
|
<button name="action_view_productions" type="object"
|
||||||
class="oe_stat_button" icon="fa-industry"
|
class="oe_stat_button" icon="fa-industry">
|
||||||
invisible="x_fc_production_count == 0">
|
|
||||||
<field name="x_fc_production_count" widget="statinfo"
|
<field name="x_fc_production_count" widget="statinfo"
|
||||||
string="Manufacturing"/>
|
string="Manufacturing"/>
|
||||||
</button>
|
</button>
|
||||||
@@ -53,12 +54,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Hide Odoo's default state statusbar — replaced below by
|
||||||
|
the custom plating workflow statusbar that reflects the
|
||||||
|
real lifecycle (awaiting parts → in production → shipped → ...). -->
|
||||||
|
<xpath expr="//header//field[@name='state']" position="attributes">
|
||||||
|
<attribute name="invisible">1</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
<!-- ===== Contextual workflow buttons on the header =====
|
<!-- ===== Contextual workflow buttons on the header =====
|
||||||
One (sometimes two) visible at a time. Pattern mirrors
|
One (sometimes two) visible at a time. Pattern mirrors
|
||||||
fusion_claims ADP handling — invisible bindings key off
|
fusion_claims ADP handling — invisible bindings key off
|
||||||
the computed x_fc_workflow_stage selector. -->
|
the computed x_fc_workflow_stage selector. -->
|
||||||
<xpath expr="//header" position="inside">
|
<xpath expr="//header" position="inside">
|
||||||
<field name="x_fc_workflow_stage" invisible="1"/>
|
<field name="x_fc_workflow_stage" widget="statusbar"
|
||||||
|
statusbar_visible="draft,awaiting_parts,inspecting,in_production,ready_to_ship,shipped,invoicing,complete"/>
|
||||||
<field name="x_fc_assigned_manager_id" invisible="1"/>
|
<field name="x_fc_assigned_manager_id" invisible="1"/>
|
||||||
|
|
||||||
<button name="action_fp_mark_inspected"
|
<button name="action_fp_mark_inspected"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.4.0.0',
|
'version': '19.0.5.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -27,7 +27,6 @@ Includes Fischerscope thickness measurement data capture.
|
|||||||
'fusion_plating_portal',
|
'fusion_plating_portal',
|
||||||
'fusion_plating_batch',
|
'fusion_plating_batch',
|
||||||
'fusion_plating_configurator',
|
'fusion_plating_configurator',
|
||||||
'mrp',
|
|
||||||
'sale_management',
|
'sale_management',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Phase 6 (Sub 11) — drop legacy MRP columns from certificate tables.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
cr.execute("ALTER TABLE fp_certificate DROP COLUMN IF EXISTS production_id")
|
||||||
|
cr.execute("ALTER TABLE fp_thickness_reading DROP COLUMN IF EXISTS production_id")
|
||||||
|
_logger.info("Sub 11: dropped production_id from fp_certificate + fp_thickness_reading")
|
||||||
@@ -35,7 +35,8 @@ class FpCertificate(models.Model):
|
|||||||
domain="[('customer_rank', '>', 0)]",
|
domain="[('customer_rank', '>', 0)]",
|
||||||
)
|
)
|
||||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
|
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
|
||||||
production_id = fields.Many2one('mrp.production', string='Manufacturing Order')
|
# Phase 6 (Sub 11) — production_id retired (MRP module gone).
|
||||||
|
# Certificates link via sale_order_id + portal_job_id natively.
|
||||||
portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job')
|
portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job')
|
||||||
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
|
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
|
||||||
process_description = fields.Char(
|
process_description = fields.Char(
|
||||||
@@ -84,23 +85,32 @@ class FpCertificate(models.Model):
|
|||||||
string='Baths Used',
|
string='Baths Used',
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('production_id')
|
@api.depends('sale_order_id')
|
||||||
def _compute_batch_ids(self):
|
def _compute_batch_ids(self):
|
||||||
|
# Phase 6 (Sub 11) — walks fp.job via SO instead of mrp.production.
|
||||||
Batch = self.env.get('fusion.plating.batch')
|
Batch = self.env.get('fusion.plating.batch')
|
||||||
Bath = self.env['fusion.plating.bath']
|
Bath = self.env['fusion.plating.bath']
|
||||||
|
Job = self.env.get('fp.job')
|
||||||
empty_batch = self.env['fusion.plating.batch']
|
empty_batch = self.env['fusion.plating.batch']
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if Batch is not None and rec.production_id:
|
if Batch is None or Job is None or not rec.sale_order_id:
|
||||||
batches = Batch.search([
|
|
||||||
('production_id', '=', rec.production_id.id),
|
|
||||||
])
|
|
||||||
rec.batch_ids = batches
|
|
||||||
rec.batch_count = len(batches)
|
|
||||||
rec.bath_ids = batches.mapped('bath_id')
|
|
||||||
else:
|
|
||||||
rec.batch_ids = empty_batch
|
rec.batch_ids = empty_batch
|
||||||
rec.batch_count = 0
|
rec.batch_count = 0
|
||||||
rec.bath_ids = Bath
|
rec.bath_ids = Bath
|
||||||
|
continue
|
||||||
|
jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
|
||||||
|
if not jobs:
|
||||||
|
rec.batch_ids = empty_batch
|
||||||
|
rec.batch_count = 0
|
||||||
|
rec.bath_ids = Bath
|
||||||
|
continue
|
||||||
|
if 'x_fc_job_id' in Batch._fields:
|
||||||
|
batches = Batch.search([('x_fc_job_id', 'in', jobs.ids)])
|
||||||
|
else:
|
||||||
|
batches = empty_batch
|
||||||
|
rec.batch_ids = batches
|
||||||
|
rec.batch_count = len(batches)
|
||||||
|
rec.bath_ids = batches.mapped('bath_id')
|
||||||
state = fields.Selection(
|
state = fields.Selection(
|
||||||
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
|
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
|
||||||
string='Status', default='draft', tracking=True, required=True,
|
string='Status', default='draft', tracking=True, required=True,
|
||||||
@@ -289,12 +299,12 @@ class FpCertificate(models.Model):
|
|||||||
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
|
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
|
||||||
'requires actual thickness readings on every CoC '
|
'requires actual thickness readings on every CoC '
|
||||||
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
|
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
|
||||||
'against MO %(mo)s via the Tablet Station before '
|
'against the job for SO %(so)s via the Tablet Station '
|
||||||
'issuing.'
|
'before issuing.'
|
||||||
) % {
|
) % {
|
||||||
'name': rec.name or rec.display_name,
|
'name': rec.name or rec.display_name,
|
||||||
'cust': rec.partner_id.name,
|
'cust': rec.partner_id.name,
|
||||||
'mo': rec.production_id.name if rec.production_id else '?',
|
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||||
})
|
})
|
||||||
rec.state = 'issued'
|
rec.state = 'issued'
|
||||||
rec.message_post(body=_('Certificate issued.'))
|
rec.message_post(body=_('Certificate issued.'))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpThicknessReading(models.Model):
|
class FpThicknessReading(models.Model):
|
||||||
@@ -20,9 +20,8 @@ class FpThicknessReading(models.Model):
|
|||||||
certificate_id = fields.Many2one(
|
certificate_id = fields.Many2one(
|
||||||
'fp.certificate', string='Certificate', ondelete='cascade',
|
'fp.certificate', string='Certificate', ondelete='cascade',
|
||||||
)
|
)
|
||||||
production_id = fields.Many2one(
|
# Phase 6 (Sub 11) — production_id retired (MRP module gone).
|
||||||
'mrp.production', string='Manufacturing Order',
|
# Thickness readings link via certificate_id and quality_check_id.
|
||||||
)
|
|
||||||
reading_number = fields.Integer(
|
reading_number = fields.Integer(
|
||||||
string='Reading #', default=1, help='Sequence number (n=1, n=2, n=3).',
|
string='Reading #', default=1, help='Sequence number (n=1, n=2, n=3).',
|
||||||
)
|
)
|
||||||
@@ -65,3 +64,14 @@ class FpThicknessReading(models.Model):
|
|||||||
measuring_time_seconds = fields.Integer(
|
measuring_time_seconds = fields.Integer(
|
||||||
string='Measuring Time (sec)', default=120,
|
string='Measuring Time (sec)', default=120,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends('reading_number', 'nip_mils', 'certificate_id')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
ctx = rec.certificate_id.display_name or ''
|
||||||
|
label = 'Reading #%d' % (rec.reading_number or 0)
|
||||||
|
if rec.nip_mils:
|
||||||
|
label = '%s (%.4f mils)' % (label, rec.nip_mils)
|
||||||
|
if ctx:
|
||||||
|
label = '%s — %s' % (label, ctx)
|
||||||
|
rec.display_name = label
|
||||||
|
|||||||
@@ -72,7 +72,6 @@
|
|||||||
<field name="certificate_type"/>
|
<field name="certificate_type"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="sale_order_id"/>
|
<field name="sale_order_id"/>
|
||||||
<field name="production_id"/>
|
|
||||||
<field name="portal_job_id"/>
|
<field name="portal_job_id"/>
|
||||||
<field name="issue_date"/>
|
<field name="issue_date"/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.14.2.0',
|
'version': '19.0.17.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -49,12 +49,13 @@ Provides:
|
|||||||
'views/fp_quote_configurator_views.xml',
|
'views/fp_quote_configurator_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
'views/fp_configurator_menu.xml',
|
|
||||||
'views/fp_sale_description_template_views.xml',
|
'views/fp_sale_description_template_views.xml',
|
||||||
'wizard/fp_direct_order_wizard_views.xml',
|
'wizard/fp_direct_order_wizard_views.xml',
|
||||||
'wizard/fp_add_from_so_wizard_views.xml',
|
'wizard/fp_add_from_so_wizard_views.xml',
|
||||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||||
|
'wizard/fp_quote_promote_wizard_views.xml',
|
||||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||||
|
'views/fp_configurator_menu.xml',
|
||||||
'data/fp_sale_description_template_data.xml',
|
'data/fp_sale_description_template_data.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ _CLONABLE_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_variants(part):
|
||||||
|
"""Return a list of {id, label, is_default, node_count} for a part's variants."""
|
||||||
|
Node = part.env['fusion.plating.process.node']
|
||||||
|
variants = part.process_variant_ids.sorted(
|
||||||
|
lambda v: (not v.is_default_variant, v.variant_label or v.name or '')
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for v in variants:
|
||||||
|
node_count = Node.search_count([
|
||||||
|
('part_catalog_id', '=', part.id),
|
||||||
|
('id', 'child_of', v.id),
|
||||||
|
])
|
||||||
|
out.append({
|
||||||
|
'id': v.id,
|
||||||
|
'label': v.variant_label or v.name or '(unnamed)',
|
||||||
|
'name': v.name or '',
|
||||||
|
'is_default': bool(v.is_default_variant),
|
||||||
|
'node_count': node_count,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _clone_subtree(env, source, part, parent):
|
def _clone_subtree(env, source, part, parent):
|
||||||
"""Recursively clone a process node subtree for a specific part.
|
"""Recursively clone a process node subtree for a specific part.
|
||||||
|
|
||||||
@@ -117,7 +139,7 @@ class FpPartComposerController(http.Controller):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/part/composer/state', type='jsonrpc', auth='user')
|
@http.route('/fp/part/composer/state', type='jsonrpc', auth='user')
|
||||||
def state(self, part_id):
|
def state(self, part_id):
|
||||||
"""Return part info plus the current default_process_id tree (or None)."""
|
"""Return part info, current default tree, and full variant list."""
|
||||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||||
if not part:
|
if not part:
|
||||||
return {'ok': False, 'error': 'Part not found'}
|
return {'ok': False, 'error': 'Part not found'}
|
||||||
@@ -134,6 +156,7 @@ class FpPartComposerController(http.Controller):
|
|||||||
},
|
},
|
||||||
'has_tree': bool(root),
|
'has_tree': bool(root),
|
||||||
'root_id': root.id if root else False,
|
'root_id': root.id if root else False,
|
||||||
|
'variants': _list_variants(part),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -157,18 +180,20 @@ class FpPartComposerController(http.Controller):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Write — clone a template into the part
|
# Write — create a new variant by cloning a template OR another variant
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/part/composer/load_template', type='jsonrpc', auth='user')
|
@http.route('/fp/part/composer/load_template', type='jsonrpc', auth='user')
|
||||||
def load_template(self, part_id, template_id):
|
def load_template(self, part_id, template_id, variant_label=None,
|
||||||
"""Clone a shared template into a part-scoped tree.
|
make_default=None):
|
||||||
|
"""Clone a shared template into a NEW variant on this part.
|
||||||
|
|
||||||
Deletes any existing part-owned tree for this part first, then
|
Unlike the previous behaviour (wipe & replace), this now adds a
|
||||||
deep-clones the template subtree with part ownership set. Finally
|
variant alongside any existing ones. The first variant created
|
||||||
pins ``part.default_process_id`` to the new root.
|
becomes the default; subsequent variants only become default if
|
||||||
|
``make_default`` is true.
|
||||||
|
|
||||||
The whole operation runs inside a savepoint — if anything fails
|
If ``variant_label`` is omitted, the controller uses the
|
||||||
partway through, the part is left in its previous state.
|
template's name as the label.
|
||||||
"""
|
"""
|
||||||
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||||
tpl = request.env['fusion.plating.process.node'].browse(int(template_id)).exists()
|
tpl = request.env['fusion.plating.process.node'].browse(int(template_id)).exists()
|
||||||
@@ -181,38 +206,119 @@ class FpPartComposerController(http.Controller):
|
|||||||
if tpl.node_type != 'recipe':
|
if tpl.node_type != 'recipe':
|
||||||
return {'ok': False, 'error': 'Template must be a recipe-type node'}
|
return {'ok': False, 'error': 'Template must be a recipe-type node'}
|
||||||
|
|
||||||
|
label = (variant_label or tpl.name or 'Variant').strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with request.env.cr.savepoint():
|
with request.env.cr.savepoint():
|
||||||
# 1. Delete any prior part-owned tree for this part.
|
# First variant on this part is always the default.
|
||||||
# parent_id has ondelete='cascade', so deleting root(s)
|
is_first = not part.process_variant_ids
|
||||||
# wipes their descendants. Use search so we don't assume
|
make_default_flag = bool(make_default) or is_first
|
||||||
# only default_process_id's tree exists.
|
|
||||||
prior = request.env['fusion.plating.process.node'].search([
|
|
||||||
('part_catalog_id', '=', part.id),
|
|
||||||
])
|
|
||||||
if prior:
|
|
||||||
prior.unlink()
|
|
||||||
|
|
||||||
# 2. Deep-clone the template subtree with part ownership.
|
|
||||||
new_root = _clone_subtree(request.env, tpl, part, parent=False)
|
new_root = _clone_subtree(request.env, tpl, part, parent=False)
|
||||||
|
new_root.variant_label = label
|
||||||
|
new_root.is_default_variant = make_default_flag
|
||||||
|
|
||||||
# 3. Pin part.default_process_id to the new root.
|
if make_default_flag:
|
||||||
part.default_process_id = new_root.id
|
# Clear flag from any other variants and pin default_process_id.
|
||||||
|
others = part.process_variant_ids.filtered(
|
||||||
|
lambda v: v.id != new_root.id and v.is_default_variant
|
||||||
|
)
|
||||||
|
if others:
|
||||||
|
others.write({'is_default_variant': False})
|
||||||
|
part.default_process_id = new_root.id
|
||||||
|
|
||||||
node_count = request.env['fusion.plating.process.node'].search_count([
|
node_count = request.env['fusion.plating.process.node'].search_count([
|
||||||
('part_catalog_id', '=', part.id),
|
('id', 'child_of', new_root.id),
|
||||||
])
|
])
|
||||||
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
'Part Composer: cloned template %s (%s) → part %s (%s), %s nodes, by uid %s',
|
'Part Composer: variant "%s" cloned from template %s onto part %s (default=%s, %s nodes), uid %s',
|
||||||
tpl.id, tpl.name, part.id, part.display_name,
|
label, tpl.id, part.id, make_default_flag, node_count, request.env.uid,
|
||||||
node_count, request.env.uid,
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'root_id': new_root.id,
|
'root_id': new_root.id,
|
||||||
'node_count': node_count,
|
'node_count': node_count,
|
||||||
|
'variants': _list_variants(part),
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.exception('Part Composer load_template failed')
|
_logger.exception('Part Composer load_template failed')
|
||||||
return {'ok': False, 'error': str(exc)}
|
return {'ok': False, 'error': str(exc)}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Variant CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/fp/part/composer/duplicate_variant', type='jsonrpc', auth='user')
|
||||||
|
def duplicate_variant(self, part_id, source_variant_id, variant_label=None):
|
||||||
|
"""Deep-copy an existing variant into a new variant on the same part."""
|
||||||
|
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||||
|
src = request.env['fusion.plating.process.node'].browse(int(source_variant_id)).exists()
|
||||||
|
if not part:
|
||||||
|
return {'ok': False, 'error': 'Part not found'}
|
||||||
|
if not src or src.part_catalog_id.id != part.id or src.parent_id:
|
||||||
|
return {'ok': False, 'error': 'Invalid source variant'}
|
||||||
|
|
||||||
|
label = (variant_label or ((src.variant_label or src.name or 'Variant') + ' (copy)')).strip()
|
||||||
|
try:
|
||||||
|
with request.env.cr.savepoint():
|
||||||
|
new_root = _clone_subtree(request.env, src, part, parent=False)
|
||||||
|
new_root.variant_label = label
|
||||||
|
new_root.is_default_variant = False # never auto-default a duplicate
|
||||||
|
node_count = request.env['fusion.plating.process.node'].search_count([
|
||||||
|
('id', 'child_of', new_root.id),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'root_id': new_root.id,
|
||||||
|
'node_count': node_count,
|
||||||
|
'variants': _list_variants(part),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('Part Composer duplicate_variant failed')
|
||||||
|
return {'ok': False, 'error': str(exc)}
|
||||||
|
|
||||||
|
@http.route('/fp/part/composer/rename_variant', type='jsonrpc', auth='user')
|
||||||
|
def rename_variant(self, part_id, variant_id, variant_label):
|
||||||
|
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||||
|
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
|
||||||
|
if not part:
|
||||||
|
return {'ok': False, 'error': 'Part not found'}
|
||||||
|
if not v or v.part_catalog_id.id != part.id or v.parent_id:
|
||||||
|
return {'ok': False, 'error': 'Invalid variant'}
|
||||||
|
label = (variant_label or '').strip()
|
||||||
|
if not label:
|
||||||
|
return {'ok': False, 'error': 'Label cannot be empty'}
|
||||||
|
v.variant_label = label
|
||||||
|
return {'ok': True, 'variants': _list_variants(part)}
|
||||||
|
|
||||||
|
@http.route('/fp/part/composer/set_default_variant', type='jsonrpc', auth='user')
|
||||||
|
def set_default_variant(self, part_id, variant_id):
|
||||||
|
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||||
|
if not part:
|
||||||
|
return {'ok': False, 'error': 'Part not found'}
|
||||||
|
ok = part.action_set_default_variant(int(variant_id))
|
||||||
|
if not ok:
|
||||||
|
return {'ok': False, 'error': 'Variant does not belong to this part'}
|
||||||
|
return {'ok': True, 'variants': _list_variants(part)}
|
||||||
|
|
||||||
|
@http.route('/fp/part/composer/delete_variant', type='jsonrpc', auth='user')
|
||||||
|
def delete_variant(self, part_id, variant_id):
|
||||||
|
part = request.env['fp.part.catalog'].browse(int(part_id)).exists()
|
||||||
|
v = request.env['fusion.plating.process.node'].browse(int(variant_id)).exists()
|
||||||
|
if not part:
|
||||||
|
return {'ok': False, 'error': 'Part not found'}
|
||||||
|
if not v or v.part_catalog_id.id != part.id or v.parent_id:
|
||||||
|
return {'ok': False, 'error': 'Invalid variant'}
|
||||||
|
if v.is_default_variant and len(part.process_variant_ids) > 1:
|
||||||
|
return {'ok': False,
|
||||||
|
'error': 'Cannot delete the default variant. Set another variant as default first.'}
|
||||||
|
try:
|
||||||
|
with request.env.cr.savepoint():
|
||||||
|
if part.default_process_id.id == v.id:
|
||||||
|
part.default_process_id = False
|
||||||
|
# ondelete=cascade on parent_id wipes descendants.
|
||||||
|
v.unlink()
|
||||||
|
return {'ok': True, 'variants': _list_variants(part)}
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('Part Composer delete_variant failed')
|
||||||
|
return {'ok': False, 'error': str(exc)}
|
||||||
|
|||||||
@@ -14,4 +14,12 @@
|
|||||||
<field name="company_id" eval="False"/>
|
<field name="company_id" eval="False"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="seq_fp_direct_order_wizard" model="ir.sequence">
|
||||||
|
<field name="name">Fusion Plating: Direct Order Draft</field>
|
||||||
|
<field name="code">fp.direct.order.wizard</field>
|
||||||
|
<field name="prefix">DOD-</field>
|
||||||
|
<field name="padding">5</field>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
# Sub 9 — Process Variants per Part. Runs on upgrade to 19.0.15.0.0.
|
||||||
|
#
|
||||||
|
# For every part that had a default_process_id, mark its root node as
|
||||||
|
# the default variant and seed a friendly label. Idempotent (NULL guards).
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return # Fresh install — nothing to migrate
|
||||||
|
|
||||||
|
_logger.info("Sub 9: backfilling process variant flags")
|
||||||
|
|
||||||
|
# Step 1: Mark each part's existing default_process_id root as the
|
||||||
|
# default variant. The flag is cleared on every other root so we
|
||||||
|
# land in a consistent "exactly one default" state.
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_plating_process_node
|
||||||
|
SET is_default_variant = FALSE
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
AND node_type = 'recipe'
|
||||||
|
AND part_catalog_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
_logger.info("Sub 9: cleared is_default_variant on %d roots", cr.rowcount)
|
||||||
|
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_plating_process_node node
|
||||||
|
SET is_default_variant = TRUE
|
||||||
|
FROM fp_part_catalog part
|
||||||
|
WHERE part.default_process_id = node.id
|
||||||
|
AND node.parent_id IS NULL
|
||||||
|
AND node.node_type = 'recipe'
|
||||||
|
AND node.part_catalog_id = part.id
|
||||||
|
""")
|
||||||
|
_logger.info(
|
||||||
|
"Sub 9: flagged is_default_variant on %d roots (one per part with default_process_id)",
|
||||||
|
cr.rowcount,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Seed variant_label='Default' on the now-flagged variants
|
||||||
|
# so the picker shows something readable. Only fills NULL/empty.
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_plating_process_node
|
||||||
|
SET variant_label = 'Default'
|
||||||
|
WHERE is_default_variant = TRUE
|
||||||
|
AND (variant_label IS NULL OR variant_label = '')
|
||||||
|
""")
|
||||||
|
_logger.info("Sub 9: seeded variant_label='Default' on %d records", cr.rowcount)
|
||||||
|
|
||||||
|
_logger.info("Sub 9: migration complete")
|
||||||
@@ -154,14 +154,36 @@ class FpPartCatalog(models.Model):
|
|||||||
# Sub 3 — part's cloned process tree. NULL until the user first
|
# Sub 3 — part's cloned process tree. NULL until the user first
|
||||||
# composes a process. The Composer client action sets this to the
|
# composes a process. The Composer client action sets this to the
|
||||||
# root node of the cloned tree.
|
# root node of the cloned tree.
|
||||||
|
#
|
||||||
|
# Sub 9 — multiple variants per part. `default_process_id` now points
|
||||||
|
# to "the variant flagged is_default_variant". `process_variant_ids`
|
||||||
|
# is the full set; estimators pick one per order line.
|
||||||
default_process_id = fields.Many2one(
|
default_process_id = fields.Many2one(
|
||||||
'fusion.plating.process.node',
|
'fusion.plating.process.node',
|
||||||
string='Default Process',
|
string='Default Process',
|
||||||
domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe')]",
|
domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe'), "
|
||||||
help='Root of this part\'s composed process tree. Use the '
|
"('parent_id', '=', False)]",
|
||||||
'Compose button to edit. When a job runs for this part, '
|
help='Root of this part\'s default process variant. Use the '
|
||||||
'work orders are generated from this tree.',
|
'Compose button to edit. When an order does not pick a '
|
||||||
|
'specific variant, this one is used.',
|
||||||
)
|
)
|
||||||
|
process_variant_ids = fields.One2many(
|
||||||
|
'fusion.plating.process.node',
|
||||||
|
'part_catalog_id',
|
||||||
|
string='Process Variants',
|
||||||
|
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||||
|
help='All recipe variants composed for this part. Each order line '
|
||||||
|
'picks one (or falls back to the default).',
|
||||||
|
)
|
||||||
|
process_variant_count = fields.Integer(
|
||||||
|
string='Variants',
|
||||||
|
compute='_compute_process_variant_count',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('process_variant_ids')
|
||||||
|
def _compute_process_variant_count(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.process_variant_count = len(rec.process_variant_ids)
|
||||||
|
|
||||||
# ---- Direct-order defaults (Phase C — C4) ----
|
# ---- Direct-order defaults (Phase C — C4) ----
|
||||||
x_fc_default_coating_config_id = fields.Many2one(
|
x_fc_default_coating_config_id = fields.Many2one(
|
||||||
@@ -404,6 +426,28 @@ class FpPartCatalog(models.Model):
|
|||||||
'target': 'current',
|
'target': 'current',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def action_set_default_variant(self, variant_id):
|
||||||
|
"""Flip the default variant for this part.
|
||||||
|
|
||||||
|
Clears the flag from any other variant and pins
|
||||||
|
`default_process_id` to the chosen one. Called by the Composer
|
||||||
|
when the estimator switches default in the variant picker.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
Node = self.env['fusion.plating.process.node']
|
||||||
|
new_default = Node.browse(int(variant_id)).exists()
|
||||||
|
if not new_default or new_default.part_catalog_id.id != self.id:
|
||||||
|
return False
|
||||||
|
# Clear flag on any other variant; set on the new one.
|
||||||
|
siblings = self.process_variant_ids.filtered(
|
||||||
|
lambda v: v.id != new_default.id and v.is_default_variant
|
||||||
|
)
|
||||||
|
if siblings:
|
||||||
|
siblings.write({'is_default_variant': False})
|
||||||
|
new_default.is_default_variant = True
|
||||||
|
self.default_process_id = new_default.id
|
||||||
|
return True
|
||||||
|
|
||||||
def action_view_customer(self):
|
def action_view_customer(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpPricingComplexitySurcharge(models.Model):
|
class FpPricingComplexitySurcharge(models.Model):
|
||||||
@@ -19,6 +19,15 @@ class FpPricingComplexitySurcharge(models.Model):
|
|||||||
)
|
)
|
||||||
surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.')
|
surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.')
|
||||||
|
|
||||||
|
@api.depends('complexity', 'surcharge_percent')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
labels = dict(self._fields['complexity'].selection)
|
||||||
|
for rec in self:
|
||||||
|
label = labels.get(rec.complexity, rec.complexity or '')
|
||||||
|
if rec.surcharge_percent:
|
||||||
|
label = '%s +%g%%' % (label, rec.surcharge_percent)
|
||||||
|
rec.display_name = label or 'Surcharge'
|
||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
|
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
|
||||||
'Only one surcharge per complexity level per rule.'),
|
'Only one surcharge per complexity level per rule.'),
|
||||||
|
|||||||
@@ -52,3 +52,24 @@ class FpProcessNode(models.Model):
|
|||||||
'Picks which physical property of the part to multiply by '
|
'Picks which physical property of the part to multiply by '
|
||||||
'the per-unit rate: weight (Lbs) or surface area (Sq in).',
|
'the per-unit rate: weight (Lbs) or surface area (Sq in).',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- Process Variants (per-part) ----------------------------------------
|
||||||
|
# A part can carry multiple recipe-root trees ("variants"). Examples:
|
||||||
|
# "Standard ENP", "Selective Masking", "Rework". Each order line picks a
|
||||||
|
# variant; the MO walker resolves through it. One variant per part is the
|
||||||
|
# default — used when the order line doesn't pick one explicitly.
|
||||||
|
#
|
||||||
|
# Variant identification only applies to root nodes (parent_id IS NULL,
|
||||||
|
# node_type='recipe') with a part_catalog_id set. Non-root nodes carry
|
||||||
|
# these fields too because they sit on the same model, but they're only
|
||||||
|
# meaningful on roots.
|
||||||
|
is_default_variant = fields.Boolean(
|
||||||
|
string='Default Variant',
|
||||||
|
help='When ticked, this variant is used by default for new orders '
|
||||||
|
'of this part. Exactly one variant per part is the default.',
|
||||||
|
)
|
||||||
|
variant_label = fields.Char(
|
||||||
|
string='Variant Label',
|
||||||
|
help='Friendly label shown in the variant picker '
|
||||||
|
'(e.g. "Standard ENP", "Selective Masking", "Rework").',
|
||||||
|
)
|
||||||
|
|||||||
@@ -510,8 +510,53 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'fp.quote.configurator') or 'New'
|
'fp.quote.configurator') or 'New'
|
||||||
return super().create(vals_list)
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def action_promote_to_direct_order(self):
|
||||||
|
"""Sub 10 — push this quote onto a Direct Order draft.
|
||||||
|
|
||||||
|
Replaces the legacy 1-line-SO creation. The estimator picks an
|
||||||
|
existing draft for the customer (consolidating multiple quotes
|
||||||
|
onto one PO) or spawns a fresh draft. The quote stays in
|
||||||
|
`draft` state until the Direct Order is confirmed; that confirm
|
||||||
|
flips the quote to `won` and back-links the SO.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'draft':
|
||||||
|
raise UserError(_('Only draft quotes can be promoted.'))
|
||||||
|
if self.sale_order_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'A sale order has already been created for this quote.'
|
||||||
|
))
|
||||||
|
if not self.part_catalog_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Pick a part catalog entry before promoting this quote.'
|
||||||
|
))
|
||||||
|
if not self.coating_config_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Pick a coating configuration before promoting this quote.'
|
||||||
|
))
|
||||||
|
existing_line = self.env['fp.direct.order.line'].search([
|
||||||
|
('quote_id', '=', self.id),
|
||||||
|
('wizard_id.state', '=', 'draft'),
|
||||||
|
], limit=1)
|
||||||
|
if existing_line:
|
||||||
|
raise UserError(_(
|
||||||
|
'This quote is already on draft "%s". Open that draft '
|
||||||
|
'and remove its line if you want to move it elsewhere.'
|
||||||
|
) % existing_line.wizard_id.name)
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Add Quote to Direct Order'),
|
||||||
|
'res_model': 'fp.quote.promote.wizard',
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': {'default_quote_id': self.id},
|
||||||
|
}
|
||||||
|
|
||||||
def action_create_quotation(self):
|
def action_create_quotation(self):
|
||||||
"""Create a sale.order from this configurator session."""
|
"""LEGACY (Sub 10): kept for backwards-compat with any in-flight
|
||||||
|
records or external triggers. New flow is via
|
||||||
|
action_promote_to_direct_order.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.state != 'draft':
|
if self.state != 'draft':
|
||||||
raise UserError(_('Only draft configurators can create quotations.'))
|
raise UserError(_('Only draft configurators can create quotations.'))
|
||||||
|
|||||||
@@ -139,11 +139,45 @@ class SaleOrder(models.Model):
|
|||||||
'margin fields should render "n/a" in the UI.',
|
'margin fields should render "n/a" in the UI.',
|
||||||
)
|
)
|
||||||
|
|
||||||
x_fc_workorder_count = fields.Integer(
|
# NB. The compute lives in fusion_plating_bridge_mrp. We keep a
|
||||||
string='Active WOs',
|
# stub field here so configurator's SO view (loaded before
|
||||||
compute='_compute_workorder_count',
|
# bridge_mrp on `-u`) can reference the field by name. bridge_mrp's
|
||||||
|
# `fields.Integer(compute=…)` redeclaration fills in the compute on
|
||||||
|
# top of this stub during its own load pass.
|
||||||
|
x_fc_workorder_count = fields.Integer(string='Work Orders')
|
||||||
|
|
||||||
|
# Sub 9 — process variant summary across order lines. Renders one
|
||||||
|
# variant label when all lines share one, otherwise "Mixed (N)".
|
||||||
|
x_fc_process_summary = fields.Char(
|
||||||
|
string='Process',
|
||||||
|
compute='_compute_process_summary',
|
||||||
|
help='Process variant(s) used by this order. Drives WO generation.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'order_line.x_fc_process_variant_id',
|
||||||
|
'order_line.x_fc_part_catalog_id.default_process_id',
|
||||||
|
)
|
||||||
|
def _compute_process_summary(self):
|
||||||
|
for so in self:
|
||||||
|
variants = []
|
||||||
|
for line in so.order_line:
|
||||||
|
if not (line.x_fc_part_catalog_id or line.x_fc_coating_config_id):
|
||||||
|
continue # non-plating line
|
||||||
|
variant = (line.x_fc_process_variant_id
|
||||||
|
or line.x_fc_part_catalog_id.default_process_id)
|
||||||
|
if variant and variant not in variants:
|
||||||
|
variants.append(variant)
|
||||||
|
if not variants:
|
||||||
|
so.x_fc_process_summary = False
|
||||||
|
elif len(variants) == 1:
|
||||||
|
v = variants[0]
|
||||||
|
so.x_fc_process_summary = (
|
||||||
|
v.variant_label or v.name or 'Default'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
so.x_fc_process_summary = 'Mixed (%d variants)' % len(variants)
|
||||||
|
|
||||||
# ---- Phase E: list view helpers ----
|
# ---- Phase E: list view helpers ----
|
||||||
x_fc_wo_completion = fields.Char(
|
x_fc_wo_completion = fields.Char(
|
||||||
string='WO Progress',
|
string='WO Progress',
|
||||||
@@ -307,34 +341,6 @@ class SaleOrder(models.Model):
|
|||||||
- sum(refunds.mapped('amount_total'))
|
- sum(refunds.mapped('amount_total'))
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('name')
|
|
||||||
def _compute_workorder_count(self):
|
|
||||||
for rec in self:
|
|
||||||
rec.x_fc_workorder_count = 0
|
|
||||||
names = [so.name for so in self if so.name]
|
|
||||||
if not names:
|
|
||||||
return
|
|
||||||
WO = self.env['mrp.workorder'].sudo()
|
|
||||||
rows = WO.read_group(
|
|
||||||
[('production_id.origin', 'in', names),
|
|
||||||
('state', 'not in', ('done', 'cancel'))],
|
|
||||||
['production_id'],
|
|
||||||
['production_id'],
|
|
||||||
lazy=False,
|
|
||||||
)
|
|
||||||
mos = self.env['mrp.production'].sudo().search(
|
|
||||||
[('origin', 'in', names)]
|
|
||||||
)
|
|
||||||
mo_to_origin = {m.id: m.origin for m in mos}
|
|
||||||
totals = {}
|
|
||||||
for r in rows:
|
|
||||||
mo_id = r['production_id'][0] if r['production_id'] else False
|
|
||||||
origin = mo_to_origin.get(mo_id)
|
|
||||||
if origin:
|
|
||||||
totals[origin] = totals.get(origin, 0) + r['__count']
|
|
||||||
for rec in self:
|
|
||||||
rec.x_fc_workorder_count = totals.get(rec.name, 0)
|
|
||||||
|
|
||||||
def action_view_workorders(self):
|
def action_view_workorders(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ class SaleOrderLine(models.Model):
|
|||||||
string='Linked Quote',
|
string='Linked Quote',
|
||||||
help='Quote that seeded this line. Links back for audit trail.',
|
help='Quote that seeded this line. Links back for audit trail.',
|
||||||
)
|
)
|
||||||
|
# Sub 9 — process variant override per line. NULL means "use the
|
||||||
|
# part's default variant". Domain restricts to root recipe nodes
|
||||||
|
# owned by the chosen part.
|
||||||
|
x_fc_process_variant_id = fields.Many2one(
|
||||||
|
'fusion.plating.process.node',
|
||||||
|
string='Process Variant',
|
||||||
|
domain="[('part_catalog_id', '=', x_fc_part_catalog_id), "
|
||||||
|
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||||
|
ondelete='set null',
|
||||||
|
help='Pick a specific process variant for this order. Leave blank '
|
||||||
|
'to use the part\'s default variant. Variants are managed via '
|
||||||
|
'the Process Composer on the part form.',
|
||||||
|
)
|
||||||
x_fc_archived = fields.Boolean(
|
x_fc_archived = fields.Boolean(
|
||||||
string='Archived',
|
string='Archived',
|
||||||
default=False,
|
default=False,
|
||||||
@@ -226,6 +239,16 @@ class SaleOrderLine(models.Model):
|
|||||||
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
|
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
|
||||||
return vals
|
return vals
|
||||||
|
|
||||||
|
@api.onchange('x_fc_part_catalog_id')
|
||||||
|
def _onchange_part_default_variant(self):
|
||||||
|
"""Clear process variant when the part changes — domain would
|
||||||
|
otherwise leave a stale value pointing at the wrong part."""
|
||||||
|
for line in self:
|
||||||
|
if (line.x_fc_process_variant_id
|
||||||
|
and line.x_fc_process_variant_id.part_catalog_id
|
||||||
|
!= line.x_fc_part_catalog_id):
|
||||||
|
line.x_fc_process_variant_id = False
|
||||||
|
|
||||||
@api.onchange('x_fc_coating_config_id')
|
@api.onchange('x_fc_coating_config_id')
|
||||||
def _onchange_coating_clears_thickness(self):
|
def _onchange_coating_clears_thickness(self):
|
||||||
"""Clear the thickness picker when coating config changes.
|
"""Clear the thickness picker when coating config changes.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ access_fp_add_from_so_wizard_estimator,fp.add.from.so.wizard.estimator,model_fp_
|
|||||||
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_add_from_so_wizard_manager,fp.add.from.so.wizard.manager,model_fp_add_from_so_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
access_fp_add_from_quote_wizard_estimator,fp.add.from.quote.wizard.estimator,model_fp_add_from_quote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||||
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_add_from_quote_wizard_manager,fp.add.from.quote.wizard.manager,model_fp_add_from_quote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_quote_promote_wizard_estimator,fp.quote.promote.wizard.estimator,model_fp_quote_promote_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||||
|
access_fp_quote_promote_wizard_manager,fp.quote.promote.wizard.manager,model_fp_quote_promote_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0
|
access_fp_sale_assembly_user,fp.sale.assembly.user,model_fp_sale_assembly,base.group_user,1,0,0,0
|
||||||
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
access_fp_sale_assembly_estimator,fp.sale.assembly.estimator,model_fp_sale_assembly,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||||
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_sale_assembly_manager,fp.sale.assembly.manager,model_fp_sale_assembly,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -4,9 +4,9 @@
|
|||||||
// Copyright 2026 Nexa Systems Inc.
|
// Copyright 2026 Nexa Systems Inc.
|
||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
//
|
//
|
||||||
// Thin wrapper around the existing recipe tree editor. Gives a part
|
// Sub 9 — multi-variant Composer. Each part can carry several recipe trees
|
||||||
// its own composed process tree by cloning a shared template, then
|
// (e.g. "Standard ENP", "Selective Masking", "Rework"). One is the default;
|
||||||
// hands off to the fp_recipe_tree_editor action for edits.
|
// estimators may pick a non-default variant on a per-order basis.
|
||||||
//
|
//
|
||||||
// Odoo 19 conventions:
|
// Odoo 19 conventions:
|
||||||
// * Backend OWL: static template + static props = ["*"]
|
// * Backend OWL: static template + static props = ["*"]
|
||||||
@@ -27,8 +27,6 @@ export class FpPartProcessComposer extends Component {
|
|||||||
this.action = useService("action");
|
this.action = useService("action");
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
|
||||||
// Pull part_id out of the client action's params (set by
|
|
||||||
// fp.part.catalog.action_open_part_composer on the server).
|
|
||||||
const params = (this.props.action && this.props.action.params) || {};
|
const params = (this.props.action && this.props.action.params) || {};
|
||||||
this.partId = params.part_id || null;
|
this.partId = params.part_id || null;
|
||||||
|
|
||||||
@@ -38,9 +36,11 @@ export class FpPartProcessComposer extends Component {
|
|||||||
part: null,
|
part: null,
|
||||||
hasTree: false,
|
hasTree: false,
|
||||||
rootId: null,
|
rootId: null,
|
||||||
|
variants: [],
|
||||||
templates: [],
|
templates: [],
|
||||||
selectedTemplateId: null,
|
selectedTemplateId: null,
|
||||||
loadingTemplate: false,
|
newVariantLabel: "",
|
||||||
|
busy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => this.refresh());
|
onMounted(() => this.refresh());
|
||||||
@@ -67,10 +67,9 @@ export class FpPartProcessComposer extends Component {
|
|||||||
this.state.part = stateRes.part;
|
this.state.part = stateRes.part;
|
||||||
this.state.hasTree = stateRes.has_tree;
|
this.state.hasTree = stateRes.has_tree;
|
||||||
this.state.rootId = stateRes.root_id || null;
|
this.state.rootId = stateRes.root_id || null;
|
||||||
|
this.state.variants = stateRes.variants || [];
|
||||||
this.state.templates = tplRes.templates || [];
|
this.state.templates = tplRes.templates || [];
|
||||||
|
|
||||||
// Default the dropdown selection to the first template so the
|
|
||||||
// user can click Load immediately.
|
|
||||||
if (this.state.templates.length > 0 && !this.state.selectedTemplateId) {
|
if (this.state.templates.length > 0 && !this.state.selectedTemplateId) {
|
||||||
this.state.selectedTemplateId = this.state.templates[0].id;
|
this.state.selectedTemplateId = this.state.templates[0].id;
|
||||||
}
|
}
|
||||||
@@ -87,46 +86,110 @@ export class FpPartProcessComposer extends Component {
|
|||||||
this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null;
|
this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onLoadTemplate() {
|
onNewLabelInput(ev) {
|
||||||
if (!this.state.selectedTemplateId) return;
|
this.state.newVariantLabel = ev.target.value || "";
|
||||||
const confirmReplace = this.state.hasTree
|
}
|
||||||
? window.confirm("This will replace the current process tree for this part. Continue?")
|
|
||||||
: true;
|
|
||||||
if (!confirmReplace) return;
|
|
||||||
|
|
||||||
this.state.loadingTemplate = true;
|
async onAddVariantFromTemplate() {
|
||||||
try {
|
if (!this.state.selectedTemplateId) {
|
||||||
|
this.notification.add("Pick a template first.", { type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = (this.state.newVariantLabel || "").trim()
|
||||||
|
|| (this.state.templates.find(t => t.id === this.state.selectedTemplateId)?.name)
|
||||||
|
|| "Variant";
|
||||||
|
await this._busy(async () => {
|
||||||
const res = await rpc("/fp/part/composer/load_template", {
|
const res = await rpc("/fp/part/composer/load_template", {
|
||||||
part_id: this.partId,
|
part_id: this.partId,
|
||||||
template_id: this.state.selectedTemplateId,
|
template_id: this.state.selectedTemplateId,
|
||||||
|
variant_label: label,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(res.error || "Load failed.");
|
if (!res.ok) throw new Error(res.error || "Add variant failed.");
|
||||||
this.notification.add(
|
this.notification.add(
|
||||||
`Template loaded — ${res.node_count} nodes cloned into this part's tree.`,
|
`Variant "${label}" added (${res.node_count} nodes).`,
|
||||||
{ type: "success" }
|
{ type: "success" },
|
||||||
);
|
);
|
||||||
|
this.state.newVariantLabel = "";
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
// Hand off directly to the tree editor so the user can
|
|
||||||
// immediately start customising.
|
|
||||||
this.openRecipeEditor(res.root_id);
|
this.openRecipeEditor(res.root_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDuplicateVariant(variantId) {
|
||||||
|
const src = this.state.variants.find(v => v.id === variantId);
|
||||||
|
const proposed = window.prompt(
|
||||||
|
"Name for the duplicated variant:",
|
||||||
|
(src?.label || "Variant") + " (copy)",
|
||||||
|
);
|
||||||
|
if (!proposed) return;
|
||||||
|
await this._busy(async () => {
|
||||||
|
const res = await rpc("/fp/part/composer/duplicate_variant", {
|
||||||
|
part_id: this.partId,
|
||||||
|
source_variant_id: variantId,
|
||||||
|
variant_label: proposed,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.error || "Duplicate failed.");
|
||||||
|
this.notification.add(`Variant "${proposed}" created.`, { type: "success" });
|
||||||
|
await this.refresh();
|
||||||
|
this.openRecipeEditor(res.root_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRenameVariant(variantId) {
|
||||||
|
const v = this.state.variants.find(x => x.id === variantId);
|
||||||
|
const proposed = window.prompt("New label:", v?.label || "");
|
||||||
|
if (!proposed) return;
|
||||||
|
await this._busy(async () => {
|
||||||
|
const res = await rpc("/fp/part/composer/rename_variant", {
|
||||||
|
part_id: this.partId,
|
||||||
|
variant_id: variantId,
|
||||||
|
variant_label: proposed,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.error || "Rename failed.");
|
||||||
|
await this.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSetDefaultVariant(variantId) {
|
||||||
|
await this._busy(async () => {
|
||||||
|
const res = await rpc("/fp/part/composer/set_default_variant", {
|
||||||
|
part_id: this.partId,
|
||||||
|
variant_id: variantId,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.error || "Set default failed.");
|
||||||
|
this.notification.add("Default variant updated.", { type: "success" });
|
||||||
|
await this.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDeleteVariant(variantId) {
|
||||||
|
const v = this.state.variants.find(x => x.id === variantId);
|
||||||
|
if (!window.confirm(`Delete variant "${v?.label || ""}"? This removes its tree.`)) return;
|
||||||
|
await this._busy(async () => {
|
||||||
|
const res = await rpc("/fp/part/composer/delete_variant", {
|
||||||
|
part_id: this.partId,
|
||||||
|
variant_id: variantId,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(res.error || "Delete failed.");
|
||||||
|
this.notification.add("Variant deleted.", { type: "success" });
|
||||||
|
await this.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _busy(fn) {
|
||||||
|
this.state.busy = true;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.notification.add(
|
this.notification.add(err.message || String(err), { type: "danger" });
|
||||||
`Load failed: ${err.message || err}`,
|
|
||||||
{ type: "danger" }
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.state.loadingTemplate = false;
|
this.state.busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openRecipeEditor(rootId) {
|
openRecipeEditor(rootId) {
|
||||||
const id = rootId || this.state.rootId;
|
const id = rootId || this.state.rootId;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
// The existing fp_recipe_tree_editor reads recipe_id from
|
|
||||||
// this.props.action?.context — pass it via `context`, not `params`.
|
|
||||||
// Label the editor as "Process Editor …" so it doesn't collide with
|
|
||||||
// "Process Composer …" in the breadcrumb stack; the two pages are
|
|
||||||
// distinct roles and should read differently in the trail.
|
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.client",
|
type: "ir.actions.client",
|
||||||
tag: "fp_recipe_tree_editor",
|
tag: "fp_recipe_tree_editor",
|
||||||
@@ -137,10 +200,6 @@ export class FpPartProcessComposer extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backToPart() {
|
backToPart() {
|
||||||
// clearBreadcrumbs: "Back" is semantically a RETURN, not a forward
|
|
||||||
// navigation — reset the stack to just the part form so repeated
|
|
||||||
// round-trips (part → composer → editor → back) don't accumulate
|
|
||||||
// duplicate entries.
|
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.act_window",
|
type: "ir.actions.act_window",
|
||||||
res_model: "fp.part.catalog",
|
res_model: "fp.part.catalog",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
Part of the Fusion Plating product family.
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
OWL template for the part-scoped Process Composer client action.
|
OWL template for the part-scoped Process Composer client action.
|
||||||
|
Sub 9 — multi-variant Composer.
|
||||||
-->
|
-->
|
||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
@@ -36,53 +37,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_part_composer_loader">
|
<div class="o_fp_part_composer_variants mt-3">
|
||||||
<label>Load Existing Process:</label>
|
<h4>Process Variants</h4>
|
||||||
<select class="form-select" t-on-change="onSelectTemplate">
|
<p class="text-muted small">
|
||||||
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
|
Add as many variants as you need (e.g. "Standard", "Selective Masking", "Rework").
|
||||||
<option t-att-value="tpl.id"
|
One variant is the default; order lines may pick another at entry time.
|
||||||
t-att-selected="tpl.id == state.selectedTemplateId">
|
</p>
|
||||||
<t t-esc="tpl.name"/>
|
<t t-if="state.variants.length === 0">
|
||||||
</option>
|
<div class="o_fp_part_composer_empty">
|
||||||
</t>
|
<i class="fa fa-cogs fa-2x"/>
|
||||||
</select>
|
<p>No variants yet. Pick a template below and add the first one.</p>
|
||||||
<button class="btn btn-primary"
|
|
||||||
t-on-click="onLoadTemplate"
|
|
||||||
t-att-disabled="state.loadingTemplate or !state.selectedTemplateId">
|
|
||||||
<t t-if="state.loadingTemplate">
|
|
||||||
<i class="fa fa-spinner fa-spin"/>
|
|
||||||
<span> Loading…</span>
|
|
||||||
</t>
|
|
||||||
<t t-else="">
|
|
||||||
<t t-if="state.hasTree">Replace with Selected</t>
|
|
||||||
<t t-else="">Load</t>
|
|
||||||
</t>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="o_fp_part_composer_tree">
|
|
||||||
<t t-if="state.hasTree">
|
|
||||||
<div class="o_fp_part_composer_hint">
|
|
||||||
<p>This part has a composed process tree. Click below to open the
|
|
||||||
full tree editor where you can add, remove, reorder, and configure
|
|
||||||
the process nodes.</p>
|
|
||||||
<button class="btn btn-primary o_fp_part_composer_editor_btn"
|
|
||||||
t-on-click="() => this.openRecipeEditor()">
|
|
||||||
<i class="fa fa-sitemap"/>
|
|
||||||
<span>Open Process Editor</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<div class="o_fp_part_composer_empty">
|
<table class="table table-sm align-middle">
|
||||||
<i class="fa fa-cogs fa-3x"/>
|
<thead>
|
||||||
<p>No process composed yet.</p>
|
<tr>
|
||||||
<p class="text-muted">
|
<th>Default</th>
|
||||||
Pick a template above and click <strong>Load</strong> to get started.
|
<th>Label</th>
|
||||||
</p>
|
<th>Recipe Name</th>
|
||||||
</div>
|
<th class="text-end">Nodes</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<t t-foreach="state.variants" t-as="v" t-key="v.id">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<t t-if="v.is_default">
|
||||||
|
<span class="badge bg-success">Default</span>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<button class="btn btn-link btn-sm p-0"
|
||||||
|
t-att-disabled="state.busy"
|
||||||
|
t-on-click="() => this.onSetDefaultVariant(v.id)">
|
||||||
|
Set Default
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong t-esc="v.label"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted" t-esc="v.name"/>
|
||||||
|
<td class="text-end" t-esc="v.node_count"/>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-primary me-1"
|
||||||
|
t-att-disabled="state.busy"
|
||||||
|
t-on-click="() => this.openRecipeEditor(v.id)">
|
||||||
|
<i class="fa fa-pencil"/> Edit
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-secondary me-1"
|
||||||
|
t-att-disabled="state.busy"
|
||||||
|
t-on-click="() => this.onDuplicateVariant(v.id)">
|
||||||
|
<i class="fa fa-copy"/> Duplicate
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-secondary me-1"
|
||||||
|
t-att-disabled="state.busy"
|
||||||
|
t-on-click="() => this.onRenameVariant(v.id)">
|
||||||
|
<i class="fa fa-i-cursor"/> Rename
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
t-att-disabled="state.busy"
|
||||||
|
t-on-click="() => this.onDeleteVariant(v.id)">
|
||||||
|
<i class="fa fa-trash"/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_part_composer_loader mt-4">
|
||||||
|
<h4>Add Variant from Template</h4>
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<label class="me-2">Template:</label>
|
||||||
|
<select class="form-select" style="max-width: 280px;"
|
||||||
|
t-on-change="onSelectTemplate">
|
||||||
|
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
|
||||||
|
<option t-att-value="tpl.id"
|
||||||
|
t-att-selected="tpl.id == state.selectedTemplateId">
|
||||||
|
<t t-esc="tpl.name"/>
|
||||||
|
</option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
<input class="form-control" style="max-width: 240px;"
|
||||||
|
placeholder="Variant label (e.g. Standard ENP)"
|
||||||
|
t-att-value="state.newVariantLabel"
|
||||||
|
t-on-input="onNewLabelInput"/>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
t-on-click="onAddVariantFromTemplate"
|
||||||
|
t-att-disabled="state.busy or !state.selectedTemplateId">
|
||||||
|
<i class="fa fa-plus"/> Add Variant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mt-1">
|
||||||
|
Leave the label blank to use the template name. The first variant added becomes the default automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -35,6 +35,12 @@
|
|||||||
action="action_fp_direct_order_wizard"
|
action="action_fp_direct_order_wizard"
|
||||||
sequence="5"/>
|
sequence="5"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_direct_order_drafts"
|
||||||
|
name="Direct Order Drafts"
|
||||||
|
parent="menu_fp_sales"
|
||||||
|
action="action_fp_direct_order_drafts"
|
||||||
|
sequence="6"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_quotations"
|
<menuitem id="menu_fp_quotations"
|
||||||
name="Quotations"
|
name="Quotations"
|
||||||
parent="menu_fp_sales"
|
parent="menu_fp_sales"
|
||||||
|
|||||||
@@ -167,20 +167,30 @@
|
|||||||
<page string="Process" name="process">
|
<page string="Process" name="process">
|
||||||
<group>
|
<group>
|
||||||
<field name="default_process_id" readonly="1"
|
<field name="default_process_id" readonly="1"
|
||||||
help="Use the Compose button to set up this part's process tree."/>
|
help="The variant used by default when an order line does not pick another."/>
|
||||||
|
<field name="process_variant_count" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<button name="action_open_part_composer" type="object"
|
<button name="action_open_part_composer" type="object"
|
||||||
string="Compose"
|
string="Compose"
|
||||||
icon="fa-wrench"
|
icon="fa-wrench"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
help="Open the Process Composer to load a template and edit this part's tree."/>
|
help="Open the Process Composer to manage this part's process variants."/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted mt-3">
|
<p class="text-muted mt-3">
|
||||||
The <strong>Compose</strong> button opens the Process Composer where you can
|
The <strong>Compose</strong> button opens the Process Composer where you can add
|
||||||
load a shared template and customise it for this part. When a job runs for
|
multiple process <em>variants</em> for this part — for example "Standard ENP",
|
||||||
this part, work orders are generated from the composed tree.
|
"Selective Masking", "Rework". One variant is flagged as default; estimators
|
||||||
|
may pick a different variant on a per-order basis.
|
||||||
</p>
|
</p>
|
||||||
|
<field name="process_variant_ids" readonly="1">
|
||||||
|
<list>
|
||||||
|
<field name="is_default_variant" widget="boolean_toggle" readonly="1"/>
|
||||||
|
<field name="variant_label"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="estimated_duration" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
</page>
|
</page>
|
||||||
<page string="Dimensions & Complexity" name="dimensions">
|
<page string="Dimensions & Complexity" name="dimensions">
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -13,12 +13,12 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Quote Configurator">
|
<form string="Quote Configurator">
|
||||||
<header>
|
<header>
|
||||||
<button name="action_create_quotation"
|
<button name="action_promote_to_direct_order"
|
||||||
string="Create Quotation"
|
string="Add to Direct Order"
|
||||||
type="object"
|
type="object"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
confirm="This will create a Sale Order from this configurator session. Continue?"
|
invisible="state != 'draft'"
|
||||||
invisible="state != 'draft'"/>
|
help="Add this quote as a line on a Direct Order draft. Multiple quotes can land on the same draft so one PO covers them all."/>
|
||||||
<button name="action_recalculate_price"
|
<button name="action_recalculate_price"
|
||||||
string="Recalculate"
|
string="Recalculate"
|
||||||
type="object"
|
type="object"
|
||||||
|
|||||||
@@ -62,10 +62,9 @@
|
|||||||
<button name="action_view_workorders"
|
<button name="action_view_workorders"
|
||||||
type="object"
|
type="object"
|
||||||
class="oe_stat_button"
|
class="oe_stat_button"
|
||||||
icon="fa-cogs"
|
icon="fa-cogs">
|
||||||
invisible="x_fc_workorder_count == 0">
|
|
||||||
<field name="x_fc_workorder_count" widget="statinfo"
|
<field name="x_fc_workorder_count" widget="statinfo"
|
||||||
string="Active WOs"/>
|
string="Work Orders"/>
|
||||||
</button>
|
</button>
|
||||||
<button name="action_view_ncrs"
|
<button name="action_view_ncrs"
|
||||||
type="object"
|
type="object"
|
||||||
@@ -93,6 +92,7 @@
|
|||||||
<field name="x_fc_configurator_id" readonly="1"/>
|
<field name="x_fc_configurator_id" readonly="1"/>
|
||||||
<field name="x_fc_part_catalog_id"/>
|
<field name="x_fc_part_catalog_id"/>
|
||||||
<field name="x_fc_coating_config_id"/>
|
<field name="x_fc_coating_config_id"/>
|
||||||
|
<field name="x_fc_process_summary" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="RFQ / PO">
|
<group string="RFQ / PO">
|
||||||
<field name="x_fc_po_number"/>
|
<field name="x_fc_po_number"/>
|
||||||
@@ -182,17 +182,29 @@
|
|||||||
</group>
|
</group>
|
||||||
</page>
|
</page>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
<!-- Make the standard customer-facing description column togglable.
|
||||||
|
The base sale.order line view shows `name` always; flipping it
|
||||||
|
to optional lets estimators hide/show it like the other columns. -->
|
||||||
|
<xpath expr="//field[@name='order_line']/list/field[@name='name']" position="attributes">
|
||||||
|
<attribute name="string">Customer-Facing</attribute>
|
||||||
|
<attribute name="optional">show</attribute>
|
||||||
|
</xpath>
|
||||||
<xpath expr="//field[@name='order_line']/list/field[@name='product_uom_qty']" position="before">
|
<xpath expr="//field[@name='order_line']/list/field[@name='product_uom_qty']" position="before">
|
||||||
<field name="x_fc_part_catalog_id" optional="show"/>
|
<field name="x_fc_part_catalog_id" optional="show"/>
|
||||||
<field name="x_fc_description_template_id"
|
<field name="x_fc_description_template_id"
|
||||||
domain="[('part_catalog_id', '=', x_fc_part_catalog_id)]"
|
domain="[('part_catalog_id', '=', x_fc_part_catalog_id)]"
|
||||||
options="{'no_create': True}"
|
context="{'default_part_catalog_id': x_fc_part_catalog_id}"
|
||||||
invisible="not x_fc_part_catalog_id"
|
invisible="not x_fc_part_catalog_id"
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="x_fc_internal_description"
|
<field name="x_fc_internal_description"
|
||||||
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
|
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="x_fc_coating_config_id" optional="show"/>
|
<field name="x_fc_coating_config_id" optional="show"/>
|
||||||
|
<field name="x_fc_process_variant_id"
|
||||||
|
string="Variant"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
invisible="not x_fc_part_catalog_id"
|
||||||
|
optional="show"/>
|
||||||
<field name="x_fc_thickness_id"
|
<field name="x_fc_thickness_id"
|
||||||
options="{'no_create': True}"
|
options="{'no_create': True}"
|
||||||
invisible="not x_fc_coating_config_id"
|
invisible="not x_fc_coating_config_id"
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ from . import fp_direct_order_wizard
|
|||||||
from . import fp_direct_order_line
|
from . import fp_direct_order_line
|
||||||
from . import fp_add_from_so_wizard
|
from . import fp_add_from_so_wizard
|
||||||
from . import fp_add_from_quote_wizard
|
from . import fp_add_from_quote_wizard
|
||||||
|
from . import fp_quote_promote_wizard
|
||||||
from . import fp_part_catalog_import_wizard
|
from . import fp_part_catalog_import_wizard
|
||||||
|
|||||||
@@ -45,17 +45,7 @@ class FpAddFromQuoteWizard(models.TransientModel):
|
|||||||
for q in self.quote_ids:
|
for q in self.quote_ids:
|
||||||
if not q.part_catalog_id or not q.coating_config_id:
|
if not q.part_catalog_id or not q.coating_config_id:
|
||||||
continue
|
continue
|
||||||
final = q.estimator_override_price or q.calculated_price
|
Line._create_from_quote(q, wizard)
|
||||||
unit = (final / q.quantity) if (final and q.quantity) else 0.0
|
|
||||||
Line.create({
|
|
||||||
'wizard_id': wizard.id,
|
|
||||||
'part_catalog_id': q.part_catalog_id.id,
|
|
||||||
'coating_config_id': q.coating_config_id.id,
|
|
||||||
'quantity': int(q.quantity) or 1,
|
|
||||||
'unit_price': unit,
|
|
||||||
'quote_id': q.id,
|
|
||||||
'line_description': q.notes or False,
|
|
||||||
})
|
|
||||||
copied += 1
|
copied += 1
|
||||||
|
|
||||||
if not copied:
|
if not copied:
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from odoo import _, api, fields, models
|
|||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class FpDirectOrderLine(models.TransientModel):
|
class FpDirectOrderLine(models.Model):
|
||||||
|
"""Sub 9 — persistent so the parent draft survives navigation."""
|
||||||
_name = 'fp.direct.order.line'
|
_name = 'fp.direct.order.line'
|
||||||
_description = 'Fusion Plating - Direct Order Line'
|
_description = 'Fusion Plating - Direct Order Line'
|
||||||
_order = 'sequence, id'
|
_order = 'sequence, id'
|
||||||
@@ -59,38 +60,50 @@ class FpDirectOrderLine(models.TransientModel):
|
|||||||
string='Additional Treatments',
|
string='Additional Treatments',
|
||||||
help='Extra pre/post treatments applied to this line.',
|
help='Extra pre/post treatments applied to this line.',
|
||||||
)
|
)
|
||||||
|
# Sub 9 — explicit per-line process variant override. NULL means
|
||||||
|
# "use the part's default variant".
|
||||||
|
process_variant_id = fields.Many2one(
|
||||||
|
'fusion.plating.process.node',
|
||||||
|
string='Process Variant',
|
||||||
|
domain="[('part_catalog_id', '=', part_catalog_id), "
|
||||||
|
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||||
|
ondelete='set null',
|
||||||
|
help='Pick a specific process variant for this line. Leave blank '
|
||||||
|
'to use the part\'s default variant. Manage variants via the '
|
||||||
|
'Process Composer on the part form.',
|
||||||
|
)
|
||||||
# Read-only preview of the process tree that WILL drive WO generation
|
# Read-only preview of the process tree that WILL drive WO generation
|
||||||
# for this line. Resolution priority:
|
# for this line. Resolution priority:
|
||||||
# 1. Part's composed process (fp.part.catalog.default_process_id)
|
# 1. Explicit process_variant_id (estimator pick)
|
||||||
# — a part-scoped customisation set via the Process Composer.
|
# 2. Part's default variant (fp.part.catalog.default_process_id)
|
||||||
# 2. Primary Treatment's default recipe (fp.coating.config.recipe_id)
|
# 3. Primary Treatment's default recipe (fp.coating.config.recipe_id)
|
||||||
# — the shared template used if the part has no override.
|
|
||||||
# Shown so operators can see *what will run* before confirming the
|
|
||||||
# order. Treatment answers the "what coating"; process answers the
|
|
||||||
# "how" — they're distinct but coupled via the resolution chain.
|
|
||||||
effective_process_id = fields.Many2one(
|
effective_process_id = fields.Many2one(
|
||||||
'fusion.plating.process.node',
|
'fusion.plating.process.node',
|
||||||
string='Process',
|
string='Process',
|
||||||
compute='_compute_effective_process',
|
compute='_compute_effective_process',
|
||||||
help='Process tree that will generate work orders for this line. '
|
help='Process tree that will generate work orders for this line.',
|
||||||
'Uses the part-composed process if one exists, otherwise the '
|
|
||||||
"primary treatment's default recipe.",
|
|
||||||
)
|
)
|
||||||
effective_process_source = fields.Char(
|
effective_process_source = fields.Char(
|
||||||
compute='_compute_effective_process',
|
compute='_compute_effective_process',
|
||||||
help='Tells the estimator whether the process comes from the '
|
help='Tells the estimator where the process comes from: '
|
||||||
'part (customised) or the coating (shared default).',
|
'an explicit variant pick, the part default, or the coating default.',
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('part_catalog_id.default_process_id',
|
@api.depends('process_variant_id',
|
||||||
|
'part_catalog_id.default_process_id',
|
||||||
'coating_config_id.recipe_id')
|
'coating_config_id.recipe_id')
|
||||||
def _compute_effective_process(self):
|
def _compute_effective_process(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
if rec.process_variant_id:
|
||||||
|
rec.effective_process_id = rec.process_variant_id
|
||||||
|
label = rec.process_variant_id.variant_label or rec.process_variant_id.name
|
||||||
|
rec.effective_process_source = 'Variant: %s' % (label or 'unnamed')
|
||||||
|
continue
|
||||||
part_proc = (rec.part_catalog_id.default_process_id
|
part_proc = (rec.part_catalog_id.default_process_id
|
||||||
if rec.part_catalog_id else False)
|
if rec.part_catalog_id else False)
|
||||||
if part_proc:
|
if part_proc:
|
||||||
rec.effective_process_id = part_proc
|
rec.effective_process_id = part_proc
|
||||||
rec.effective_process_source = 'Part (customised)'
|
rec.effective_process_source = 'Part default'
|
||||||
continue
|
continue
|
||||||
cc_proc = (rec.coating_config_id.recipe_id
|
cc_proc = (rec.coating_config_id.recipe_id
|
||||||
if rec.coating_config_id else False)
|
if rec.coating_config_id else False)
|
||||||
@@ -101,6 +114,14 @@ class FpDirectOrderLine(models.TransientModel):
|
|||||||
rec.effective_process_id = False
|
rec.effective_process_id = False
|
||||||
rec.effective_process_source = False
|
rec.effective_process_source = False
|
||||||
|
|
||||||
|
@api.onchange('part_catalog_id')
|
||||||
|
def _onchange_part_clears_variant(self):
|
||||||
|
"""Clear variant pick when the part changes (variants are part-scoped)."""
|
||||||
|
for rec in self:
|
||||||
|
if (rec.process_variant_id
|
||||||
|
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
|
||||||
|
rec.process_variant_id = False
|
||||||
|
|
||||||
# ---- Qty / price ----
|
# ---- Qty / price ----
|
||||||
quantity = fields.Integer(string='Qty', default=1, required=True)
|
quantity = fields.Integer(string='Qty', default=1, required=True)
|
||||||
currency_id = fields.Many2one(related='wizard_id.currency_id')
|
currency_id = fields.Many2one(related='wizard_id.currency_id')
|
||||||
@@ -113,6 +134,19 @@ class FpDirectOrderLine(models.TransientModel):
|
|||||||
currency_field='currency_id',
|
currency_field='currency_id',
|
||||||
compute='_compute_line_subtotal',
|
compute='_compute_line_subtotal',
|
||||||
)
|
)
|
||||||
|
# Sub 9 — taxes per line. Defaults from the FP-SERVICE product's
|
||||||
|
# sale taxes; fiscal-position-mapped from the customer when the
|
||||||
|
# wizard creates the SO line. Overridable per row.
|
||||||
|
tax_ids = fields.Many2many(
|
||||||
|
'account.tax',
|
||||||
|
relation='fp_direct_order_line_tax_rel',
|
||||||
|
column1='line_id',
|
||||||
|
column2='tax_id',
|
||||||
|
string='Taxes',
|
||||||
|
domain="[('type_tax_use', '=', 'sale')]",
|
||||||
|
help='Sales taxes applied to this line. Defaults from the plating '
|
||||||
|
'service product; override for tax-exempt or special-rate orders.',
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Scheduling / fulfilment ----
|
# ---- Scheduling / fulfilment ----
|
||||||
part_deadline = fields.Date(
|
part_deadline = fields.Date(
|
||||||
@@ -258,6 +292,27 @@ class FpDirectOrderLine(models.TransientModel):
|
|||||||
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
|
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
|
||||||
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
|
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
|
||||||
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
|
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
|
||||||
|
# Seed default taxes from the FP-SERVICE product, fiscal-position
|
||||||
|
# mapped from the customer. Only fills when the user hasn't set
|
||||||
|
# taxes manually.
|
||||||
|
if not self.tax_ids:
|
||||||
|
self._seed_default_taxes()
|
||||||
|
|
||||||
|
def _seed_default_taxes(self):
|
||||||
|
"""Pick taxes from the FP-SERVICE product, mapped through the
|
||||||
|
customer's fiscal position when one is set."""
|
||||||
|
self.ensure_one()
|
||||||
|
product = self.env['product.product'].search(
|
||||||
|
[('default_code', '=', 'FP-SERVICE')], limit=1,
|
||||||
|
)
|
||||||
|
if not product or not product.taxes_id:
|
||||||
|
return
|
||||||
|
taxes = product.taxes_id
|
||||||
|
partner = self.wizard_id.partner_id
|
||||||
|
if partner and partner.property_account_position_id:
|
||||||
|
taxes = partner.property_account_position_id.map_tax(taxes)
|
||||||
|
if taxes:
|
||||||
|
self.tax_ids = [(6, 0, taxes.ids)]
|
||||||
|
|
||||||
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
|
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
|
||||||
def _onchange_lookup_price(self):
|
def _onchange_lookup_price(self):
|
||||||
@@ -343,6 +398,30 @@ class FpDirectOrderLine(models.TransientModel):
|
|||||||
_apply(match)
|
_apply(match)
|
||||||
|
|
||||||
# ---- Helpers ----
|
# ---- Helpers ----
|
||||||
|
@api.model
|
||||||
|
def _create_from_quote(self, quote, wizard):
|
||||||
|
"""Seed a Direct Order line from a `fp.quote.configurator` row.
|
||||||
|
|
||||||
|
Single source of truth for both the per-quote "Promote" action and
|
||||||
|
the bulk "Add From Quotes" sub-wizard — keeps the field mapping
|
||||||
|
in one place so the two flows can never drift.
|
||||||
|
"""
|
||||||
|
if not quote.part_catalog_id or not quote.coating_config_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Quote %s has no part or coating set; cannot seed a line.'
|
||||||
|
) % (quote.name or quote.id))
|
||||||
|
final = quote.estimator_override_price or quote.calculated_price
|
||||||
|
unit = (final / quote.quantity) if (final and quote.quantity) else 0.0
|
||||||
|
return self.create({
|
||||||
|
'wizard_id': wizard.id,
|
||||||
|
'part_catalog_id': quote.part_catalog_id.id,
|
||||||
|
'coating_config_id': quote.coating_config_id.id,
|
||||||
|
'quantity': int(quote.quantity) or 1,
|
||||||
|
'unit_price': unit,
|
||||||
|
'quote_id': quote.id,
|
||||||
|
'line_description': quote.notes or False,
|
||||||
|
})
|
||||||
|
|
||||||
def _get_or_bump_revision(self):
|
def _get_or_bump_revision(self):
|
||||||
"""Return the part to use for the SO line, optionally bumping revision."""
|
"""Return the part to use for the SO line, optionally bumping revision."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -7,23 +7,60 @@ from odoo import _, api, fields, models
|
|||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class FpDirectOrderWizard(models.TransientModel):
|
class FpDirectOrderWizard(models.Model):
|
||||||
"""Direct order entry for repeat customers.
|
"""Direct order entry for repeat customers.
|
||||||
|
|
||||||
|
Sub 9 — converted from TransientModel to persistent Model so an
|
||||||
|
estimator can save a draft, navigate elsewhere (part form, Process
|
||||||
|
Composer, customer record), and come back. Entries persist across
|
||||||
|
sessions; finished drafts move to state='confirmed' and link to the
|
||||||
|
sale.order they produced.
|
||||||
|
|
||||||
Creates a sale.order (in draft / quotation state) with one
|
Creates a sale.order (in draft / quotation state) with one
|
||||||
sale.order.line per wizard line. The user reviews the resulting
|
sale.order.line per wizard line. The user reviews the resulting
|
||||||
quotation, makes any adjustments, and clicks Send / Confirm
|
quotation, makes any adjustments, and clicks Send / Confirm
|
||||||
manually. The wizard does NOT auto-confirm and does NOT auto-email
|
manually. The wizard does NOT auto-confirm and does NOT auto-email
|
||||||
the customer — that was deliberately removed in Sub 1 after the
|
the customer.
|
||||||
client requested a review step before anything leaves the shop.
|
|
||||||
"""
|
"""
|
||||||
_name = 'fp.direct.order.wizard'
|
_name = 'fp.direct.order.wizard'
|
||||||
_description = 'Fusion Plating - Direct Order Entry'
|
_description = 'Fusion Plating - Direct Order Entry'
|
||||||
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
|
_order = 'create_date desc, id desc'
|
||||||
|
_rec_name = 'name'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Reference',
|
||||||
|
required=True,
|
||||||
|
copy=False,
|
||||||
|
readonly=True,
|
||||||
|
default=lambda self: _('New'),
|
||||||
|
)
|
||||||
|
state = fields.Selection(
|
||||||
|
[('draft', 'Draft'),
|
||||||
|
('confirmed', 'Confirmed'),
|
||||||
|
('cancelled', 'Cancelled')],
|
||||||
|
string='Status', default='draft', required=True, copy=False,
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
sale_order_id = fields.Many2one(
|
||||||
|
'sale.order',
|
||||||
|
string='Sale Order',
|
||||||
|
readonly=True, copy=False, tracking=True,
|
||||||
|
help='Set when the draft is confirmed — points to the SO created.',
|
||||||
|
)
|
||||||
|
user_id = fields.Many2one(
|
||||||
|
'res.users', string='Estimator',
|
||||||
|
default=lambda self: self.env.user, tracking=True,
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Customer ----
|
# ---- Customer ----
|
||||||
|
# NB. Persistent model: partner is optional at draft-creation time so
|
||||||
|
# the estimator can spawn a blank draft and fill it in. The
|
||||||
|
# action_create_order method enforces the non-null check at confirm.
|
||||||
partner_id = fields.Many2one(
|
partner_id = fields.Many2one(
|
||||||
'res.partner', string='Customer', required=True,
|
'res.partner', string='Customer',
|
||||||
domain="[('customer_rank', '>', 0)]",
|
domain="[('customer_rank', '>', 0)]",
|
||||||
|
tracking=True,
|
||||||
)
|
)
|
||||||
partner_invoice_id = fields.Many2one(
|
partner_invoice_id = fields.Many2one(
|
||||||
'res.partner', string='Invoice Address',
|
'res.partner', string='Invoice Address',
|
||||||
@@ -46,7 +83,7 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
string='Planned Start', default=fields.Date.context_today,
|
string='Planned Start', default=fields.Date.context_today,
|
||||||
)
|
)
|
||||||
internal_deadline = fields.Date(string='Internal Deadline')
|
internal_deadline = fields.Date(string='Internal Deadline')
|
||||||
customer_deadline = fields.Date(string='Customer Deadline')
|
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
|
||||||
|
|
||||||
# ---- Order flags (Phase B) ----
|
# ---- Order flags (Phase B) ----
|
||||||
is_blanket_order = fields.Boolean(
|
is_blanket_order = fields.Boolean(
|
||||||
@@ -65,7 +102,7 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
# wizard now accepts a PO Pending flag in lieu of a PO#/doc; the
|
# wizard now accepts a PO Pending flag in lieu of a PO#/doc; the
|
||||||
# underlying SO is confirmed with a chase activity scheduled for
|
# underlying SO is confirmed with a chase activity scheduled for
|
||||||
# the expected date.
|
# the expected date.
|
||||||
po_number = fields.Char(string='Customer PO #')
|
po_number = fields.Char(string='Customer PO #', tracking=True)
|
||||||
po_attachment_file = fields.Binary(string='PO Document')
|
po_attachment_file = fields.Binary(string='PO Document')
|
||||||
po_attachment_filename = fields.Char(string='PO Filename')
|
po_attachment_filename = fields.Char(string='PO Filename')
|
||||||
po_pending = fields.Boolean(
|
po_pending = fields.Boolean(
|
||||||
@@ -101,6 +138,16 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
progress_initial_percent = fields.Float(
|
progress_initial_percent = fields.Float(
|
||||||
string='Progress - Initial %', default=50.0,
|
string='Progress - Initial %', default=50.0,
|
||||||
)
|
)
|
||||||
|
# Sub 9 — payment terms surfaced on the wizard so the resulting SO
|
||||||
|
# picks them up. Auto-seeded from the customer's invoice-strategy
|
||||||
|
# default (or the partner's property_payment_term_id), then nudged
|
||||||
|
# again when the strategy changes (COD/Prepay → Immediate Payment).
|
||||||
|
# User can override per draft.
|
||||||
|
payment_term_id = fields.Many2one(
|
||||||
|
'account.payment.term', string='Payment Terms',
|
||||||
|
help='Carries onto the sale order. Auto-fills from the customer '
|
||||||
|
'invoice strategy default; COD / Prepay forces immediate payment.',
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Notes ----
|
# ---- Notes ----
|
||||||
notes = fields.Text(string='Internal Notes')
|
notes = fields.Text(string='Internal Notes')
|
||||||
@@ -121,6 +168,17 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
# ---- Missing info banner ----
|
# ---- Missing info banner ----
|
||||||
missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
|
missing_info_msg = fields.Char(compute='_compute_missing_info_msg')
|
||||||
|
|
||||||
|
# ---- Persistence helpers ----
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('name') or vals.get('name') == _('New'):
|
||||||
|
vals['name'] = (
|
||||||
|
self.env['ir.sequence'].next_by_code('fp.direct.order.wizard')
|
||||||
|
or _('New Direct Order')
|
||||||
|
)
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
# ---- Computes ----
|
# ---- Computes ----
|
||||||
@api.depends('line_ids.line_subtotal', 'line_ids.quantity')
|
@api.depends('line_ids.line_subtotal', 'line_ids.quantity')
|
||||||
def _compute_totals(self):
|
def _compute_totals(self):
|
||||||
@@ -151,7 +209,7 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
# ---- Onchange ----
|
# ---- Onchange ----
|
||||||
@api.onchange('partner_id')
|
@api.onchange('partner_id')
|
||||||
def _onchange_partner_id(self):
|
def _onchange_partner_id(self):
|
||||||
"""Seed invoice defaults + default addresses when customer changes."""
|
"""Seed invoice defaults + addresses + payment terms when customer changes."""
|
||||||
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
|
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
|
||||||
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
|
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
|
||||||
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
|
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
|
||||||
@@ -159,11 +217,93 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
addrs = self.partner_id.address_get(['invoice', 'delivery'])
|
addrs = self.partner_id.address_get(['invoice', 'delivery'])
|
||||||
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
|
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
|
||||||
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
|
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
|
||||||
|
# Seed payment terms: customer's invoice-strategy default wins;
|
||||||
|
# fallback to partner.property_payment_term_id.
|
||||||
|
term = False
|
||||||
|
isd = self.env['fp.invoice.strategy.default'].search(
|
||||||
|
[('partner_id', '=', self.partner_id.id)], limit=1,
|
||||||
|
)
|
||||||
|
if isd and isd.payment_term_id:
|
||||||
|
term = isd.payment_term_id
|
||||||
|
# Also seed strategy from the same record if not already set.
|
||||||
|
if not self.invoice_strategy:
|
||||||
|
self.invoice_strategy = isd.default_strategy
|
||||||
|
if not self.deposit_percent:
|
||||||
|
self.deposit_percent = isd.default_deposit_percent or 0.0
|
||||||
|
if not term and self.partner_id.property_payment_term_id:
|
||||||
|
term = self.partner_id.property_payment_term_id
|
||||||
|
self.payment_term_id = term or False
|
||||||
else:
|
else:
|
||||||
self.partner_invoice_id = False
|
self.partner_invoice_id = False
|
||||||
self.partner_shipping_id = False
|
self.partner_shipping_id = False
|
||||||
|
self.payment_term_id = False
|
||||||
|
# Re-apply strategy → terms mapping after partner switch.
|
||||||
|
self._apply_strategy_payment_term()
|
||||||
|
|
||||||
|
@api.onchange('invoice_strategy')
|
||||||
|
def _onchange_invoice_strategy(self):
|
||||||
|
"""Map the strategy onto sensible payment terms."""
|
||||||
|
self._apply_strategy_payment_term()
|
||||||
|
|
||||||
|
def _apply_strategy_payment_term(self):
|
||||||
|
"""Mapping rule:
|
||||||
|
- cod_prepay → Immediate Payment (Odoo's stock term)
|
||||||
|
- deposit / progress / net_terms → keep what the partner default
|
||||||
|
already gave us; if blank, leave it blank so the user can pick.
|
||||||
|
Never overwrites an explicit user choice for non-COD strategies —
|
||||||
|
only fills in when payment_term_id is empty.
|
||||||
|
"""
|
||||||
|
for rec in self:
|
||||||
|
if rec.invoice_strategy == 'cod_prepay':
|
||||||
|
immediate = rec.env.ref(
|
||||||
|
'account.account_payment_term_immediate',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if immediate:
|
||||||
|
rec.payment_term_id = immediate.id
|
||||||
|
|
||||||
# ---- Actions ----
|
# ---- Actions ----
|
||||||
|
@api.model
|
||||||
|
def action_open_new_draft(self):
|
||||||
|
"""Create a fresh draft record and open it in form view.
|
||||||
|
|
||||||
|
Wired to the "New Direct Order" menu / button. Creating the
|
||||||
|
record up front means the draft is auto-persisted from the
|
||||||
|
first keystroke — the estimator can navigate away (to the
|
||||||
|
part form, the Process Composer, etc.) without losing work.
|
||||||
|
"""
|
||||||
|
draft = self.create({})
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Direct Order'),
|
||||||
|
'res_model': 'fp.direct.order.wizard',
|
||||||
|
'res_id': draft.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_cancel(self):
|
||||||
|
"""Move the draft to cancelled state. Kept for audit; not deleted."""
|
||||||
|
self.write({'state': 'cancelled'})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_reopen(self):
|
||||||
|
"""Reopen a cancelled draft for further editing."""
|
||||||
|
self.filtered(lambda r: r.state == 'cancelled').write({'state': 'draft'})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_view_sale_order(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.sale_order_id:
|
||||||
|
return False
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'sale.order',
|
||||||
|
'res_id': self.sale_order_id.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
def action_add_from_prior_so(self):
|
def action_add_from_prior_so(self):
|
||||||
"""Open a sub-wizard to copy lines from a prior sale.order."""
|
"""Open a sub-wizard to copy lines from a prior sale.order."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
@@ -207,6 +347,8 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
Sub 1 in the Fine-Tuning Initiative roadmap (CLAUDE.md).
|
Sub 1 in the Fine-Tuning Initiative roadmap (CLAUDE.md).
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
if not self.partner_id:
|
||||||
|
raise UserError(_('Pick a customer before confirming.'))
|
||||||
if not self.line_ids:
|
if not self.line_ids:
|
||||||
raise UserError(_('Add at least one part line before confirming.'))
|
raise UserError(_('Add at least one part line before confirming.'))
|
||||||
|
|
||||||
@@ -269,6 +411,7 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
'x_fc_invoice_strategy': self.invoice_strategy,
|
'x_fc_invoice_strategy': self.invoice_strategy,
|
||||||
'x_fc_deposit_percent': self.deposit_percent,
|
'x_fc_deposit_percent': self.deposit_percent,
|
||||||
'x_fc_progress_initial_percent': self.progress_initial_percent,
|
'x_fc_progress_initial_percent': self.progress_initial_percent,
|
||||||
|
'payment_term_id': self.payment_term_id.id or False,
|
||||||
'x_fc_delivery_method': self.delivery_method,
|
'x_fc_delivery_method': self.delivery_method,
|
||||||
'x_fc_is_blanket_order': self.is_blanket_order,
|
'x_fc_is_blanket_order': self.is_blanket_order,
|
||||||
'x_fc_block_partial_shipments': self.block_partial_shipments,
|
'x_fc_block_partial_shipments': self.block_partial_shipments,
|
||||||
@@ -312,11 +455,18 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
'x_fc_start_at_node_id': line.start_at_node_id.id or False,
|
'x_fc_start_at_node_id': line.start_at_node_id.id or False,
|
||||||
'x_fc_is_one_off': line.is_one_off,
|
'x_fc_is_one_off': line.is_one_off,
|
||||||
'x_fc_quote_id': line.quote_id.id or False,
|
'x_fc_quote_id': line.quote_id.id or False,
|
||||||
|
'x_fc_process_variant_id': line.process_variant_id.id or False,
|
||||||
# Sub 5 — carry serial / job# / thickness onto the SO line.
|
# Sub 5 — carry serial / job# / thickness onto the SO line.
|
||||||
# Revision snapshot auto-fills on SO-line create from the part.
|
# Revision snapshot auto-fills on SO-line create from the part.
|
||||||
'x_fc_serial_id': line.serial_id.id or False,
|
'x_fc_serial_id': line.serial_id.id or False,
|
||||||
'x_fc_job_number': line.job_number or False,
|
'x_fc_job_number': line.job_number or False,
|
||||||
'x_fc_thickness_id': line.thickness_id.id or False,
|
'x_fc_thickness_id': line.thickness_id.id or False,
|
||||||
|
# Sub 9 — explicit tax override from the wizard line.
|
||||||
|
# When blank, Odoo will compute taxes from the product
|
||||||
|
# defaults at SO-line save time (the standard behaviour).
|
||||||
|
# NB. Odoo 19 renamed the SO line field to tax_ids.
|
||||||
|
'tax_ids': ([(6, 0, line.tax_ids.ids)]
|
||||||
|
if line.tax_ids else False),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
# 5. Create — stays in draft / quotation. Sub 1: user reviews
|
# 5. Create — stays in draft / quotation. Sub 1: user reviews
|
||||||
@@ -324,6 +474,27 @@ class FpDirectOrderWizard(models.TransientModel):
|
|||||||
# auto-email to the client.
|
# auto-email to the client.
|
||||||
so = self.env['sale.order'].create(so_vals)
|
so = self.env['sale.order'].create(so_vals)
|
||||||
|
|
||||||
|
# Mark this draft as confirmed and link the SO.
|
||||||
|
self.write({'state': 'confirmed', 'sale_order_id': so.id})
|
||||||
|
|
||||||
|
# Sub 10 — flip every linked quote to "won" now that an SO exists.
|
||||||
|
# We deliberately wait until SO creation rather than at promote
|
||||||
|
# time, because "won" should mean "the deal closed", not "we put
|
||||||
|
# it on a draft." A draft can still be cancelled.
|
||||||
|
linked_quotes = self.line_ids.mapped('quote_id').filtered(
|
||||||
|
lambda q: q.state in ('draft', 'sent', 'accepted')
|
||||||
|
)
|
||||||
|
if linked_quotes:
|
||||||
|
linked_quotes.write({
|
||||||
|
'state': 'confirmed',
|
||||||
|
'won_date': fields.Date.today(),
|
||||||
|
'sale_order_id': so.id,
|
||||||
|
})
|
||||||
|
for q in linked_quotes:
|
||||||
|
q.message_post(body=_(
|
||||||
|
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
|
||||||
|
) % {'doo': self.name, 'so': so.name})
|
||||||
|
|
||||||
# 6. Push-to-defaults (C4) — uses the resolved part cached
|
# 6. Push-to-defaults (C4) — uses the resolved part cached
|
||||||
# during the build loop so rev-bumped lines write defaults to
|
# during the build loop so rev-bumped lines write defaults to
|
||||||
# the NEW revision, not the pre-bump one.
|
# the NEW revision, not the pre-bump one.
|
||||||
|
|||||||
@@ -6,12 +6,32 @@
|
|||||||
<field name="model">fp.direct.order.wizard</field>
|
<field name="model">fp.direct.order.wizard</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Direct Order Entry">
|
<form string="Direct Order Entry">
|
||||||
|
<header>
|
||||||
|
<button name="action_create_order" type="object"
|
||||||
|
string="Create & Confirm Order"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="state != 'draft'"/>
|
||||||
|
<button name="action_view_sale_order" type="object"
|
||||||
|
string="Open Sale Order"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="state != 'confirmed' or not sale_order_id"/>
|
||||||
|
<button name="action_cancel" type="object"
|
||||||
|
string="Discard Draft"
|
||||||
|
confirm="Mark this draft as cancelled? The data is preserved for audit."
|
||||||
|
invisible="state != 'draft'"/>
|
||||||
|
<button name="action_reopen" type="object"
|
||||||
|
string="Reopen Draft"
|
||||||
|
invisible="state != 'cancelled'"/>
|
||||||
|
<field name="state" widget="statusbar"
|
||||||
|
statusbar_visible="draft,confirmed"/>
|
||||||
|
</header>
|
||||||
<div class="alert alert-info py-2 mb-0 small"
|
<div class="alert alert-info py-2 mb-0 small"
|
||||||
role="alert">
|
role="alert"
|
||||||
|
invisible="state != 'draft'">
|
||||||
<i class="fa fa-info-circle me-1"/>
|
<i class="fa fa-info-circle me-1"/>
|
||||||
Changes are not saved until you click
|
This draft is auto-saved as you edit. You can navigate away
|
||||||
<strong>Create & Confirm Order</strong>. Closing this
|
(open the part form, the Process Composer, etc.) and return
|
||||||
window (Esc or X) discards your entries.
|
via <strong>Sales → Direct Order Drafts</strong>.
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-warning mb-0"
|
<div class="alert alert-warning mb-0"
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -20,11 +40,23 @@
|
|||||||
<field name="missing_info_msg" readonly="1" nolabel="1"/>
|
<field name="missing_info_msg" readonly="1" nolabel="1"/>
|
||||||
</div>
|
</div>
|
||||||
<sheet>
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_sale_order" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-shopping-cart"
|
||||||
|
invisible="not sale_order_id">
|
||||||
|
<div class="o_stat_info">
|
||||||
|
<span class="o_stat_text">Sale Order</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<h1>New Direct Order</h1>
|
<label for="name" class="o_form_label"/>
|
||||||
<p class="text-muted">
|
<h1><field name="name" readonly="1"/></h1>
|
||||||
Skip the quotation stage - create a confirmed order
|
<field name="user_id" readonly="state != 'draft'"
|
||||||
when the customer has already sent a PO.
|
options="{'no_create': True}"/>
|
||||||
|
<p class="text-muted" invisible="state != 'draft'">
|
||||||
|
Skip the quotation stage — create a confirmed order
|
||||||
|
when the customer has already sent a PO. Drafts auto-save.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,6 +102,8 @@
|
|||||||
<group string="Fulfilment & Invoicing">
|
<group string="Fulfilment & Invoicing">
|
||||||
<field name="delivery_method"/>
|
<field name="delivery_method"/>
|
||||||
<field name="invoice_strategy"/>
|
<field name="invoice_strategy"/>
|
||||||
|
<field name="payment_term_id"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
<label for="deposit_percent"
|
<label for="deposit_percent"
|
||||||
invisible="invoice_strategy != 'deposit'"/>
|
invisible="invoice_strategy != 'deposit'"/>
|
||||||
<div class="o_row"
|
<div class="o_row"
|
||||||
@@ -112,12 +146,20 @@
|
|||||||
options="{'no_create_edit': True}"/>
|
options="{'no_create_edit': True}"/>
|
||||||
<field name="description_template_id"
|
<field name="description_template_id"
|
||||||
domain="[('part_catalog_id', '=', part_catalog_id)]"
|
domain="[('part_catalog_id', '=', part_catalog_id)]"
|
||||||
options="{'no_create': True}"
|
context="{'default_part_catalog_id': part_catalog_id}"
|
||||||
invisible="not part_catalog_id"
|
invisible="not part_catalog_id"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
|
<field name="line_description"
|
||||||
|
string="Customer-Facing"
|
||||||
|
optional="hide"/>
|
||||||
<field name="internal_description"
|
<field name="internal_description"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="coating_config_id"/>
|
<field name="coating_config_id"/>
|
||||||
|
<field name="process_variant_id"
|
||||||
|
string="Variant"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
invisible="not part_catalog_id"
|
||||||
|
optional="show"/>
|
||||||
<field name="effective_process_id"
|
<field name="effective_process_id"
|
||||||
string="Process"
|
string="Process"
|
||||||
readonly="1"
|
readonly="1"
|
||||||
@@ -141,6 +183,10 @@
|
|||||||
<field name="unit_price"
|
<field name="unit_price"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
|
<field name="tax_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
optional="show"/>
|
||||||
<field name="line_subtotal"
|
<field name="line_subtotal"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"
|
options="{'currency_field': 'currency_id'}"
|
||||||
@@ -163,6 +209,10 @@
|
|||||||
<field name="coating_config_id"/>
|
<field name="coating_config_id"/>
|
||||||
<field name="treatment_ids"
|
<field name="treatment_ids"
|
||||||
widget="many2many_tags"/>
|
widget="many2many_tags"/>
|
||||||
|
<field name="process_variant_id"
|
||||||
|
string="Process Variant"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
invisible="not part_catalog_id"/>
|
||||||
<field name="effective_process_id"
|
<field name="effective_process_id"
|
||||||
string="Effective Process"
|
string="Effective Process"
|
||||||
readonly="1"/>
|
readonly="1"/>
|
||||||
@@ -178,6 +228,9 @@
|
|||||||
<field name="unit_price"
|
<field name="unit_price"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
|
<field name="tax_ids"
|
||||||
|
widget="many2many_tags"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
<field name="line_subtotal"
|
<field name="line_subtotal"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
@@ -199,8 +252,9 @@
|
|||||||
</group>
|
</group>
|
||||||
<group string="Line Description">
|
<group string="Line Description">
|
||||||
<field name="description_template_id"
|
<field name="description_template_id"
|
||||||
options="{'no_create': True, 'no_open': True}"
|
domain="[('part_catalog_id', '=', part_catalog_id)]"
|
||||||
placeholder="Start typing to search saved descriptions..."/>
|
context="{'default_part_catalog_id': part_catalog_id}"
|
||||||
|
placeholder="Start typing to search saved descriptions, or type a new name to create one..."/>
|
||||||
<label for="line_description"
|
<label for="line_description"
|
||||||
string="Customer-Facing"/>
|
string="Customer-Facing"/>
|
||||||
<field name="line_description"
|
<field name="line_description"
|
||||||
@@ -245,29 +299,100 @@
|
|||||||
</notebook>
|
</notebook>
|
||||||
|
|
||||||
</sheet>
|
</sheet>
|
||||||
<footer>
|
<chatter/>
|
||||||
<button name="action_create_order"
|
|
||||||
type="object"
|
|
||||||
string="Create & Confirm Order"
|
|
||||||
class="btn-primary"/>
|
|
||||||
<button string="Cancel"
|
|
||||||
special="cancel"
|
|
||||||
class="btn-secondary"
|
|
||||||
confirm="Discard this order? All header data and line items will be lost."/>
|
|
||||||
</footer>
|
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Form action — keeps the same external ID as before so existing
|
||||||
|
button references survive (act_window cannot be replaced by a
|
||||||
|
server action with the same xmlid). target='current' lets the
|
||||||
|
estimator breadcrumb between the wizard and the part form / Composer.
|
||||||
|
Odoo prompts to save unsaved changes when navigating away. -->
|
||||||
<record id="action_fp_direct_order_wizard" model="ir.actions.act_window">
|
<record id="action_fp_direct_order_wizard" model="ir.actions.act_window">
|
||||||
<field name="name">New Direct Order</field>
|
<field name="name">New Direct Order</field>
|
||||||
<field name="res_model">fp.direct.order.wizard</field>
|
<field name="res_model">fp.direct.order.wizard</field>
|
||||||
<field name="view_mode">form</field>
|
<field name="view_mode">form</field>
|
||||||
<field name="target">new</field>
|
<field name="target">current</field>
|
||||||
<!-- Use Odoo's built-in extra-large dialog size so the line
|
<field name="context">{}</field>
|
||||||
table (10+ columns) isn't squeezed into ellipsis at the
|
</record>
|
||||||
default modal width. Roughly 30% wider than the default. -->
|
|
||||||
<field name="context">{'dialog_size': 'extra-large'}</field>
|
<!-- ===== Drafts list view (resume an in-flight order entry) ===== -->
|
||||||
|
<record id="view_fp_direct_order_wizard_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.direct.order.wizard.list</field>
|
||||||
|
<field name="model">fp.direct.order.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Direct Order Drafts"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-muted="state == 'cancelled'"
|
||||||
|
decoration-success="state == 'confirmed'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="po_number" optional="show"/>
|
||||||
|
<field name="customer_deadline" optional="hide"/>
|
||||||
|
<field name="total_line_count" optional="hide"/>
|
||||||
|
<field name="total_qty" optional="hide"/>
|
||||||
|
<field name="total_amount" widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}"
|
||||||
|
sum="Total"/>
|
||||||
|
<field name="currency_id" column_invisible="1"/>
|
||||||
|
<field name="create_date" optional="show"/>
|
||||||
|
<field name="sale_order_id" optional="hide"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-success="state == 'confirmed'"
|
||||||
|
decoration-muted="state == 'cancelled'"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_direct_order_wizard_search" model="ir.ui.view">
|
||||||
|
<field name="name">fp.direct.order.wizard.search</field>
|
||||||
|
<field name="model">fp.direct.order.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="po_number"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<filter name="filter_draft" string="Draft"
|
||||||
|
domain="[('state', '=', 'draft')]"/>
|
||||||
|
<filter name="filter_confirmed" string="Confirmed"
|
||||||
|
domain="[('state', '=', 'confirmed')]"/>
|
||||||
|
<filter name="filter_cancelled" string="Cancelled"
|
||||||
|
domain="[('state', '=', 'cancelled')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="filter_my" string="My Drafts"
|
||||||
|
domain="[('user_id', '=', uid)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="group_state" string="Status"
|
||||||
|
context="{'group_by': 'state'}"/>
|
||||||
|
<filter name="group_partner" string="Customer"
|
||||||
|
context="{'group_by': 'partner_id'}"/>
|
||||||
|
<filter name="group_user" string="Estimator"
|
||||||
|
context="{'group_by': 'user_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_direct_order_drafts" model="ir.actions.act_window">
|
||||||
|
<field name="name">Direct Order Drafts</field>
|
||||||
|
<field name="res_model">fp.direct.order.wizard</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="target">current</field>
|
||||||
|
<field name="search_view_id" ref="view_fp_direct_order_wizard_search"/>
|
||||||
|
<field name="context">{'search_default_filter_draft': 1}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No drafts yet — start one!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Drafts persist across sessions. Save your progress, switch to a
|
||||||
|
part form, edit the Process Composer, and come back to finish.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class FpQuotePromoteWizard(models.TransientModel):
|
||||||
|
"""Chooser dialog: promote a won quote into a Direct Order draft.
|
||||||
|
|
||||||
|
Sub 10 — quote→direct-order handoff. The estimator picks either an
|
||||||
|
existing open draft for this customer (lets multiple quotes
|
||||||
|
consolidate onto a single PO) or creates a fresh draft.
|
||||||
|
"""
|
||||||
|
_name = 'fp.quote.promote.wizard'
|
||||||
|
_description = 'Promote Quote to Direct Order'
|
||||||
|
|
||||||
|
quote_id = fields.Many2one(
|
||||||
|
'fp.quote.configurator', required=True, readonly=True,
|
||||||
|
)
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
related='quote_id.partner_id', readonly=True,
|
||||||
|
)
|
||||||
|
quote_currency_id = fields.Many2one(
|
||||||
|
related='quote_id.currency_id', readonly=True,
|
||||||
|
)
|
||||||
|
target_mode = fields.Selection(
|
||||||
|
[('existing', 'Add to existing draft'),
|
||||||
|
('new', 'Create new Direct Order')],
|
||||||
|
string='Target', required=True, default='new',
|
||||||
|
)
|
||||||
|
target_wizard_id = fields.Many2one(
|
||||||
|
'fp.direct.order.wizard',
|
||||||
|
string='Existing Draft',
|
||||||
|
domain="[('state', '=', 'draft'), ('partner_id', '=', partner_id)]",
|
||||||
|
help='Pick an open draft for this customer. The quote is added '
|
||||||
|
'as a new line to that draft.',
|
||||||
|
)
|
||||||
|
open_drafts_count = fields.Integer(
|
||||||
|
compute='_compute_open_drafts_count',
|
||||||
|
help='Drafts currently open for this customer.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('partner_id')
|
||||||
|
def _compute_open_drafts_count(self):
|
||||||
|
DOO = self.env['fp.direct.order.wizard']
|
||||||
|
for rec in self:
|
||||||
|
rec.open_drafts_count = DOO.search_count([
|
||||||
|
('state', '=', 'draft'),
|
||||||
|
('partner_id', '=', rec.partner_id.id),
|
||||||
|
]) if rec.partner_id else 0
|
||||||
|
|
||||||
|
@api.onchange('partner_id', 'open_drafts_count')
|
||||||
|
def _onchange_default_target(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.open_drafts_count == 0:
|
||||||
|
rec.target_mode = 'new'
|
||||||
|
|
||||||
|
def action_promote(self):
|
||||||
|
self.ensure_one()
|
||||||
|
q = self.quote_id
|
||||||
|
|
||||||
|
# Re-check the not-already-promoted invariant — a separate user
|
||||||
|
# could have added this quote to a draft between the action open
|
||||||
|
# and the click, so we re-verify before mutating.
|
||||||
|
existing_line = self.env['fp.direct.order.line'].search([
|
||||||
|
('quote_id', '=', q.id),
|
||||||
|
('wizard_id.state', '=', 'draft'),
|
||||||
|
], limit=1)
|
||||||
|
if existing_line:
|
||||||
|
raise UserError(_(
|
||||||
|
'This quote is already on draft "%s". Open that draft '
|
||||||
|
'and remove its line if you want to move it elsewhere.'
|
||||||
|
) % existing_line.wizard_id.name)
|
||||||
|
|
||||||
|
# Resolve target draft.
|
||||||
|
if self.target_mode == 'existing':
|
||||||
|
if not self.target_wizard_id:
|
||||||
|
raise UserError(_('Pick an existing draft, or switch to '
|
||||||
|
'"Create new Direct Order".'))
|
||||||
|
target = self.target_wizard_id
|
||||||
|
if target.state != 'draft':
|
||||||
|
raise UserError(_(
|
||||||
|
'Draft "%s" is no longer in draft state.'
|
||||||
|
) % target.name)
|
||||||
|
else:
|
||||||
|
target = self.env['fp.direct.order.wizard'].create({
|
||||||
|
'partner_id': q.partner_id.id,
|
||||||
|
'currency_id': q.currency_id.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Currency must match — Direct Order doesn't convert.
|
||||||
|
if target.currency_id != q.currency_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Quote currency (%s) does not match Direct Order '
|
||||||
|
'currency (%s). Re-quote in the order currency, or '
|
||||||
|
'create a new Direct Order in this quote\'s currency.'
|
||||||
|
) % (q.currency_id.name, target.currency_id.name))
|
||||||
|
|
||||||
|
# Seed the line.
|
||||||
|
self.env['fp.direct.order.line']._create_from_quote(q, target)
|
||||||
|
|
||||||
|
# Open the target draft so the estimator can keep adding lines.
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'fp.direct.order.wizard',
|
||||||
|
'res_id': target.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_quote_promote_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.quote.promote.wizard.form</field>
|
||||||
|
<field name="model">fp.quote.promote.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Promote Quote to Direct Order">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h2>Add Quote to Direct Order</h2>
|
||||||
|
<p class="text-muted">
|
||||||
|
This quote will be added as a single line on a
|
||||||
|
Direct Order draft. Multiple quotes can land on
|
||||||
|
the same draft so one PO covers them all.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<field name="quote_id" readonly="1"/>
|
||||||
|
<field name="partner_id" readonly="1"/>
|
||||||
|
<field name="quote_currency_id" readonly="1"/>
|
||||||
|
<field name="open_drafts_count" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="target_mode" widget="radio"/>
|
||||||
|
<field name="target_wizard_id"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
required="target_mode == 'existing'"
|
||||||
|
invisible="target_mode != 'existing'"/>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-info py-2 mb-0 small"
|
||||||
|
role="alert"
|
||||||
|
invisible="open_drafts_count != 0 or target_mode != 'new'">
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
No open drafts for this customer — a fresh Direct
|
||||||
|
Order will be created.
|
||||||
|
</div>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_promote" type="object"
|
||||||
|
string="Add to Direct Order"
|
||||||
|
class="btn-primary"/>
|
||||||
|
<button string="Cancel" special="cancel"
|
||||||
|
class="btn-secondary"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpInvoiceStrategyDefault(models.Model):
|
class FpInvoiceStrategyDefault(models.Model):
|
||||||
@@ -33,6 +33,17 @@ class FpInvoiceStrategyDefault(models.Model):
|
|||||||
)
|
)
|
||||||
notes = fields.Text(string='Notes')
|
notes = fields.Text(string='Notes')
|
||||||
|
|
||||||
|
@api.depends('partner_id', 'default_strategy')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
labels = dict(self._fields['default_strategy'].selection)
|
||||||
|
for rec in self:
|
||||||
|
bits = []
|
||||||
|
if rec.partner_id:
|
||||||
|
bits.append(rec.partner_id.display_name)
|
||||||
|
if rec.default_strategy:
|
||||||
|
bits.append(labels.get(rec.default_strategy, rec.default_strategy))
|
||||||
|
rec.display_name = ' — '.join(bits) or 'Invoice Strategy'
|
||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
('fp_invoice_strategy_partner_uniq', 'unique(partner_id)',
|
('fp_invoice_strategy_partner_uniq', 'unique(partner_id)',
|
||||||
'Only one invoice strategy default per customer.'),
|
'Only one invoice strategy default per customer.'),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.5.1.0',
|
'version': '19.0.6.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
@@ -54,6 +54,9 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/fp_job_form_inherit.xml',
|
'views/fp_job_form_inherit.xml',
|
||||||
|
'views/sale_order_views.xml',
|
||||||
|
'views/fp_job_consumption_views.xml',
|
||||||
|
'views/fp_step_priority_views.xml',
|
||||||
'views/jobs_in_shopfloor_menu.xml',
|
'views/jobs_in_shopfloor_menu.xml',
|
||||||
'views/legacy_menu_hide.xml',
|
'views/legacy_menu_hide.xml',
|
||||||
'report/report_fp_job_sticker.xml',
|
'report/report_fp_job_sticker.xml',
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocate ir.model.data XML IDs from
|
||||||
|
# fusion_plating_bridge_mrp to fusion_plating_jobs for the four
|
||||||
|
# models that moved: fp.work.role, fp.operator.proficiency,
|
||||||
|
# fp.qc.checklist.template (+line), fp.job.consumption.
|
||||||
|
#
|
||||||
|
# Pre-migration so Odoo's normal load pass sees the records under the
|
||||||
|
# new module owner, not as orphans pending deletion.
|
||||||
|
#
|
||||||
|
# Idempotent — `ON CONFLICT DO NOTHING` skips rows already migrated.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return # Fresh install — nothing to migrate
|
||||||
|
|
||||||
|
moves = [
|
||||||
|
# (xmlid pattern, list of model identifiers to move)
|
||||||
|
('model_fp_job_consumption',),
|
||||||
|
# ACL records (csv:id values get prefixed with the owning module)
|
||||||
|
('access_fp_job_consumption_%',),
|
||||||
|
]
|
||||||
|
for (pat,) in moves:
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ir_model_data
|
||||||
|
SET module = 'fusion_plating_jobs'
|
||||||
|
WHERE module = 'fusion_plating_bridge_mrp'
|
||||||
|
AND name LIKE %s
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ir_model_data d2
|
||||||
|
WHERE d2.module = 'fusion_plating_jobs'
|
||||||
|
AND d2.name = ir_model_data.name
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(pat,),
|
||||||
|
)
|
||||||
|
if cr.rowcount:
|
||||||
|
_logger.info(
|
||||||
|
"Sub 11: re-keyed %d ir.model.data rows matching %s -> fusion_plating_jobs",
|
||||||
|
cr.rowcount, pat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Views, actions, menus that the old module created
|
||||||
|
view_patterns = [
|
||||||
|
'view_fp_job_consumption_%',
|
||||||
|
'action_fp_job_consumption%',
|
||||||
|
'menu_fp_job_consumption%',
|
||||||
|
]
|
||||||
|
for pat in view_patterns:
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ir_model_data
|
||||||
|
SET module = 'fusion_plating_jobs'
|
||||||
|
WHERE module = 'fusion_plating_bridge_mrp'
|
||||||
|
AND name LIKE %s
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ir_model_data d2
|
||||||
|
WHERE d2.module = 'fusion_plating_jobs'
|
||||||
|
AND d2.name = ir_model_data.name
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(pat,),
|
||||||
|
)
|
||||||
|
if cr.rowcount:
|
||||||
|
_logger.info(
|
||||||
|
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating_jobs",
|
||||||
|
cr.rowcount, pat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 1 swap: fp.job.consumption columns. Drop the legacy
|
||||||
|
# MRP-pointing columns (production_id, workorder_id) from the
|
||||||
|
# already-existing table — there are zero rows referencing MRP, and
|
||||||
|
# the new model declares job_id / step_id instead.
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE fp_job_consumption
|
||||||
|
DROP COLUMN IF EXISTS production_id,
|
||||||
|
DROP COLUMN IF EXISTS workorder_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
_logger.info("Sub 11: dropped MRP columns on fp_job_consumption")
|
||||||
@@ -27,3 +27,12 @@ from . import fusion_plating_kpi_value
|
|||||||
|
|
||||||
# Phase 5 — Job Margin report.
|
# Phase 5 — Job Margin report.
|
||||||
from . import report_fp_job_margin
|
from . import report_fp_job_margin
|
||||||
|
|
||||||
|
# Phase 1 of MRP cut-out (Sub 11) — relocated from fusion_plating_bridge_mrp.
|
||||||
|
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
|
||||||
|
# back on jobs without a cycle.)
|
||||||
|
from . import fp_job_consumption
|
||||||
|
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the
|
||||||
|
# hr.employee shop-roles inherit live in fusion_plating core so every
|
||||||
|
# downstream module (cgp, bridge_mrp residue, etc.) sees them without a
|
||||||
|
# transitive dep on jobs.
|
||||||
|
|||||||
@@ -517,6 +517,12 @@ class FpJob(models.Model):
|
|||||||
if self.env.context.get('fp_jobs_migration'):
|
if self.env.context.get('fp_jobs_migration'):
|
||||||
return result
|
return result
|
||||||
for job in self:
|
for job in self:
|
||||||
|
# Auto-generate steps from the recipe — was previously only
|
||||||
|
# called by seed scripts, which meant real-life confirmed
|
||||||
|
# jobs sat with zero operations. Idempotent: the generator
|
||||||
|
# short-circuits when steps already exist.
|
||||||
|
if job.recipe_id and not job.step_ids:
|
||||||
|
job._generate_steps_from_recipe()
|
||||||
job._fp_create_portal_job()
|
job._fp_create_portal_job()
|
||||||
job._fp_create_qc_check_if_needed()
|
job._fp_create_qc_check_if_needed()
|
||||||
job._fp_create_racking_inspection()
|
job._fp_create_racking_inspection()
|
||||||
@@ -526,36 +532,28 @@ class FpJob(models.Model):
|
|||||||
def _fp_create_racking_inspection(self):
|
def _fp_create_racking_inspection(self):
|
||||||
"""Auto-create a draft racking inspection on job confirm.
|
"""Auto-create a draft racking inspection on job confirm.
|
||||||
|
|
||||||
Mirrors bridge_mrp's behaviour for MO confirm. Best-effort: the
|
Phase 9 — production_id is now optional on fp.racking.inspection,
|
||||||
legacy fp.racking.inspection model still requires a production_id
|
so we always create one bound by `x_fc_job_id`. When the job is
|
||||||
(mrp.production), so we can only create one when this job is
|
also linked to an MO (legacy bridge_mrp coexistence), populate
|
||||||
bound to an MO via bridge_mrp. Otherwise we skip cleanly — Phase
|
production_id too so legacy reports keep working.
|
||||||
9 will flip the required-FK to fp.job.
|
|
||||||
|
Idempotent — if an inspection already exists for this job, skip.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if 'fp.racking.inspection' not in self.env:
|
if 'fp.racking.inspection' not in self.env:
|
||||||
return
|
return
|
||||||
Inspection = self.env['fp.racking.inspection'].sudo()
|
Inspection = self.env['fp.racking.inspection'].sudo()
|
||||||
# The model still requires production_id today. If the job has
|
if 'x_fc_job_id' not in Inspection._fields:
|
||||||
# no MO link (which it won't in pure-native mode), skip rather
|
# Schema not yet upgraded — skip.
|
||||||
# than crash. The link exists when fusion_plating_bridge_mrp is
|
|
||||||
# installed and a production was created in parallel.
|
|
||||||
production = False
|
|
||||||
if 'production_id' in self._fields and self.production_id:
|
|
||||||
production = self.production_id
|
|
||||||
elif 'mrp_production_id' in self._fields and getattr(
|
|
||||||
self, 'mrp_production_id', False):
|
|
||||||
production = self.mrp_production_id
|
|
||||||
if not production:
|
|
||||||
_logger.debug(
|
|
||||||
"Job %s: no MO link — skipping racking-inspection auto-create "
|
|
||||||
"(required production_id not yet on fp.job).", self.name,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
existing = Inspection.search([
|
||||||
|
('x_fc_job_id', '=', self.id),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
|
||||||
|
vals = {'x_fc_job_id': self.id}
|
||||||
try:
|
try:
|
||||||
vals = {'production_id': production.id}
|
|
||||||
if 'x_fc_job_id' in Inspection._fields:
|
|
||||||
vals['x_fc_job_id'] = self.id
|
|
||||||
Inspection.create(vals)
|
Inspection.create(vals)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
|
|||||||
104
fusion_plating/fusion_plating_jobs/models/fp_job_consumption.py
Normal file
104
fusion_plating/fusion_plating_jobs/models/fp_job_consumption.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
|
||||||
|
# MRP-flavoured fields (production_id, workorder_id) replaced by their
|
||||||
|
# native fp.job / fp.job.step equivalents.
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
|
class FpJobConsumption(models.Model):
|
||||||
|
"""A single consumable drawdown charged to a plating job.
|
||||||
|
|
||||||
|
Sources include bath replenishment applied against a job, masking tape
|
||||||
|
rolls, PPE, nickel salts — anything that has a cost and should roll
|
||||||
|
into job costing.
|
||||||
|
|
||||||
|
Kept deliberately lightweight: one row per event, cost derived from
|
||||||
|
`product.standard_price` at log time (snapshot, not reactive).
|
||||||
|
"""
|
||||||
|
_name = 'fp.job.consumption'
|
||||||
|
_description = 'Fusion Plating — Job Consumption'
|
||||||
|
_order = 'logged_date desc, id desc'
|
||||||
|
|
||||||
|
job_id = fields.Many2one(
|
||||||
|
'fp.job', string='Plating Job',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
step_id = fields.Many2one(
|
||||||
|
'fp.job.step', string='Job Step',
|
||||||
|
domain="[('job_id', '=', job_id)]",
|
||||||
|
ondelete='set null',
|
||||||
|
)
|
||||||
|
product_id = fields.Many2one(
|
||||||
|
'product.product', string='Product', required=True,
|
||||||
|
domain="[('sale_ok', '=', False)]",
|
||||||
|
)
|
||||||
|
product_name = fields.Char(
|
||||||
|
string='Product Name (snapshot)',
|
||||||
|
help='Free-text product label if no inventory product is linked.',
|
||||||
|
)
|
||||||
|
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
|
||||||
|
uom_id = fields.Many2one(
|
||||||
|
'uom.uom', string='UoM',
|
||||||
|
)
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
'res.currency', required=True,
|
||||||
|
default=lambda self: self.env.company.currency_id,
|
||||||
|
)
|
||||||
|
unit_cost = fields.Monetary(
|
||||||
|
string='Unit Cost (snapshot)', currency_field='currency_id',
|
||||||
|
help='Taken from product.standard_price at log time.',
|
||||||
|
)
|
||||||
|
total_cost = fields.Monetary(
|
||||||
|
string='Total Cost', currency_field='currency_id',
|
||||||
|
compute='_compute_total_cost', store=True,
|
||||||
|
)
|
||||||
|
logged_date = fields.Datetime(
|
||||||
|
string='Logged', default=fields.Datetime.now,
|
||||||
|
)
|
||||||
|
logged_by_id = fields.Many2one(
|
||||||
|
'res.users', string='Logged By', default=lambda self: self.env.user,
|
||||||
|
)
|
||||||
|
source = fields.Selection(
|
||||||
|
[('replenishment', 'Bath Replenishment'),
|
||||||
|
('masking', 'Masking Material'),
|
||||||
|
('ppe', 'PPE / Consumables'),
|
||||||
|
('chemistry', 'Process Chemistry'),
|
||||||
|
('other', 'Other')],
|
||||||
|
string='Source', default='other', required=True,
|
||||||
|
)
|
||||||
|
replenishment_id = fields.Many2one(
|
||||||
|
'fusion.plating.bath.replenishment.suggestion',
|
||||||
|
string='Replenishment Suggestion',
|
||||||
|
ondelete='set null',
|
||||||
|
)
|
||||||
|
notes = fields.Char(string='Notes')
|
||||||
|
|
||||||
|
@api.depends('quantity', 'unit_cost')
|
||||||
|
def _compute_total_cost(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
|
||||||
|
|
||||||
|
@api.depends('product_id', 'product_name', 'quantity', 'job_id')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
label = rec.product_id.display_name or rec.product_name or 'Consumption'
|
||||||
|
qty = ('%g' % rec.quantity) if rec.quantity else ''
|
||||||
|
job = rec.job_id.name or ''
|
||||||
|
bits = [label]
|
||||||
|
if qty:
|
||||||
|
bits.append('×' + qty)
|
||||||
|
if job:
|
||||||
|
bits.append('(%s)' % job)
|
||||||
|
rec.display_name = ' '.join(bits)
|
||||||
|
|
||||||
|
@api.onchange('product_id')
|
||||||
|
def _onchange_product(self):
|
||||||
|
if self.product_id:
|
||||||
|
self.product_name = self.product_id.display_name
|
||||||
|
self.unit_cost = self.product_id.standard_price or 0.0
|
||||||
|
self.uom_id = self.product_id.uom_id or False
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
# bridge_mrp keeps its version alive so legacy MO-flow keeps working.
|
# bridge_mrp keeps its version alive so legacy MO-flow keeps working.
|
||||||
# Both coexist during the migration period.
|
# Both coexist during the migration period.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpJobNodeOverride(models.Model):
|
class FpJobNodeOverride(models.Model):
|
||||||
@@ -35,6 +35,14 @@ class FpJobNodeOverride(models.Model):
|
|||||||
help='When True, this opt-in/out node is included in step generation.',
|
help='When True, this opt-in/out node is included in step generation.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends('job_id', 'node_id', 'included')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
job = rec.job_id.display_name or '(no job)'
|
||||||
|
node = rec.node_id.display_name or '(no node)'
|
||||||
|
tag = 'included' if rec.included else 'excluded'
|
||||||
|
rec.display_name = '%s · %s [%s]' % (job, node, tag)
|
||||||
|
|
||||||
_unique_job_node = models.Constraint(
|
_unique_job_node = models.Constraint(
|
||||||
'unique(job_id, node_id)',
|
'unique(job_id, node_id)',
|
||||||
'A job can only have one override per recipe node.',
|
'A job can only have one override per recipe node.',
|
||||||
|
|||||||
@@ -13,6 +13,34 @@ from odoo.exceptions import UserError
|
|||||||
class FpJobStep(models.Model):
|
class FpJobStep(models.Model):
|
||||||
_inherit = 'fp.job.step'
|
_inherit = 'fp.job.step'
|
||||||
|
|
||||||
|
def button_start(self):
|
||||||
|
"""Override — soft gate when parts haven't been received yet.
|
||||||
|
|
||||||
|
Doesn't block (parts could be in-transit late, manager wants
|
||||||
|
the shop to start prep regardless), but posts a chatter warning
|
||||||
|
on the job so the audit trail captures premature starts.
|
||||||
|
"""
|
||||||
|
result = super().button_start()
|
||||||
|
for step in self:
|
||||||
|
so = step.job_id.sale_order_id
|
||||||
|
if not so:
|
||||||
|
continue
|
||||||
|
recv = so.x_fc_receiving_status if (
|
||||||
|
'x_fc_receiving_status' in so._fields
|
||||||
|
) else None
|
||||||
|
if recv in (False, None, 'not_received'):
|
||||||
|
step.job_id.message_post(body=_(
|
||||||
|
'Step "%(step)s" started before parts were received '
|
||||||
|
'(SO %(so)s — receiving status: %(status)s). '
|
||||||
|
'Confirm the parts are physically on the floor before '
|
||||||
|
'continuing.'
|
||||||
|
) % {
|
||||||
|
'step': step.name,
|
||||||
|
'so': so.name or '',
|
||||||
|
'status': recv or 'unknown',
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
def button_pause(self):
|
def button_pause(self):
|
||||||
"""Pause an in-progress step (operator break, end of shift).
|
"""Pause an in-progress step (operator break, end of shift).
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,55 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
#
|
#
|
||||||
# Phase 3 — parallel job link on fp.racking.inspection.
|
# Phase 3 / Phase 9 — native-job link on fp.racking.inspection.
|
||||||
# Coexists with the legacy production_id (mrp.production) link.
|
# Coexists with the legacy production_id (mrp.production) link; either
|
||||||
|
# (or both) may be set.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class FpRackingInspection(models.Model):
|
class FpRackingInspection(models.Model):
|
||||||
_inherit = 'fp.racking.inspection'
|
_inherit = 'fp.racking.inspection'
|
||||||
|
|
||||||
x_fc_job_id = fields.Many2one(
|
# x_fc_job_id is declared in the base receiving module so its views
|
||||||
'fp.job',
|
# can reference it. We add help/depends here.
|
||||||
string='Plating Job',
|
|
||||||
index=True,
|
@api.depends('x_fc_job_id.name', 'partner_id.name')
|
||||||
help='Native fp.job link. Coexists with the legacy production_id.',
|
def _compute_name(self):
|
||||||
)
|
for rec in self:
|
||||||
|
if rec.x_fc_job_id:
|
||||||
|
rec.name = _('Inspection — %s') % rec.x_fc_job_id.name
|
||||||
|
else:
|
||||||
|
rec.name = _('Racking Inspection')
|
||||||
|
|
||||||
|
@api.depends('x_fc_job_id.sale_order_id')
|
||||||
|
def _compute_sale_order(self):
|
||||||
|
for rec in self:
|
||||||
|
so = (rec.x_fc_job_id.sale_order_id
|
||||||
|
if rec.x_fc_job_id and rec.x_fc_job_id.sale_order_id
|
||||||
|
else False)
|
||||||
|
rec.sale_order_id = so or False
|
||||||
|
rec.partner_id = so.partner_id if so else False
|
||||||
|
|
||||||
|
@api.constrains('x_fc_job_id')
|
||||||
|
def _check_link_present(self):
|
||||||
|
for rec in self:
|
||||||
|
if not rec.x_fc_job_id:
|
||||||
|
raise ValidationError(_(
|
||||||
|
'Racking inspection must reference a plating job.'
|
||||||
|
))
|
||||||
|
|
||||||
|
@api.constrains('x_fc_job_id')
|
||||||
|
def _check_job_unique(self):
|
||||||
|
for rec in self:
|
||||||
|
if not rec.x_fc_job_id:
|
||||||
|
continue
|
||||||
|
dup = self.search_count([
|
||||||
|
('x_fc_job_id', '=', rec.x_fc_job_id.id),
|
||||||
|
('id', '!=', rec.id),
|
||||||
|
])
|
||||||
|
if dup:
|
||||||
|
raise ValidationError(_(
|
||||||
|
'Only one racking inspection per plating job.'
|
||||||
|
))
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
# bridge_mrp's MO-creation hook handles the flow.
|
# bridge_mrp's MO-creation hook handles the flow.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -18,6 +21,147 @@ _logger = logging.getLogger(__name__)
|
|||||||
class SaleOrder(models.Model):
|
class SaleOrder(models.Model):
|
||||||
_inherit = 'sale.order'
|
_inherit = 'sale.order'
|
||||||
|
|
||||||
|
x_fc_fp_job_count = fields.Integer(
|
||||||
|
string='Plating Jobs',
|
||||||
|
compute='_compute_fp_job_count',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
|
||||||
|
# relocated from fusion_plating_bridge_mrp. Field re-declared with
|
||||||
|
# the same selection + compute pointer; jobs is now the source of
|
||||||
|
# truth so Phase 5 can delete bridge_mrp without losing the field.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
x_fc_workflow_stage = fields.Selection(
|
||||||
|
[
|
||||||
|
('draft', 'Quote'),
|
||||||
|
('awaiting_parts', 'Parts'),
|
||||||
|
('inspecting', 'Inspecting'),
|
||||||
|
('accept_parts', 'Accept'),
|
||||||
|
('assign_work', 'Assign'),
|
||||||
|
('in_production', 'Production'),
|
||||||
|
('ready_to_ship', 'Ready'),
|
||||||
|
('shipped', 'Shipped'),
|
||||||
|
('invoicing', 'Invoicing'),
|
||||||
|
('paid', 'Paid'),
|
||||||
|
('complete', 'Done'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
],
|
||||||
|
compute='_compute_workflow_stage',
|
||||||
|
string='Workflow Stage',
|
||||||
|
help='Current position in the SO → Ship → Invoice workflow. '
|
||||||
|
'Drives which next-step button is shown on the SO header.',
|
||||||
|
)
|
||||||
|
x_fc_assigned_manager_id = fields.Many2one(
|
||||||
|
'res.users', string='Assigned Manager',
|
||||||
|
help='The manager responsible for this job. Set when the job '
|
||||||
|
'is confirmed (falls back to the salesperson).',
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_fp_job_count(self):
|
||||||
|
Job = self.env['fp.job'].sudo()
|
||||||
|
for so in self:
|
||||||
|
so.x_fc_fp_job_count = Job.search_count(
|
||||||
|
[('sale_order_id', '=', so.id)]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_workflow_stage(self):
|
||||||
|
"""Native-jobs override — walks fp.job state instead of mrp.production.
|
||||||
|
|
||||||
|
When `use_native_jobs` is on, the SO is fulfilled by `fp.job`
|
||||||
|
records, not MRP MOs. The bridge_mrp compute reads `mrp.production`
|
||||||
|
and would falsely stall the banner. We branch at the top: native
|
||||||
|
mode → fp.job walker; legacy mode → super() (bridge_mrp).
|
||||||
|
"""
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
native = ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True'
|
||||||
|
if not native:
|
||||||
|
return super()._compute_workflow_stage()
|
||||||
|
|
||||||
|
Job = self.env['fp.job']
|
||||||
|
Delivery = self.env.get('fusion.plating.delivery')
|
||||||
|
for so in self:
|
||||||
|
if so.state == 'cancel':
|
||||||
|
so.x_fc_workflow_stage = 'cancelled'
|
||||||
|
continue
|
||||||
|
if so.state in ('draft', 'sent'):
|
||||||
|
so.x_fc_workflow_stage = 'draft'
|
||||||
|
continue
|
||||||
|
|
||||||
|
jobs = Job.search([('sale_order_id', '=', so.id)])
|
||||||
|
all_jobs_done = bool(jobs) and all(
|
||||||
|
j.state == 'done' for j in jobs
|
||||||
|
)
|
||||||
|
|
||||||
|
shipped = False
|
||||||
|
if Delivery is not None and jobs:
|
||||||
|
if 'x_fc_job_id' in Delivery._fields:
|
||||||
|
shipped = bool(Delivery.search_count([
|
||||||
|
('x_fc_job_id', 'in', jobs.ids),
|
||||||
|
('state', '=', 'delivered'),
|
||||||
|
]))
|
||||||
|
|
||||||
|
posted_invoices = so.invoice_ids.filtered(
|
||||||
|
lambda i: i.state == 'posted'
|
||||||
|
)
|
||||||
|
has_posted_invoice = bool(posted_invoices)
|
||||||
|
all_paid = has_posted_invoice and all(
|
||||||
|
i.payment_state in ('paid', 'in_payment')
|
||||||
|
for i in posted_invoices
|
||||||
|
)
|
||||||
|
|
||||||
|
if shipped and all_paid:
|
||||||
|
so.x_fc_workflow_stage = 'complete'
|
||||||
|
continue
|
||||||
|
if all_paid and not shipped:
|
||||||
|
so.x_fc_workflow_stage = 'paid'
|
||||||
|
continue
|
||||||
|
if shipped and has_posted_invoice:
|
||||||
|
so.x_fc_workflow_stage = 'invoicing'
|
||||||
|
continue
|
||||||
|
if shipped:
|
||||||
|
so.x_fc_workflow_stage = 'shipped'
|
||||||
|
continue
|
||||||
|
if all_jobs_done:
|
||||||
|
so.x_fc_workflow_stage = 'ready_to_ship'
|
||||||
|
continue
|
||||||
|
|
||||||
|
recv_status = so.x_fc_receiving_status or 'not_received'
|
||||||
|
if recv_status == 'not_received':
|
||||||
|
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||||
|
continue
|
||||||
|
if recv_status in ('partial', 'received'):
|
||||||
|
so.x_fc_workflow_stage = 'inspecting'
|
||||||
|
continue
|
||||||
|
if recv_status == 'inspected':
|
||||||
|
if not so.x_fc_assigned_manager_id and not jobs:
|
||||||
|
so.x_fc_workflow_stage = 'assign_work'
|
||||||
|
continue
|
||||||
|
so.x_fc_workflow_stage = 'in_production'
|
||||||
|
continue
|
||||||
|
so.x_fc_workflow_stage = (
|
||||||
|
'in_production' if jobs else 'awaiting_parts'
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_view_fp_jobs(self):
|
||||||
|
self.ensure_one()
|
||||||
|
jobs = self.env['fp.job'].search([('sale_order_id', '=', self.id)])
|
||||||
|
action = {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Plating Jobs'),
|
||||||
|
'res_model': 'fp.job',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('sale_order_id', '=', self.id)],
|
||||||
|
'context': {'default_sale_order_id': self.id},
|
||||||
|
}
|
||||||
|
if len(jobs) == 1:
|
||||||
|
action.update({
|
||||||
|
'view_mode': 'form',
|
||||||
|
'res_id': jobs.id,
|
||||||
|
})
|
||||||
|
return action
|
||||||
|
|
||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
result = super().action_confirm()
|
result = super().action_confirm()
|
||||||
# Only run when the native flag is on
|
# Only run when the native flag is on
|
||||||
@@ -25,6 +169,22 @@ class SaleOrder(models.Model):
|
|||||||
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
|
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
|
||||||
for so in self:
|
for so in self:
|
||||||
so._fp_auto_create_job()
|
so._fp_auto_create_job()
|
||||||
|
# Auto-confirm any draft jobs we just created so steps
|
||||||
|
# generate immediately (no manager click required).
|
||||||
|
# Best-effort: an exception in side-effects shouldn't
|
||||||
|
# block the SO confirm itself.
|
||||||
|
draft_jobs = self.env['fp.job'].sudo().search([
|
||||||
|
('sale_order_id', '=', so.id),
|
||||||
|
('state', '=', 'draft'),
|
||||||
|
])
|
||||||
|
for job in draft_jobs:
|
||||||
|
try:
|
||||||
|
job.action_confirm()
|
||||||
|
except Exception as exc:
|
||||||
|
so.message_post(body=_(
|
||||||
|
'Auto-confirm of fp.job %(job)s failed: %(err)s. '
|
||||||
|
'Confirm manually from the job form.'
|
||||||
|
) % {'job': job.name, 'err': exc})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _fp_auto_create_job(self):
|
def _fp_auto_create_job(self):
|
||||||
@@ -121,3 +281,94 @@ class SaleOrder(models.Model):
|
|||||||
self.name, job.name, qty, (recipe.name if recipe else '-'),
|
self.name, job.name, qty, (recipe.name if recipe else '-'),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Phase 4 (Sub 11) — workflow stage action buttons.
|
||||||
|
# Native versions of bridge_mrp's action_fp_* methods. Drop the
|
||||||
|
# mrp.production lookups; talk to fp.job and fp.receiving directly.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def action_fp_mark_inspected(self):
|
||||||
|
"""Flip open receivings from draft → inspecting."""
|
||||||
|
self.ensure_one()
|
||||||
|
Recv = self.env.get('fp.receiving')
|
||||||
|
if Recv is None:
|
||||||
|
return False
|
||||||
|
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||||
|
if rec.state == 'draft':
|
||||||
|
rec.state = 'inspecting'
|
||||||
|
self.message_post(body=_('Parts marked as inspecting.'))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_fp_accept_parts(self):
|
||||||
|
"""Mark receiving accepted; flip SO receiving status to inspected."""
|
||||||
|
self.ensure_one()
|
||||||
|
Recv = self.env.get('fp.receiving')
|
||||||
|
if Recv is None:
|
||||||
|
return False
|
||||||
|
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||||
|
if rec.state in ('draft', 'inspecting'):
|
||||||
|
rec.state = 'accepted'
|
||||||
|
if 'x_fc_receiving_status' in self._fields:
|
||||||
|
self.x_fc_receiving_status = 'inspected'
|
||||||
|
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_fp_assign_to_me(self):
|
||||||
|
"""Manager claims the SO and confirms its draft fp.jobs."""
|
||||||
|
self.ensure_one()
|
||||||
|
user = self.env.user
|
||||||
|
self.x_fc_assigned_manager_id = user.id
|
||||||
|
Job = self.env['fp.job']
|
||||||
|
jobs = Job.search([
|
||||||
|
('sale_order_id', '=', self.id),
|
||||||
|
('state', '=', 'draft'),
|
||||||
|
])
|
||||||
|
for job in jobs:
|
||||||
|
try:
|
||||||
|
job.action_confirm()
|
||||||
|
except Exception as exc:
|
||||||
|
self.message_post(body=_(
|
||||||
|
'Auto-confirm of fp.job %s failed: %s'
|
||||||
|
) % (job.name, exc))
|
||||||
|
if 'manager_id' in job._fields and not job.manager_id:
|
||||||
|
job.manager_id = user.id
|
||||||
|
self.message_post(
|
||||||
|
body=Markup(_(
|
||||||
|
'Job assigned to <b>%s</b>. %d plating job(s) released to the floor.'
|
||||||
|
)) % (user.name, len(jobs)),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_fp_mark_shipped(self):
|
||||||
|
"""Mark linked deliveries delivered (triggers auto-invoice)."""
|
||||||
|
self.ensure_one()
|
||||||
|
Delivery = self.env.get('fusion.plating.delivery')
|
||||||
|
if Delivery is None:
|
||||||
|
return False
|
||||||
|
Job = self.env['fp.job']
|
||||||
|
jobs = Job.search([('sale_order_id', '=', self.id)])
|
||||||
|
deliveries = Delivery.browse([])
|
||||||
|
if 'x_fc_job_id' in Delivery._fields:
|
||||||
|
deliveries = Delivery.search([
|
||||||
|
('x_fc_job_id', 'in', jobs.ids),
|
||||||
|
('state', '!=', 'delivered'),
|
||||||
|
])
|
||||||
|
for dlv in deliveries:
|
||||||
|
dlv.action_mark_delivered()
|
||||||
|
self.message_post(
|
||||||
|
body=_(
|
||||||
|
'%d delivery record(s) marked delivered. '
|
||||||
|
'Invoice flow triggered per invoice strategy.'
|
||||||
|
) % len(deliveries),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_fp_open_shop_floor(self):
|
||||||
|
"""Jump to the Plant Overview filtered to this SO's jobs."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'fp_plant_overview',
|
||||||
|
'name': _('Shop Floor — %s') % self.name,
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
|||||||
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fp_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fp_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||||
|
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_job_consumption_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.job.consumption.list</field>
|
||||||
|
<field name="model">fp.job.consumption</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list editable="bottom" default_order="logged_date desc">
|
||||||
|
<field name="logged_date"/>
|
||||||
|
<field name="job_id"/>
|
||||||
|
<field name="step_id"/>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="quantity"/>
|
||||||
|
<field name="uom_id"/>
|
||||||
|
<field name="currency_id" column_invisible="1"/>
|
||||||
|
<field name="unit_cost" widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
|
<field name="total_cost" widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
||||||
|
<field name="source"/>
|
||||||
|
<field name="logged_by_id" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_job_consumption_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.job.consumption.form</field>
|
||||||
|
<field name="model">fp.job.consumption</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="job_id"/>
|
||||||
|
<field name="step_id"/>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="product_name"/>
|
||||||
|
<field name="source"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="quantity"/>
|
||||||
|
<field name="uom_id"/>
|
||||||
|
<field name="currency_id"/>
|
||||||
|
<field name="unit_cost" widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
|
<field name="total_cost" widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}" readonly="1"/>
|
||||||
|
<field name="logged_date"/>
|
||||||
|
<field name="logged_by_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Notes">
|
||||||
|
<field name="notes" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_job_consumption" model="ir.actions.act_window">
|
||||||
|
<field name="name">Job Consumables Log</field>
|
||||||
|
<field name="res_model">fp.job.consumption</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
Phase 3 (Sub 11) — native Production Priorities kanban / list on
|
||||||
|
fp.job.step. Replaces the bridge_mrp version that bound to
|
||||||
|
mrp.workorder. Same UX (drag-drop ordering across work centres,
|
||||||
|
list with handle, badges by state).
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_step_priority_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">fp.job.step.priority.kanban</field>
|
||||||
|
<field name="model">fp.job.step</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban default_group_by="work_centre_id" default_order="sequence, id">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="work_centre_id"/>
|
||||||
|
<field name="job_id"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="duration_actual"/>
|
||||||
|
<field name="duration_expected"/>
|
||||||
|
<field name="assigned_user_id"/>
|
||||||
|
<templates>
|
||||||
|
<t t-name="card">
|
||||||
|
<div class="oe_kanban_card oe_kanban_global_click">
|
||||||
|
<div class="o_kanban_record_top mb-0">
|
||||||
|
<div class="o_kanban_record_headings">
|
||||||
|
<strong class="o_kanban_record_title">
|
||||||
|
<field name="job_id"/>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_body">
|
||||||
|
<div><strong><field name="name"/></strong></div>
|
||||||
|
<div class="text-muted">
|
||||||
|
<t t-if="record.assigned_user_id.raw_value">
|
||||||
|
<field name="assigned_user_id"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="record.duration_actual.raw_value">
|
||||||
|
— <field name="duration_actual" widget="float_time"/> elapsed
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_kanban_record_bottom">
|
||||||
|
<div class="oe_kanban_bottom_left">
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'ready'"
|
||||||
|
decoration-warning="state == 'in_progress'"
|
||||||
|
decoration-muted="state == 'paused'"
|
||||||
|
decoration-success="state == 'done'"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_step_priority_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.job.step.priority.list</field>
|
||||||
|
<field name="model">fp.job.step</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list default_order="sequence, id">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="job_id"/>
|
||||||
|
<field name="work_centre_id"/>
|
||||||
|
<field name="assigned_user_id"/>
|
||||||
|
<field name="duration_expected" widget="float_time"/>
|
||||||
|
<field name="duration_actual" widget="float_time"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-info="state == 'ready'"
|
||||||
|
decoration-warning="state == 'in_progress'"
|
||||||
|
decoration-muted="state == 'paused'"
|
||||||
|
decoration-success="state == 'done'"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_step_priority" model="ir.actions.act_window">
|
||||||
|
<field name="name">Production Priorities</field>
|
||||||
|
<field name="res_model">fp.job.step</field>
|
||||||
|
<field name="view_mode">kanban,list,form</field>
|
||||||
|
<field name="domain">[('state', 'in', ['pending', 'ready', 'in_progress', 'paused'])]</field>
|
||||||
|
<field name="view_ids" eval="[(5, 0, 0),
|
||||||
|
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_fp_step_priority_kanban')}),
|
||||||
|
(0, 0, {'view_mode': 'list', 'view_id': ref('view_fp_step_priority_list')})]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_step_priority"
|
||||||
|
name="Production Priorities"
|
||||||
|
parent="fusion_plating.menu_fp_operations"
|
||||||
|
action="action_fp_step_priority"
|
||||||
|
sequence="10"
|
||||||
|
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
Adds the SO → fp.job smart button so the SO form is a hub for the
|
||||||
|
native job lifecycle (replaces the legacy MO smart button when the
|
||||||
|
use_native_jobs flag is on).
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_sale_order_form_fp_jobs" model="ir.ui.view">
|
||||||
|
<field name="name">sale.order.form.fp.jobs</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<!-- After the legacy Manufacturing button (added by bridge_mrp).
|
||||||
|
Always visible (no invisible-on-zero) so users can navigate
|
||||||
|
from the SO even before jobs exist. -->
|
||||||
|
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
|
||||||
|
<button name="action_view_fp_jobs" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-cogs">
|
||||||
|
<field name="x_fc_fp_job_count" widget="statinfo"
|
||||||
|
string="Plating Jobs"/>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Notifications',
|
'name': 'Fusion Plating — Notifications',
|
||||||
'version': '19.0.5.0.0',
|
'version': '19.0.6.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
@@ -20,12 +20,10 @@
|
|||||||
'fusion_plating_certificates',
|
'fusion_plating_certificates',
|
||||||
'fusion_plating_receiving',
|
'fusion_plating_receiving',
|
||||||
'fusion_plating_invoicing',
|
'fusion_plating_invoicing',
|
||||||
'fusion_plating_bridge_mrp',
|
|
||||||
'fusion_plating_logistics',
|
'fusion_plating_logistics',
|
||||||
'fusion_plating_reports',
|
'fusion_plating_reports',
|
||||||
'sale_management',
|
'sale_management',
|
||||||
'account',
|
'account',
|
||||||
'mrp',
|
|
||||||
'mail',
|
'mail',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
|
|||||||
@@ -179,58 +179,10 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- ============================================================= -->
|
<!-- Phase 5 (Sub 11) — fp_mail_template_mo_complete removed.
|
||||||
<!-- 4. Manufacturing Complete (Info, #2B6CB0) -->
|
The native equivalent fires from fp.job.button_mark_done via
|
||||||
<!-- ============================================================= -->
|
fp.notification.template's `job_complete` trigger, defined
|
||||||
<record id="fp_mail_template_mo_complete" model="mail.template">
|
in fp_notification_template_data.xml. -->
|
||||||
<field name="name">FP: Manufacturing Complete</field>
|
|
||||||
<field name="model_id" ref="mrp.model_mrp_production"/>
|
|
||||||
<field name="subject">Job Complete — {{ object.x_fc_portal_job_id.name or object.name }}</field>
|
|
||||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
|
||||||
<field name="email_to">{{ object.x_fc_portal_job_id.partner_id.email }}</field>
|
|
||||||
<field name="auto_delete" eval="True"/>
|
|
||||||
<field name="body_html" type="html">
|
|
||||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
|
||||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
|
||||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
|
||||||
EN Technologies
|
|
||||||
</div>
|
|
||||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Manufacturing Complete — Ready to Ship</h2>
|
|
||||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
|
||||||
Hi <t t-out="object.x_fc_portal_job_id.partner_id.name or ''"/>, your job has cleared production and quality. We are preparing it for shipment.
|
|
||||||
</p>
|
|
||||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
|
||||||
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
|
|
||||||
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
|
|
||||||
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Value</th>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
|
||||||
<td style="padding: 8px 4px;">Job Reference</td>
|
|
||||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.x_fc_portal_job_id.name or object.name"/></td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
|
||||||
<td style="padding: 8px 4px;">Sale Order</td>
|
|
||||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.origin or '—'"/></td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
|
||||||
<td style="padding: 8px 4px;">Quantity</td>
|
|
||||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="int(object.product_qty)"/></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
|
|
||||||
<strong>Next:</strong> Your Certificate of Conformance will be issued with the shipment. Delivery scheduling to follow.
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 32px; font-size: 14px;">
|
|
||||||
Best regards,<br/>
|
|
||||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
|
||||||
EN Technologies Inc.
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
|
||||||
This is an automated notification from EN Technologies production system.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- ============================================================= -->
|
<!-- ============================================================= -->
|
||||||
<!-- 5. Shipped / Delivered (Success, #38a169) -->
|
<!-- 5. Shipped / Delivered (Success, #38a169) -->
|
||||||
|
|||||||
@@ -10,5 +10,7 @@ from . import sale_order
|
|||||||
from . import fp_receiving
|
from . import fp_receiving
|
||||||
from . import account_move
|
from . import account_move
|
||||||
from . import account_payment
|
from . import account_payment
|
||||||
from . import mrp_production
|
# Phase 5 (Sub 11) — mrp.production hook retired. The native equivalent
|
||||||
|
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
|
||||||
|
# from . import mrp_production
|
||||||
from . import fp_delivery
|
from . import fp_delivery
|
||||||
|
|||||||
@@ -12,19 +12,18 @@ class FpDelivery(models.Model):
|
|||||||
def action_mark_delivered(self):
|
def action_mark_delivered(self):
|
||||||
res = super().action_mark_delivered()
|
res = super().action_mark_delivered()
|
||||||
Dispatch = self.env['fp.notification.template']
|
Dispatch = self.env['fp.notification.template']
|
||||||
|
Job = self.env.get('fp.job')
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.partner_id:
|
if not rec.partner_id:
|
||||||
continue
|
continue
|
||||||
so = False
|
so = False
|
||||||
if rec.job_ref:
|
# Native: fp.job direct link.
|
||||||
# Delivery's job_ref is the MO name; find the SO via MO origin.
|
if Job is not None and 'x_fc_job_id' in rec._fields and rec.x_fc_job_id:
|
||||||
mo = self.env['mrp.production'].search(
|
so = rec.x_fc_job_id.sale_order_id or False
|
||||||
[('name', '=', rec.job_ref)], limit=1,
|
elif Job is not None and rec.job_ref:
|
||||||
)
|
job = Job.search([('name', '=', rec.job_ref)], limit=1)
|
||||||
if mo and mo.origin:
|
if job:
|
||||||
so = self.env['sale.order'].search(
|
so = job.sale_order_id or False
|
||||||
[('name', '=', mo.origin)], limit=1,
|
|
||||||
)
|
|
||||||
# Sub 6 — pass the delivery address so location-scoped
|
# Sub 6 — pass the delivery address so location-scoped
|
||||||
# contacts receive the 'shipped' notification.
|
# contacts receive the 'shipped' notification.
|
||||||
Dispatch._dispatch(
|
Dispatch._dispatch(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
from .fp_notification_template import TRIGGER_EVENTS
|
from .fp_notification_template import TRIGGER_EVENTS
|
||||||
|
|
||||||
@@ -29,3 +29,20 @@ class FpNotificationLog(models.Model):
|
|||||||
)
|
)
|
||||||
error_message = fields.Text(string='Error Message')
|
error_message = fields.Text(string='Error Message')
|
||||||
mail_mail_id = fields.Many2one('mail.mail', string='Mail Record')
|
mail_mail_id = fields.Many2one('mail.mail', string='Mail Record')
|
||||||
|
|
||||||
|
@api.depends('template_id', 'partner_id', 'sent_date', 'status')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
trigger_labels = dict(self._fields['trigger_event'].selection)
|
||||||
|
for rec in self:
|
||||||
|
bits = []
|
||||||
|
label = (
|
||||||
|
rec.template_id.display_name
|
||||||
|
or trigger_labels.get(rec.trigger_event)
|
||||||
|
or 'Notification'
|
||||||
|
)
|
||||||
|
bits.append(label)
|
||||||
|
if rec.partner_id:
|
||||||
|
bits.append('→ %s' % rec.partner_id.display_name)
|
||||||
|
if rec.sent_date:
|
||||||
|
bits.append(rec.sent_date.strftime('%Y-%m-%d %H:%M'))
|
||||||
|
rec.display_name = ' '.join(bits)
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Quality (QMS)',
|
'name': 'Fusion Plating — Quality (QMS)',
|
||||||
'version': '19.0.2.3.0',
|
'version': '19.0.3.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||||
@@ -67,6 +67,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'depends': [
|
'depends': [
|
||||||
'fusion_plating',
|
'fusion_plating',
|
||||||
'fusion_plating_configurator',
|
'fusion_plating_configurator',
|
||||||
|
'fusion_plating_certificates', # fp.thickness.reading link from QC
|
||||||
|
'fusion_plating_shopfloor', # _fp_shopfloor_tokens.scss for QC tablet
|
||||||
'mail',
|
'mail',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
@@ -74,6 +76,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/fp_sequence_data.xml',
|
'data/fp_sequence_data.xml',
|
||||||
'data/fp_quality_hold_sequence_data.xml',
|
'data/fp_quality_hold_sequence_data.xml',
|
||||||
|
'data/fp_qc_data.xml',
|
||||||
|
'views/fp_qc_template_views.xml',
|
||||||
'views/fp_quality_hold_views.xml',
|
'views/fp_quality_hold_views.xml',
|
||||||
'views/fp_ncr_views.xml',
|
'views/fp_ncr_views.xml',
|
||||||
'views/fp_capa_views.xml',
|
'views/fp_capa_views.xml',
|
||||||
@@ -84,9 +88,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'views/fp_fair_views.xml',
|
'views/fp_fair_views.xml',
|
||||||
'views/fp_doc_control_views.xml',
|
'views/fp_doc_control_views.xml',
|
||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
|
'views/res_partner_qc_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/fp_contract_review_views.xml',
|
'views/fp_contract_review_views.xml',
|
||||||
'views/fp_part_catalog_views.xml',
|
'views/fp_part_catalog_views.xml',
|
||||||
|
'views/fp_quality_check_views.xml',
|
||||||
'reports/fp_contract_review_report.xml',
|
'reports/fp_contract_review_report.xml',
|
||||||
'reports/fp_contract_review_template.xml',
|
'reports/fp_contract_review_template.xml',
|
||||||
'views/fp_menu.xml',
|
'views/fp_menu.xml',
|
||||||
@@ -97,6 +103,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
'fusion_plating_quality/static/src/scss/fusion_plating_quality.scss',
|
'fusion_plating_quality/static/src/scss/fusion_plating_quality.scss',
|
||||||
|
# Phase 2 (Sub 11) — QC tablet OWL relocated from bridge_mrp.
|
||||||
|
'fusion_plating_quality/static/src/scss/fp_qc_checklist.scss',
|
||||||
|
'fusion_plating_quality/static/src/xml/fp_qc_checklist.xml',
|
||||||
|
'fusion_plating_quality/static/src/js/fp_qc_checklist.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import fp_qc_controller
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""HTTP endpoints for the mobile QC checklist OWL client action.
|
||||||
|
|
||||||
|
Kept narrow (read state + mark-pass/fail + upload PDF + finalize). The
|
||||||
|
OWL component is purely a thin client over these endpoints so any
|
||||||
|
future native mobile app can reuse the same API.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http, _
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FpQcController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _check(check_id):
|
||||||
|
"""Resolve and access-check a QC record."""
|
||||||
|
if not check_id:
|
||||||
|
return False
|
||||||
|
check = request.env['fusion.plating.quality.check'].browse(
|
||||||
|
int(check_id)
|
||||||
|
).exists()
|
||||||
|
if not check:
|
||||||
|
return False
|
||||||
|
check.check_access('read')
|
||||||
|
return check
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _line_payload(line):
|
||||||
|
return {
|
||||||
|
'id': line.id,
|
||||||
|
'sequence': line.sequence,
|
||||||
|
'name': line.name,
|
||||||
|
'description': line.description or '',
|
||||||
|
'check_type': line.check_type,
|
||||||
|
'required': line.required,
|
||||||
|
'requires_value': line.requires_value,
|
||||||
|
'value': line.value,
|
||||||
|
'value_min': line.value_min,
|
||||||
|
'value_max': line.value_max,
|
||||||
|
'value_uom': line.value_uom or '',
|
||||||
|
'value_in_range': line.value_in_range,
|
||||||
|
'requires_photo': line.requires_photo,
|
||||||
|
'has_photo': bool(line.photo_attachment_id),
|
||||||
|
'photo_attachment_id': line.photo_attachment_id.id or False,
|
||||||
|
'result': line.result or 'pending',
|
||||||
|
'notes': line.notes or '',
|
||||||
|
'inspector_name': (
|
||||||
|
line.inspector_id.name if line.inspector_id else ''
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_payload(check):
|
||||||
|
return {
|
||||||
|
'id': check.id,
|
||||||
|
'name': check.name,
|
||||||
|
'state': check.state,
|
||||||
|
'overall_result': check.overall_result or '',
|
||||||
|
'job_id': check.job_id.id if check.job_id else False,
|
||||||
|
'job_name': check.job_id.name if check.job_id else '',
|
||||||
|
'partner_name': (
|
||||||
|
check.partner_id.name if check.partner_id else ''
|
||||||
|
),
|
||||||
|
'template_name': (
|
||||||
|
check.template_id.name if check.template_id else ''
|
||||||
|
),
|
||||||
|
'inspector_name': (
|
||||||
|
check.inspector_id.name if check.inspector_id else ''
|
||||||
|
),
|
||||||
|
'line_count': check.line_count,
|
||||||
|
'lines_passed': check.lines_passed,
|
||||||
|
'lines_failed': check.lines_failed,
|
||||||
|
'lines_pending': check.lines_pending,
|
||||||
|
'require_thickness_readings': check.require_thickness_readings,
|
||||||
|
'require_thickness_report_pdf': check.require_thickness_report_pdf,
|
||||||
|
'has_thickness_pdf': bool(check.thickness_report_pdf_id),
|
||||||
|
'thickness_reading_count': check.thickness_reading_count,
|
||||||
|
'notes': check.notes or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET state — OWL calls this on mount + after every action
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/fp/qc/get', type='jsonrpc', auth='user', methods=['POST'],
|
||||||
|
)
|
||||||
|
def get_state(self, check_id=None, job_id=None, **kw):
|
||||||
|
check = self._check(check_id)
|
||||||
|
if not check and job_id:
|
||||||
|
# Resolve latest active QC for this fp.job
|
||||||
|
check = request.env['fusion.plating.quality.check'].search([
|
||||||
|
('job_id', '=', int(job_id)),
|
||||||
|
], order='create_date desc', limit=1)
|
||||||
|
if not check:
|
||||||
|
return {'ok': False, 'error': 'no_qc'}
|
||||||
|
if not check:
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'check': self._check_payload(check),
|
||||||
|
'lines': [
|
||||||
|
self._line_payload(l)
|
||||||
|
for l in check.line_ids.sorted('sequence')
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Line actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/fp/qc/line/mark', type='jsonrpc', auth='user', methods=['POST'],
|
||||||
|
)
|
||||||
|
def line_mark(self, check_id=None, line_id=None, result=None,
|
||||||
|
value=None, notes=None, **kw):
|
||||||
|
check = self._check(check_id)
|
||||||
|
if not check:
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
Line = request.env['fusion.plating.quality.check.line']
|
||||||
|
line = Line.browse(int(line_id)).exists()
|
||||||
|
if not line or line.check_id.id != check.id:
|
||||||
|
return {'ok': False, 'error': 'invalid_line'}
|
||||||
|
|
||||||
|
# Start the check if it's still draft
|
||||||
|
if check.state == 'draft':
|
||||||
|
check.action_start()
|
||||||
|
|
||||||
|
# Numeric value handling — write before action to let
|
||||||
|
# _compute_value_in_range update the record.
|
||||||
|
vals = {}
|
||||||
|
if value is not None and line.requires_value:
|
||||||
|
try:
|
||||||
|
vals['value'] = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'ok': False, 'error': 'invalid_value'}
|
||||||
|
if notes is not None:
|
||||||
|
vals['notes'] = notes
|
||||||
|
if vals:
|
||||||
|
line.write(vals)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if result == 'pass':
|
||||||
|
line.action_mark_pass()
|
||||||
|
elif result == 'fail':
|
||||||
|
line.action_mark_fail()
|
||||||
|
elif result == 'na':
|
||||||
|
line.action_mark_na()
|
||||||
|
elif result == 'pending':
|
||||||
|
line.write({
|
||||||
|
'result': 'pending',
|
||||||
|
'inspector_id': False,
|
||||||
|
'completed_at': False,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'line': self._line_payload(line),
|
||||||
|
'check': self._check_payload(check),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Photo upload for an individual line
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/fp/qc/line/photo', type='http', auth='user', methods=['POST'],
|
||||||
|
csrf=False,
|
||||||
|
)
|
||||||
|
def line_photo(self, line_id=None, **kw):
|
||||||
|
Line = request.env['fusion.plating.quality.check.line']
|
||||||
|
line = Line.browse(int(line_id)).exists()
|
||||||
|
if not line:
|
||||||
|
return request.make_json_response(
|
||||||
|
{'ok': False, 'error': 'invalid_line'},
|
||||||
|
)
|
||||||
|
upload = request.httprequest.files.get('file')
|
||||||
|
if not upload:
|
||||||
|
return request.make_json_response(
|
||||||
|
{'ok': False, 'error': 'no_file'},
|
||||||
|
)
|
||||||
|
data = upload.read()
|
||||||
|
if not data:
|
||||||
|
return request.make_json_response(
|
||||||
|
{'ok': False, 'error': 'empty_file'},
|
||||||
|
)
|
||||||
|
att = request.env['ir.attachment'].create({
|
||||||
|
'name': upload.filename or 'qc_photo.jpg',
|
||||||
|
'type': 'binary',
|
||||||
|
'datas': base64.b64encode(data),
|
||||||
|
'res_model': 'fusion.plating.quality.check.line',
|
||||||
|
'res_id': line.id,
|
||||||
|
'mimetype': upload.mimetype or 'image/jpeg',
|
||||||
|
})
|
||||||
|
line.write({'photo_attachment_id': att.id})
|
||||||
|
return request.make_json_response({
|
||||||
|
'ok': True,
|
||||||
|
'attachment_id': att.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fischerscope PDF upload
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/fp/qc/thickness_pdf', type='http', auth='user',
|
||||||
|
methods=['POST'], csrf=False,
|
||||||
|
)
|
||||||
|
def thickness_pdf(self, check_id=None, **kw):
|
||||||
|
check = self._check(check_id)
|
||||||
|
if not check:
|
||||||
|
return request.make_json_response(
|
||||||
|
{'ok': False, 'error': 'not_found'},
|
||||||
|
)
|
||||||
|
upload = request.httprequest.files.get('file')
|
||||||
|
if not upload:
|
||||||
|
return request.make_json_response(
|
||||||
|
{'ok': False, 'error': 'no_file'},
|
||||||
|
)
|
||||||
|
data = upload.read()
|
||||||
|
if not data:
|
||||||
|
return request.make_json_response(
|
||||||
|
{'ok': False, 'error': 'empty_file'},
|
||||||
|
)
|
||||||
|
att = request.env['ir.attachment'].create({
|
||||||
|
'name': upload.filename or 'thickness_report.pdf',
|
||||||
|
'type': 'binary',
|
||||||
|
'datas': base64.b64encode(data),
|
||||||
|
'res_model': 'fusion.plating.quality.check',
|
||||||
|
'res_id': check.id,
|
||||||
|
'mimetype': upload.mimetype or 'application/pdf',
|
||||||
|
})
|
||||||
|
# Triggers _on_thickness_pdf_uploaded via write() override.
|
||||||
|
check.write({'thickness_report_pdf_id': att.id})
|
||||||
|
return request.make_json_response({
|
||||||
|
'ok': True,
|
||||||
|
'attachment_id': att.id,
|
||||||
|
'reading_count': check.thickness_reading_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Check-level actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/fp/qc/finalize', type='jsonrpc', auth='user', methods=['POST'],
|
||||||
|
)
|
||||||
|
def finalize(self, check_id=None, result=None, notes=None, **kw):
|
||||||
|
check = self._check(check_id)
|
||||||
|
if not check:
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
if notes is not None:
|
||||||
|
check.write({'notes': notes})
|
||||||
|
try:
|
||||||
|
if result == 'pass':
|
||||||
|
check.action_pass()
|
||||||
|
elif result == 'fail':
|
||||||
|
check.action_fail()
|
||||||
|
elif result == 'rework':
|
||||||
|
check.action_rework()
|
||||||
|
else:
|
||||||
|
return {'ok': False, 'error': 'invalid_result'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'ok': False, 'error': str(e)}
|
||||||
|
return {'ok': True, 'check': self._check_payload(check)}
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/fp/qc/start', type='jsonrpc', auth='user', methods=['POST'],
|
||||||
|
)
|
||||||
|
def start(self, check_id=None, **kw):
|
||||||
|
check = self._check(check_id)
|
||||||
|
if not check:
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
if check.state == 'draft':
|
||||||
|
check.action_start()
|
||||||
|
return {'ok': True, 'check': self._check_payload(check)}
|
||||||
161
fusion_plating/fusion_plating_quality/data/fp_qc_data.xml
Normal file
161
fusion_plating/fusion_plating_quality/data/fp_qc_data.xml
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Sequence for QC checks + a starter default template so QC works
|
||||||
|
out of the box for any customer that has x_fc_requires_qc=True
|
||||||
|
but no per-customer template yet.
|
||||||
|
-->
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<!-- ===== Sequence ===== -->
|
||||||
|
<record id="seq_fp_quality_check" model="ir.sequence">
|
||||||
|
<field name="name">Fusion Plating: Quality Check</field>
|
||||||
|
<field name="code">fusion.plating.quality.check</field>
|
||||||
|
<field name="prefix">QC/%(year)s/</field>
|
||||||
|
<field name="padding">4</field>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===== Default checklist template (global — partner_id blank) =====
|
||||||
|
sequence=5 so it wins over any other global template when
|
||||||
|
resolve_for_partner falls back from a missing per-customer match. -->
|
||||||
|
<record id="qc_template_default" model="fp.qc.checklist.template">
|
||||||
|
<field name="name">Standard Plating QC</field>
|
||||||
|
<field name="sequence">5</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="require_inspector_signoff">True</field>
|
||||||
|
<field name="require_thickness_readings">False</field>
|
||||||
|
<field name="require_thickness_report_pdf">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_line_visual" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_default"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="name">Visual — no pits, burns, or bare spots</field>
|
||||||
|
<field name="description">Examine the entire plated surface under shop lighting. Look for pits, burns, dewetting, bare spots, or rough texture. Reject if any defect is visible to the naked eye.</field>
|
||||||
|
<field name="check_type">visual</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_line_colour" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_default"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="name">Colour — uniform finish across part</field>
|
||||||
|
<field name="description">Finish should be uniform with no streaking, blotching, or dull-vs-bright zones. Compare against the customer colour sample if one is on file.</field>
|
||||||
|
<field name="check_type">visual</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_line_adhesion" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_default"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="name">Adhesion — tape test pass</field>
|
||||||
|
<field name="description">Apply tape to an inconspicuous area, press firmly for 3 seconds, pull at 90°. No flaking permitted.</field>
|
||||||
|
<field name="check_type">adhesion</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_line_masking" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_default"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="name">Masking — no plating in masked zones</field>
|
||||||
|
<field name="description">Areas that were masked per customer print must be free of plating deposit. Light staining acceptable; build-up is not.</field>
|
||||||
|
<field name="check_type">visual</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_line_quantity" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_default"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="name">Quantity — matches WO count</field>
|
||||||
|
<field name="description">Count the parts. Must equal the WO quantity minus any documented rework/scrap.</field>
|
||||||
|
<field name="check_type">functional</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_line_packaging" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_default"/>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="name">Packaging — parts protected for shipping</field>
|
||||||
|
<field name="description">Parts individually bagged / padded, no direct metal-on-metal contact that could scratch the finish in transit.</field>
|
||||||
|
<field name="check_type">visual</field>
|
||||||
|
<field name="required">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===== Aerospace checklist (stricter — used as a starter for
|
||||||
|
Nadcap customers; admin copies and reassigns to partner) ===== -->
|
||||||
|
<record id="qc_template_aerospace" model="fp.qc.checklist.template">
|
||||||
|
<field name="name">Aerospace / Nadcap QC</field>
|
||||||
|
<field name="sequence">100</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="require_inspector_signoff">True</field>
|
||||||
|
<field name="require_thickness_readings">True</field>
|
||||||
|
<field name="require_thickness_report_pdf">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_aero_visual" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_aerospace"/>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="name">Visual — 10× loupe, no discontinuities</field>
|
||||||
|
<field name="description">Inspect under 10× magnification. Reject any pit, crack, inclusion, or discontinuity visible at that power.</field>
|
||||||
|
<field name="check_type">visual</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
<field name="requires_photo">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_aero_thickness_1" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_aerospace"/>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="name">Thickness — Fischerscope reading #1</field>
|
||||||
|
<field name="description">Fischerscope XDAL 600 XRF measurement at primary inspection point. Value must fall inside the customer spec range. Record the NiP mils reading.</field>
|
||||||
|
<field name="check_type">thickness</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
<field name="requires_value">True</field>
|
||||||
|
<field name="value_uom">mils</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_aero_thickness_2" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_aerospace"/>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
<field name="name">Thickness — Fischerscope reading #2</field>
|
||||||
|
<field name="description">Second XRF point — per customer print's secondary inspection location.</field>
|
||||||
|
<field name="check_type">thickness</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
<field name="requires_value">True</field>
|
||||||
|
<field name="value_uom">mils</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_aero_thickness_3" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_aerospace"/>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
<field name="name">Thickness — Fischerscope reading #3</field>
|
||||||
|
<field name="description">Third XRF point — per customer print's tertiary inspection location.</field>
|
||||||
|
<field name="check_type">thickness</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
<field name="requires_value">True</field>
|
||||||
|
<field name="value_uom">mils</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_aero_adhesion" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_aerospace"/>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="name">Adhesion — ASTM B571 tape test</field>
|
||||||
|
<field name="description">Apply ASTM B571 tape to freshly-scribed area, remove at 90° per standard. No flaking of plating permitted.</field>
|
||||||
|
<field name="check_type">adhesion</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="qc_tpl_aero_dimensional" model="fp.qc.checklist.template.line">
|
||||||
|
<field name="template_id" ref="qc_template_aerospace"/>
|
||||||
|
<field name="sequence">60</field>
|
||||||
|
<field name="name">Dimensional — critical feature verification</field>
|
||||||
|
<field name="description">Caliper / mic any feature marked critical on the customer print. Confirm plating did not push dimensions out of tolerance.</field>
|
||||||
|
<field name="check_type">dimensional</field>
|
||||||
|
<field name="required">True</field>
|
||||||
|
<field name="requires_value">False</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocate fusion.plating.quality.check (+line)
|
||||||
|
# from fusion_plating_bridge_mrp to fusion_plating_quality.
|
||||||
|
# Drop the legacy production_id column on the existing table.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return # Fresh install — nothing to migrate
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
'model_fusion_plating_quality_check',
|
||||||
|
'model_fusion_plating_quality_check_line',
|
||||||
|
'model_fp_qc_checklist_template',
|
||||||
|
'model_fp_qc_checklist_template_line',
|
||||||
|
'access_fp_qc_check_%',
|
||||||
|
'access_fp_qc_check_line_%',
|
||||||
|
'access_fp_qc_template_%',
|
||||||
|
'access_fp_qc_template_line_%',
|
||||||
|
'view_fp_quality_check%',
|
||||||
|
'view_fp_qc_template_%',
|
||||||
|
'fp_quality_check_%',
|
||||||
|
'action_fp_quality_check%',
|
||||||
|
'action_fp_qc_template%',
|
||||||
|
'menu_fp_quality_check%',
|
||||||
|
'menu_fp_qc_template%',
|
||||||
|
'qc_template_default',
|
||||||
|
'qc_template_aerospace',
|
||||||
|
'qc_tpl_%',
|
||||||
|
'seq_fp_quality_check',
|
||||||
|
]
|
||||||
|
for pat in patterns:
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ir_model_data
|
||||||
|
SET module = 'fusion_plating_quality'
|
||||||
|
WHERE module = 'fusion_plating_bridge_mrp'
|
||||||
|
AND name LIKE %s
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ir_model_data d2
|
||||||
|
WHERE d2.module = 'fusion_plating_quality'
|
||||||
|
AND d2.name = ir_model_data.name
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(pat,),
|
||||||
|
)
|
||||||
|
if cr.rowcount:
|
||||||
|
_logger.info(
|
||||||
|
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating_quality",
|
||||||
|
cr.rowcount, pat,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the legacy production_id column on fusion_plating_quality_check.
|
||||||
|
# Zero rows reference MRP (verified pre-cut). The new model declares
|
||||||
|
# job_id / sale_order_id / partner_id (related from job).
|
||||||
|
cr.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE fusion_plating_quality_check
|
||||||
|
DROP COLUMN IF EXISTS production_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
_logger.info("Sub 11: dropped production_id column on fusion_plating_quality_check")
|
||||||
@@ -18,3 +18,8 @@ from . import res_company
|
|||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
from . import res_partner
|
from . import res_partner
|
||||||
from . import fp_part_catalog
|
from . import fp_part_catalog
|
||||||
|
|
||||||
|
# Phase 1 of MRP cut-out (Sub 11) — relocated from fusion_plating_bridge_mrp.
|
||||||
|
from . import fp_qc_template
|
||||||
|
from . import fp_thickness_reading
|
||||||
|
from . import fp_quality_check
|
||||||
|
|||||||
171
fusion_plating/fusion_plating_quality/models/fp_qc_template.py
Normal file
171
fusion_plating/fusion_plating_quality/models/fp_qc_template.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
|
||||||
|
# This model never had MRP fields; the bridge module was just its
|
||||||
|
# initial home. Now lives under fusion_plating_jobs.
|
||||||
|
"""QC Checklist Template — admin config for per-customer QC requirements.
|
||||||
|
|
||||||
|
Customers differ wildly in what they expect from quality control:
|
||||||
|
* commercial job-shop accounts often just want "did it plate?" — one
|
||||||
|
visual check
|
||||||
|
* aerospace / Nadcap customers expect visual, dimensional,
|
||||||
|
adhesion, and Fischerscope thickness readings — every part, every
|
||||||
|
lot, signed off
|
||||||
|
* internal rework jobs may have no QC requirement at all
|
||||||
|
|
||||||
|
Rather than coding that policy into the shop, each customer gets their
|
||||||
|
own checklist template. On job confirm, the active template is cloned
|
||||||
|
into a fresh `fusion.plating.quality.check` — the instance operators
|
||||||
|
actually fill in.
|
||||||
|
"""
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
|
class FpQcChecklistTemplate(models.Model):
|
||||||
|
_name = 'fp.qc.checklist.template'
|
||||||
|
_description = 'Fusion Plating — QC Checklist Template'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
_order = 'partner_id, sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Template Name', required=True, tracking=True,
|
||||||
|
help='e.g. "Standard Aerospace CoC + Thickness" or '
|
||||||
|
'"Commercial — Visual Only".',
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
'res.partner', string='Customer',
|
||||||
|
domain="[('customer_rank', '>', 0)]",
|
||||||
|
help='Leave blank for the global default template. A customer-'
|
||||||
|
'specific template wins over the default when both exist.',
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
notes = fields.Html(
|
||||||
|
string='Notes',
|
||||||
|
help='Context for QC inspectors — what this customer cares '
|
||||||
|
'about, common reject reasons, spec docs to reference.',
|
||||||
|
)
|
||||||
|
|
||||||
|
line_ids = fields.One2many(
|
||||||
|
'fp.qc.checklist.template.line', 'template_id',
|
||||||
|
string='Checklist Items', copy=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
require_thickness_readings = fields.Boolean(
|
||||||
|
string='Require Thickness Readings', default=False, tracking=True,
|
||||||
|
help='Job cannot be marked done unless at least one '
|
||||||
|
'fp.thickness.reading is logged against it. Use for '
|
||||||
|
'aerospace / Nadcap accounts.',
|
||||||
|
)
|
||||||
|
require_thickness_report_pdf = fields.Boolean(
|
||||||
|
string='Require Thickness Report PDF', default=False, tracking=True,
|
||||||
|
help='Job cannot be marked done unless the operator has '
|
||||||
|
'uploaded the Fischerscope / XDAL 600 PDF report to the '
|
||||||
|
'quality check.',
|
||||||
|
)
|
||||||
|
require_inspector_signoff = fields.Boolean(
|
||||||
|
string='Require Inspector Sign-off', default=True, tracking=True,
|
||||||
|
help='The quality check itself must be in the "passed" state '
|
||||||
|
'(not just draft or in-progress).',
|
||||||
|
)
|
||||||
|
|
||||||
|
check_count = fields.Integer(
|
||||||
|
string='# QC Checks Created', compute='_compute_check_count',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_check_count(self):
|
||||||
|
Check = self.env['fusion.plating.quality.check']
|
||||||
|
for rec in self:
|
||||||
|
rec.check_count = Check.search_count([
|
||||||
|
('template_id', '=', rec.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def resolve_for_partner(self, partner):
|
||||||
|
"""Return the best-matching template for a customer.
|
||||||
|
|
||||||
|
Order: active customer-specific template > active default template >
|
||||||
|
None (no QC required).
|
||||||
|
"""
|
||||||
|
if partner:
|
||||||
|
specific = self.search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('active', '=', True),
|
||||||
|
], limit=1)
|
||||||
|
if specific:
|
||||||
|
return specific
|
||||||
|
return self.search([
|
||||||
|
('partner_id', '=', False),
|
||||||
|
('active', '=', True),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
def action_view_checks(self):
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('QC Checks — %s') % self.name,
|
||||||
|
'res_model': 'fusion.plating.quality.check',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('template_id', '=', self.id)],
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FpQcChecklistTemplateLine(models.Model):
|
||||||
|
_name = 'fp.qc.checklist.template.line'
|
||||||
|
_description = 'Fusion Plating — QC Checklist Template Line'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
template_id = fields.Many2one(
|
||||||
|
'fp.qc.checklist.template', string='Template',
|
||||||
|
required=True, ondelete='cascade',
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
name = fields.Char(
|
||||||
|
string='Check Item', required=True, translate=True,
|
||||||
|
help='The operator-facing question, e.g. "No visible pits or '
|
||||||
|
'blemishes on surface", "Thickness within 0.0005–0.0010".',
|
||||||
|
)
|
||||||
|
description = fields.Text(
|
||||||
|
string='Inspection Guidance',
|
||||||
|
help='Extra detail shown on the tablet when the operator taps '
|
||||||
|
'the item. Use for photos-to-compare-against, acceptable-'
|
||||||
|
'colour ranges, how to position the part, etc.',
|
||||||
|
)
|
||||||
|
check_type = fields.Selection(
|
||||||
|
[
|
||||||
|
('visual', 'Visual Inspection'),
|
||||||
|
('dimensional', 'Dimensional'),
|
||||||
|
('thickness', 'Thickness'),
|
||||||
|
('adhesion', 'Adhesion'),
|
||||||
|
('hardness', 'Hardness'),
|
||||||
|
('salt_spray', 'Salt Spray'),
|
||||||
|
('functional', 'Functional'),
|
||||||
|
('other', 'Other'),
|
||||||
|
],
|
||||||
|
string='Check Type', default='visual', required=True,
|
||||||
|
)
|
||||||
|
required = fields.Boolean(
|
||||||
|
string='Required', default=True,
|
||||||
|
help='If off, the inspector can skip this item without blocking '
|
||||||
|
'the QC from passing.',
|
||||||
|
)
|
||||||
|
requires_value = fields.Boolean(
|
||||||
|
string='Requires Numeric Value', default=False,
|
||||||
|
help='Inspector must enter a measurement. If min/max are set, '
|
||||||
|
'the reading must fall inside to count as pass.',
|
||||||
|
)
|
||||||
|
value_min = fields.Float(string='Min Value', digits=(12, 4))
|
||||||
|
value_max = fields.Float(string='Max Value', digits=(12, 4))
|
||||||
|
value_uom = fields.Char(
|
||||||
|
string='Unit',
|
||||||
|
help='Free text. e.g. "mils", "microns", "HV", "µm".',
|
||||||
|
)
|
||||||
|
requires_photo = fields.Boolean(
|
||||||
|
string='Requires Photo', default=False,
|
||||||
|
help='Inspector must attach a photo of the part.',
|
||||||
|
)
|
||||||
549
fusion_plating/fusion_plating_quality/models/fp_quality_check.py
Normal file
549
fusion_plating/fusion_plating_quality/models/fp_quality_check.py
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
|
||||||
|
# Now binds to fp.job (native) instead of mrp.production.
|
||||||
|
"""Per-job QC instance.
|
||||||
|
|
||||||
|
When a plating job confirms and the customer requires QC, we clone
|
||||||
|
the active checklist template into a `fusion.plating.quality.check`
|
||||||
|
with one line per template line. The inspector picks it up on the
|
||||||
|
tablet, walks the checks, and signs off — which unblocks the job's
|
||||||
|
`button_mark_done`.
|
||||||
|
|
||||||
|
The QC also owns the Fischerscope / XDAL 600 thickness report PDF.
|
||||||
|
When the operator uploads one, we extract per-reading data server-side
|
||||||
|
and auto-create `fp.thickness.reading` rows so the CoC PDF picks them up.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FpQualityCheck(models.Model):
|
||||||
|
_name = 'fusion.plating.quality.check'
|
||||||
|
_description = 'Fusion Plating — Quality Check'
|
||||||
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string='Reference', required=True, copy=False, readonly=True,
|
||||||
|
default=lambda self: self._default_name(), tracking=True,
|
||||||
|
)
|
||||||
|
job_id = fields.Many2one(
|
||||||
|
'fp.job', string='Plating Job',
|
||||||
|
required=True, ondelete='cascade', tracking=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
sale_order_id = fields.Many2one(
|
||||||
|
'sale.order', related='job_id.sale_order_id',
|
||||||
|
store=True, readonly=True,
|
||||||
|
)
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
'res.partner', related='job_id.partner_id',
|
||||||
|
store=True, readonly=True,
|
||||||
|
)
|
||||||
|
template_id = fields.Many2one(
|
||||||
|
'fp.qc.checklist.template', string='Template',
|
||||||
|
help='The checklist template these lines were cloned from.',
|
||||||
|
)
|
||||||
|
state = fields.Selection(
|
||||||
|
[
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('passed', 'Passed'),
|
||||||
|
('failed', 'Failed'),
|
||||||
|
('rework', 'Rework Required'),
|
||||||
|
],
|
||||||
|
string='Status', default='draft', required=True, tracking=True,
|
||||||
|
)
|
||||||
|
overall_result = fields.Selection(
|
||||||
|
[('pass', 'Pass'), ('fail', 'Fail'), ('partial', 'Partial Pass')],
|
||||||
|
string='Result', tracking=True,
|
||||||
|
help='Summary outcome — set when inspector signs off.',
|
||||||
|
)
|
||||||
|
line_ids = fields.One2many(
|
||||||
|
'fusion.plating.quality.check.line', 'check_id',
|
||||||
|
string='Check Items',
|
||||||
|
)
|
||||||
|
line_count = fields.Integer(compute='_compute_line_stats', store=True)
|
||||||
|
lines_passed = fields.Integer(compute='_compute_line_stats', store=True)
|
||||||
|
lines_failed = fields.Integer(compute='_compute_line_stats', store=True)
|
||||||
|
lines_pending = fields.Integer(compute='_compute_line_stats', store=True)
|
||||||
|
|
||||||
|
inspector_id = fields.Many2one(
|
||||||
|
'res.users', string='Inspector',
|
||||||
|
help='Whoever signed the QC off. Filled when state moves to '
|
||||||
|
'passed/failed.',
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
started_at = fields.Datetime(
|
||||||
|
string='Started', help='First time inspector opened this check.',
|
||||||
|
)
|
||||||
|
completed_at = fields.Datetime(
|
||||||
|
string='Completed', help='When the check was signed off.',
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
notes = fields.Html(string='Inspector Notes')
|
||||||
|
|
||||||
|
thickness_report_pdf_id = fields.Many2one(
|
||||||
|
'ir.attachment', string='Thickness Report PDF',
|
||||||
|
help='Upload the Fischerscope / XDAL 600 export. On upload we '
|
||||||
|
'parse the PDF and auto-create fp.thickness.reading rows.',
|
||||||
|
)
|
||||||
|
thickness_reading_ids = fields.One2many(
|
||||||
|
'fp.thickness.reading', 'quality_check_id',
|
||||||
|
string='Thickness Readings',
|
||||||
|
)
|
||||||
|
thickness_reading_count = fields.Integer(
|
||||||
|
compute='_compute_thickness_count',
|
||||||
|
)
|
||||||
|
|
||||||
|
require_thickness_readings = fields.Boolean(
|
||||||
|
related='template_id.require_thickness_readings',
|
||||||
|
store=True, readonly=True,
|
||||||
|
)
|
||||||
|
require_thickness_report_pdf = fields.Boolean(
|
||||||
|
related='template_id.require_thickness_report_pdf',
|
||||||
|
store=True, readonly=True,
|
||||||
|
)
|
||||||
|
require_inspector_signoff = fields.Boolean(
|
||||||
|
related='template_id.require_inspector_signoff',
|
||||||
|
store=True, readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
company_id = fields.Many2one(
|
||||||
|
'res.company', related='job_id.company_id',
|
||||||
|
store=True, readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('line_ids.result')
|
||||||
|
def _compute_line_stats(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.line_count = len(rec.line_ids)
|
||||||
|
rec.lines_passed = len(rec.line_ids.filtered(
|
||||||
|
lambda l: l.result == 'pass'
|
||||||
|
))
|
||||||
|
rec.lines_failed = len(rec.line_ids.filtered(
|
||||||
|
lambda l: l.result == 'fail'
|
||||||
|
))
|
||||||
|
rec.lines_pending = len(rec.line_ids.filtered(
|
||||||
|
lambda l: l.result in (False, 'pending')
|
||||||
|
))
|
||||||
|
|
||||||
|
@api.depends('thickness_reading_ids')
|
||||||
|
def _compute_thickness_count(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.thickness_reading_count = len(rec.thickness_reading_ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _default_name(self):
|
||||||
|
seq = self.env['ir.sequence'].next_by_code(
|
||||||
|
'fusion.plating.quality.check',
|
||||||
|
)
|
||||||
|
return seq or 'QC/NEW'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('name') or vals.get('name') == '/':
|
||||||
|
vals['name'] = self._default_name()
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def create_for_job(self, job, template=None):
|
||||||
|
"""Spin up a QC record for a plating job, cloning lines from the
|
||||||
|
template.
|
||||||
|
|
||||||
|
If no template is passed, we resolve one from the job's customer.
|
||||||
|
Returns the created check, or an empty recordset if no template
|
||||||
|
matches (=> no QC required for this customer).
|
||||||
|
"""
|
||||||
|
self = self.sudo()
|
||||||
|
if template is None:
|
||||||
|
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
|
||||||
|
job.partner_id,
|
||||||
|
)
|
||||||
|
if not template:
|
||||||
|
return self.browse()
|
||||||
|
|
||||||
|
existing = self.search([
|
||||||
|
('job_id', '=', job.id),
|
||||||
|
('state', '!=', 'failed'),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
check = self.create({
|
||||||
|
'job_id': job.id,
|
||||||
|
'template_id': template.id,
|
||||||
|
'state': 'draft',
|
||||||
|
})
|
||||||
|
Line = self.env['fusion.plating.quality.check.line']
|
||||||
|
for tline in template.line_ids.sorted('sequence'):
|
||||||
|
Line.create({
|
||||||
|
'check_id': check.id,
|
||||||
|
'sequence': tline.sequence,
|
||||||
|
'name': tline.name,
|
||||||
|
'description': tline.description,
|
||||||
|
'check_type': tline.check_type,
|
||||||
|
'required': tline.required,
|
||||||
|
'requires_value': tline.requires_value,
|
||||||
|
'value_min': tline.value_min,
|
||||||
|
'value_max': tline.value_max,
|
||||||
|
'value_uom': tline.value_uom,
|
||||||
|
'requires_photo': tline.requires_photo,
|
||||||
|
'result': 'pending',
|
||||||
|
})
|
||||||
|
job.message_post(
|
||||||
|
body=_('QC checklist "%s" created — %d items to inspect.') % (
|
||||||
|
template.name, len(template.line_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return check
|
||||||
|
|
||||||
|
def action_start(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.state == 'draft':
|
||||||
|
rec.write({
|
||||||
|
'state': 'in_progress',
|
||||||
|
'started_at': fields.Datetime.now(),
|
||||||
|
'inspector_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
rec.message_post(body=_('QC started.'))
|
||||||
|
|
||||||
|
def action_pass(self):
|
||||||
|
for rec in self:
|
||||||
|
rec._ensure_all_required_complete()
|
||||||
|
rec.write({
|
||||||
|
'state': 'passed',
|
||||||
|
'overall_result': 'pass',
|
||||||
|
'completed_at': fields.Datetime.now(),
|
||||||
|
'inspector_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
rec.message_post(body=Markup(
|
||||||
|
'<b>QC PASSED</b> — inspector %s.'
|
||||||
|
) % self.env.user.name)
|
||||||
|
|
||||||
|
def action_fail(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.write({
|
||||||
|
'state': 'failed',
|
||||||
|
'overall_result': 'fail',
|
||||||
|
'completed_at': fields.Datetime.now(),
|
||||||
|
'inspector_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
rec.message_post(body=Markup(
|
||||||
|
'<b>QC FAILED</b> — inspector %s.'
|
||||||
|
) % self.env.user.name)
|
||||||
|
|
||||||
|
def action_rework(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.write({
|
||||||
|
'state': 'rework',
|
||||||
|
'overall_result': 'partial',
|
||||||
|
'completed_at': fields.Datetime.now(),
|
||||||
|
'inspector_id': self.env.user.id,
|
||||||
|
})
|
||||||
|
rec.message_post(body=_('QC flagged for rework.'))
|
||||||
|
|
||||||
|
def action_reset_to_draft(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.write({
|
||||||
|
'state': 'draft',
|
||||||
|
'overall_result': False,
|
||||||
|
'completed_at': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_spawn_retry(self):
|
||||||
|
"""Spin up a fresh QC instance for the same job after a failure."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'failed':
|
||||||
|
return
|
||||||
|
new_check = self.sudo().create_for_job(
|
||||||
|
self.job_id, template=self.template_id,
|
||||||
|
)
|
||||||
|
if not new_check:
|
||||||
|
return False
|
||||||
|
self.message_post(body=_('Retry QC created: %s') % new_check.name)
|
||||||
|
new_check.message_post(body=_('Retry of failed QC %s') % self.name)
|
||||||
|
return new_check.action_open_tablet()
|
||||||
|
|
||||||
|
def _ensure_all_required_complete(self):
|
||||||
|
for rec in self:
|
||||||
|
pending = rec.line_ids.filtered(
|
||||||
|
lambda l: l.required and l.result in (False, 'pending')
|
||||||
|
)
|
||||||
|
if pending:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot pass QC "%(name)s" — %(n)d required check '
|
||||||
|
'item(s) still pending:\n • %(items)s'
|
||||||
|
) % {
|
||||||
|
'name': rec.name,
|
||||||
|
'n': len(pending),
|
||||||
|
'items': '\n • '.join(pending.mapped('name')),
|
||||||
|
})
|
||||||
|
failed = rec.line_ids.filtered(lambda l: l.result == 'fail')
|
||||||
|
if failed:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot pass QC "%(name)s" — %(n)d check item(s) '
|
||||||
|
'failed. Fail the QC instead, or reset those '
|
||||||
|
'items to pass.'
|
||||||
|
) % {'name': rec.name, 'n': len(failed)})
|
||||||
|
|
||||||
|
def _on_thickness_pdf_uploaded(self):
|
||||||
|
"""Parse the attached PDF and create fp.thickness.reading rows."""
|
||||||
|
ThicknessReading = self.env['fp.thickness.reading']
|
||||||
|
for rec in self:
|
||||||
|
if not rec.thickness_report_pdf_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = rec._extract_pdf_text(rec.thickness_report_pdf_id)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
'QC %s: pdftotext extraction failed', rec.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
readings = rec._parse_fischerscope_text(text)
|
||||||
|
if not readings:
|
||||||
|
rec.message_post(body=_(
|
||||||
|
'Thickness report PDF attached but no readings '
|
||||||
|
'could be extracted automatically. Please enter '
|
||||||
|
'readings manually.'
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
auto = rec.thickness_reading_ids.filtered(
|
||||||
|
lambda r: r.auto_extracted
|
||||||
|
)
|
||||||
|
auto.unlink()
|
||||||
|
|
||||||
|
for idx, row in enumerate(readings, start=1):
|
||||||
|
vals = {
|
||||||
|
'quality_check_id': rec.id,
|
||||||
|
'reading_number': idx,
|
||||||
|
'nip_mils': row.get('nip_mils', 0.0),
|
||||||
|
'ni_percent': row.get('ni_percent', 0.0),
|
||||||
|
'p_percent': row.get('p_percent', 0.0),
|
||||||
|
'position_label': row.get('position', ''),
|
||||||
|
'auto_extracted': True,
|
||||||
|
}
|
||||||
|
ThicknessReading.create(vals)
|
||||||
|
rec.message_post(body=_(
|
||||||
|
'Extracted %d thickness reading(s) from "%s".'
|
||||||
|
) % (len(readings), rec.thickness_report_pdf_id.name))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_pdf_text(attachment):
|
||||||
|
raw = base64.b64decode(attachment.datas or b'')
|
||||||
|
if not raw:
|
||||||
|
return ''
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix='.pdf', delete=True,
|
||||||
|
) as tmp:
|
||||||
|
tmp.write(raw)
|
||||||
|
tmp.flush()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['pdftotext', '-layout', tmp.name, '-'],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
return result.stdout or ''
|
||||||
|
except FileNotFoundError:
|
||||||
|
_logger.warning(
|
||||||
|
'pdftotext not installed — cannot auto-extract '
|
||||||
|
'Fischerscope PDF data. Install poppler-utils on '
|
||||||
|
'the Odoo host.',
|
||||||
|
)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_fischerscope_text(text):
|
||||||
|
readings = []
|
||||||
|
row_re = re.compile(
|
||||||
|
r'^\s*(?:n\s*=\s*|N\s*)?(\d{1,3})[\s.:]+'
|
||||||
|
r'([0-9]*\.[0-9]+|\d+)'
|
||||||
|
r'(?:\s*(?:mils|microns|µm|um))?'
|
||||||
|
r'[\s|]+'
|
||||||
|
r'([0-9]*\.?[0-9]+)'
|
||||||
|
r'[\s|%]+'
|
||||||
|
r'([0-9]*\.?[0-9]+)'
|
||||||
|
r'[\s|%]*'
|
||||||
|
r'(.*)$',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
m = row_re.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
idx = int(m.group(1))
|
||||||
|
nip = float(m.group(2))
|
||||||
|
ni = float(m.group(3))
|
||||||
|
p = float(m.group(4))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not (0 < nip < 1) and not (0 < nip < 30):
|
||||||
|
continue
|
||||||
|
if not (0 < ni < 100):
|
||||||
|
continue
|
||||||
|
if not (0 < p < 30):
|
||||||
|
continue
|
||||||
|
if idx < 1 or idx > 500:
|
||||||
|
continue
|
||||||
|
position = (m.group(5) or '').strip()[:60]
|
||||||
|
readings.append({
|
||||||
|
'index': idx,
|
||||||
|
'nip_mils': nip,
|
||||||
|
'ni_percent': ni,
|
||||||
|
'p_percent': p,
|
||||||
|
'position': position,
|
||||||
|
})
|
||||||
|
seen = set()
|
||||||
|
dedup = []
|
||||||
|
for r in readings:
|
||||||
|
if r['index'] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(r['index'])
|
||||||
|
dedup.append(r)
|
||||||
|
return dedup
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
trigger = 'thickness_report_pdf_id' in vals and vals.get(
|
||||||
|
'thickness_report_pdf_id'
|
||||||
|
)
|
||||||
|
res = super().write(vals)
|
||||||
|
if trigger:
|
||||||
|
self._on_thickness_pdf_uploaded()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def action_open_tablet(self):
|
||||||
|
"""Launch the mobile QC checklist OWL client action."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'fp_qc_checklist',
|
||||||
|
'name': _('QC — %s') % (self.job_id.name or ''),
|
||||||
|
'params': {'check_id': self.id},
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FpQualityCheckLine(models.Model):
|
||||||
|
_name = 'fusion.plating.quality.check.line'
|
||||||
|
_description = 'Fusion Plating — Quality Check Line'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
check_id = fields.Many2one(
|
||||||
|
'fusion.plating.quality.check', string='Check',
|
||||||
|
required=True, ondelete='cascade', index=True,
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
name = fields.Char(string='Check Item', required=True)
|
||||||
|
description = fields.Text(string='Guidance')
|
||||||
|
check_type = fields.Selection(
|
||||||
|
selection=lambda self: self.env[
|
||||||
|
'fp.qc.checklist.template.line'
|
||||||
|
]._fields['check_type'].selection,
|
||||||
|
string='Type', default='visual',
|
||||||
|
)
|
||||||
|
required = fields.Boolean(default=True)
|
||||||
|
requires_value = fields.Boolean()
|
||||||
|
value = fields.Float(digits=(12, 4))
|
||||||
|
value_min = fields.Float(digits=(12, 4))
|
||||||
|
value_max = fields.Float(digits=(12, 4))
|
||||||
|
value_uom = fields.Char(string='Unit')
|
||||||
|
requires_photo = fields.Boolean()
|
||||||
|
photo_attachment_id = fields.Many2one(
|
||||||
|
'ir.attachment', string='Photo',
|
||||||
|
)
|
||||||
|
result = fields.Selection(
|
||||||
|
[
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('pass', 'Pass'),
|
||||||
|
('fail', 'Fail'),
|
||||||
|
('na', 'N/A'),
|
||||||
|
],
|
||||||
|
string='Result', default='pending', required=True,
|
||||||
|
)
|
||||||
|
notes = fields.Text(string='Note')
|
||||||
|
inspector_id = fields.Many2one('res.users', string='Inspector')
|
||||||
|
completed_at = fields.Datetime(string='Completed At')
|
||||||
|
|
||||||
|
value_in_range = fields.Boolean(
|
||||||
|
compute='_compute_value_in_range', store=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('value', 'value_min', 'value_max', 'requires_value')
|
||||||
|
def _compute_value_in_range(self):
|
||||||
|
for rec in self:
|
||||||
|
if not rec.requires_value:
|
||||||
|
rec.value_in_range = True
|
||||||
|
continue
|
||||||
|
vmin = rec.value_min
|
||||||
|
vmax = rec.value_max
|
||||||
|
if vmin and rec.value < vmin:
|
||||||
|
rec.value_in_range = False
|
||||||
|
elif vmax and rec.value > vmax:
|
||||||
|
rec.value_in_range = False
|
||||||
|
else:
|
||||||
|
rec.value_in_range = True
|
||||||
|
|
||||||
|
def action_mark_pass(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.requires_value and not rec.value_in_range:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot pass "%(item)s" — value %(val)s is outside '
|
||||||
|
'the acceptance range (%(min)s – %(max)s %(uom)s).'
|
||||||
|
) % {
|
||||||
|
'item': rec.name,
|
||||||
|
'val': rec.value,
|
||||||
|
'min': rec.value_min,
|
||||||
|
'max': rec.value_max,
|
||||||
|
'uom': rec.value_uom or '',
|
||||||
|
})
|
||||||
|
if rec.requires_photo and not rec.photo_attachment_id:
|
||||||
|
raise UserError(_(
|
||||||
|
'Cannot pass "%(item)s" — a photo is required.'
|
||||||
|
) % {'item': rec.name})
|
||||||
|
rec.write({
|
||||||
|
'result': 'pass',
|
||||||
|
'inspector_id': self.env.user.id,
|
||||||
|
'completed_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_mark_fail(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.write({
|
||||||
|
'result': 'fail',
|
||||||
|
'inspector_id': self.env.user.id,
|
||||||
|
'completed_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_mark_na(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.required:
|
||||||
|
raise UserError(_(
|
||||||
|
'"%(item)s" is a required check and cannot be '
|
||||||
|
'marked N/A.'
|
||||||
|
) % {'item': rec.name})
|
||||||
|
rec.write({
|
||||||
|
'result': 'na',
|
||||||
|
'inspector_id': self.env.user.id,
|
||||||
|
'completed_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
@@ -30,10 +30,19 @@ class FpQualityHold(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ----- What's on hold -----
|
# ----- What's on hold -----
|
||||||
# NOTE: workorder_id, production_id, and portal_job_id live in
|
# Phase 1 (Sub 11) — native plating-job link replaces the legacy
|
||||||
# fusion_plating_bridge_mrp (which depends on mrp and
|
# workorder_id / production_id pair that lived in bridge_mrp.
|
||||||
# fusion_plating_portal). Keeping them here would force hard
|
# The bridge fields stay during the migration window so existing
|
||||||
# dependencies and break minimal CE-only installs.
|
# records keep their FKs; Phase 5 removes bridge_mrp entirely.
|
||||||
|
job_id = fields.Many2one(
|
||||||
|
'fp.job', string='Plating Job',
|
||||||
|
index=True, ondelete='set null',
|
||||||
|
)
|
||||||
|
step_id = fields.Many2one(
|
||||||
|
'fp.job.step', string='Job Step',
|
||||||
|
domain="[('job_id', '=', job_id)]",
|
||||||
|
ondelete='set null',
|
||||||
|
)
|
||||||
part_ref = fields.Char(string='Part Number')
|
part_ref = fields.Char(string='Part Number')
|
||||||
|
|
||||||
# ----- Hold details -----
|
# ----- Hold details -----
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
#
|
||||||
|
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
|
||||||
|
# Adds the back-reference from fp.thickness.reading to the QC record
|
||||||
|
# that produced it. Lives here (not in fusion_plating_certificates)
|
||||||
|
# because the link target is fusion.plating.quality.check, owned by
|
||||||
|
# fusion_plating_quality.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpThicknessReading(models.Model):
|
||||||
|
_inherit = 'fp.thickness.reading'
|
||||||
|
|
||||||
|
quality_check_id = fields.Many2one(
|
||||||
|
'fusion.plating.quality.check', string='Quality Check',
|
||||||
|
ondelete='set null', index=True,
|
||||||
|
help='The QC record the reading belongs to (populated when '
|
||||||
|
'readings are logged from the mobile QC checklist).',
|
||||||
|
)
|
||||||
|
auto_extracted = fields.Boolean(
|
||||||
|
string='Auto-Extracted',
|
||||||
|
help='True for readings parsed out of a Fischerscope PDF. '
|
||||||
|
'These are replaced when the PDF is re-uploaded; '
|
||||||
|
'manually-entered readings are preserved.',
|
||||||
|
)
|
||||||
@@ -17,3 +17,17 @@ class ResPartner(models.Model):
|
|||||||
'fully optional — the reminder can be dismissed and never '
|
'fully optional — the reminder can be dismissed and never '
|
||||||
'blocks production.',
|
'blocks production.',
|
||||||
)
|
)
|
||||||
|
# Phase 4 (Sub 11) — relocated from fusion_plating_bridge_mrp.
|
||||||
|
x_fc_requires_qc = fields.Boolean(
|
||||||
|
string='Require QC Sign-off',
|
||||||
|
default=False, tracking=True,
|
||||||
|
help='When enabled, a job for this customer cannot be marked '
|
||||||
|
'complete until a QC inspector has signed off on the '
|
||||||
|
'quality checklist.',
|
||||||
|
)
|
||||||
|
x_fc_qc_template_id = fields.Many2one(
|
||||||
|
'fp.qc.checklist.template', string='QC Checklist Template',
|
||||||
|
help='Override the auto-resolved template for this customer. '
|
||||||
|
'Leave blank to use any active customer-specific template, '
|
||||||
|
'falling back to the global default.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,3 +32,15 @@ access_fp_quality_hold_manager,fp.quality.hold.manager,model_fusion_plating_qual
|
|||||||
access_fp_contract_review_operator,fp.contract.review.operator,model_fp_contract_review,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
access_fp_contract_review_operator,fp.contract.review.operator,model_fp_contract_review,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
access_fp_contract_review_supervisor,fp.contract.review.supervisor,model_fp_contract_review,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
access_fp_contract_review_supervisor,fp.contract.review.supervisor,model_fp_contract_review,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
access_fp_contract_review_manager,fp.contract.review.manager,model_fp_contract_review,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_contract_review_manager,fp.contract.review.manager,model_fp_contract_review,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||||
|
access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||||
|
access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||||
|
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||||
|
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -0,0 +1,349 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
// =============================================================================
|
||||||
|
// Fusion Plating — Mobile QC Checklist (OWL backend client action)
|
||||||
|
// Copyright 2026 Nexa Systems Inc.
|
||||||
|
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
//
|
||||||
|
// Matches the existing Tablet Station / Plant Overview conventions:
|
||||||
|
// * `static template` + `static props = ["*"]`
|
||||||
|
// * Standalone rpc() from @web/core/network/rpc
|
||||||
|
// * Design tokens from _fp_shopfloor_tokens.scss (no borders, shadow
|
||||||
|
// elevation, 48 px touch targets)
|
||||||
|
//
|
||||||
|
// Invoked either via the MO "Open QC" smart-button (action_open_tablet)
|
||||||
|
// or directly with `ir.actions.client` tag `fp_qc_checklist` and the
|
||||||
|
// action's params.check_id.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { Component, useState, onMounted, useRef } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class FpQcChecklist extends Component {
|
||||||
|
static template = "fusion_plating_quality.FpQcChecklist";
|
||||||
|
static props = ["*"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.action = useService("action");
|
||||||
|
this.fileInput = useRef("fileInput");
|
||||||
|
this.pdfInput = useRef("pdfInput");
|
||||||
|
this.photoLineId = null;
|
||||||
|
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
check: null,
|
||||||
|
lines: [],
|
||||||
|
expandedLineId: null,
|
||||||
|
showFinalize: false,
|
||||||
|
finalizeNotes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// action.params (from ir.actions.client) is the canonical
|
||||||
|
// source; fall back to URL query params for deep-linking.
|
||||||
|
const params = (this.props.action && this.props.action.params) || {};
|
||||||
|
this.checkId = params.check_id || null;
|
||||||
|
this.jobId = params.job_id || null;
|
||||||
|
|
||||||
|
onMounted(() => this.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Data
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
async refresh() {
|
||||||
|
this.state.loading = true;
|
||||||
|
this.state.error = null;
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/qc/get", {
|
||||||
|
check_id: this.checkId,
|
||||||
|
job_id: this.jobId,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.state.error = res.error === "no_qc"
|
||||||
|
? "No QC checklist exists for this MO yet."
|
||||||
|
: (res.error || "QC not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.check = res.check;
|
||||||
|
this.state.lines = res.lines || [];
|
||||||
|
this.checkId = res.check.id;
|
||||||
|
} catch (err) {
|
||||||
|
this.state.error = err && err.message ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Line actions
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
async markLine(line, result) {
|
||||||
|
if (this.state.saving) return;
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
check_id: this.checkId,
|
||||||
|
line_id: line.id,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
if (line.requires_value) {
|
||||||
|
payload.value = line.value;
|
||||||
|
}
|
||||||
|
if (line.notes !== undefined) payload.notes = line.notes;
|
||||||
|
const res = await rpc("/fp/qc/line/mark", payload);
|
||||||
|
if (!res.ok) {
|
||||||
|
this.notification.add(res.error || "Mark failed", {
|
||||||
|
type: "danger",
|
||||||
|
title: line.name,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Merge updated line into state
|
||||||
|
const idx = this.state.lines.findIndex((l) => l.id === line.id);
|
||||||
|
if (idx >= 0) this.state.lines[idx] = res.line;
|
||||||
|
this.state.check = res.check;
|
||||||
|
this.notification.add(
|
||||||
|
result === "pass" ? "Passed" : result === "fail" ? "Failed" : "Marked",
|
||||||
|
{ type: result === "fail" ? "danger" : "success" },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(
|
||||||
|
err && err.message ? err.message : String(err),
|
||||||
|
{ type: "danger" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value input — debounced write on blur. Pending result stays until
|
||||||
|
// operator taps pass/fail.
|
||||||
|
onValueInput(line, ev) {
|
||||||
|
const v = parseFloat(ev.target.value);
|
||||||
|
line.value = isNaN(v) ? 0 : v;
|
||||||
|
if (line.requires_value) {
|
||||||
|
const inRange =
|
||||||
|
(!line.value_min || line.value >= line.value_min) &&
|
||||||
|
(!line.value_max || line.value <= line.value_max);
|
||||||
|
line.value_in_range = inRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNotesInput(line, ev) {
|
||||||
|
line.notes = ev.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpanded(line) {
|
||||||
|
this.state.expandedLineId =
|
||||||
|
this.state.expandedLineId === line.id ? null : line.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Photo upload
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
triggerPhoto(line) {
|
||||||
|
this.photoLineId = line.id;
|
||||||
|
if (this.fileInput.el) {
|
||||||
|
this.fileInput.el.value = "";
|
||||||
|
this.fileInput.el.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPhotoSelected(ev) {
|
||||||
|
const file = ev.target.files && ev.target.files[0];
|
||||||
|
if (!file || !this.photoLineId) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("line_id", this.photoLineId);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/fp/qc/line/photo", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
if (!json.ok) {
|
||||||
|
this.notification.add(json.error || "Upload failed", {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.notification.add("Photo uploaded", { type: "success" });
|
||||||
|
await this.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(
|
||||||
|
err && err.message ? err.message : String(err),
|
||||||
|
{ type: "danger" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.photoLineId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Fischerscope PDF upload
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
triggerPdfUpload() {
|
||||||
|
if (this.pdfInput.el) {
|
||||||
|
this.pdfInput.el.value = "";
|
||||||
|
this.pdfInput.el.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPdfSelected(ev) {
|
||||||
|
const file = ev.target.files && ev.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("check_id", this.checkId);
|
||||||
|
try {
|
||||||
|
this.state.saving = true;
|
||||||
|
const resp = await fetch("/fp/qc/thickness_pdf", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
const json = await resp.json();
|
||||||
|
if (!json.ok) {
|
||||||
|
this.notification.add(json.error || "Upload failed", {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.notification.add(
|
||||||
|
`Uploaded — ${json.reading_count || 0} reading(s) extracted`,
|
||||||
|
{ type: "success" },
|
||||||
|
);
|
||||||
|
await this.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(
|
||||||
|
err && err.message ? err.message : String(err),
|
||||||
|
{ type: "danger" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Finalize
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
openFinalize() {
|
||||||
|
this.state.showFinalize = true;
|
||||||
|
this.state.finalizeNotes = this.state.check
|
||||||
|
? this.state.check.notes || ""
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
closeFinalize() {
|
||||||
|
this.state.showFinalize = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(result) {
|
||||||
|
try {
|
||||||
|
this.state.saving = true;
|
||||||
|
const res = await rpc("/fp/qc/finalize", {
|
||||||
|
check_id: this.checkId,
|
||||||
|
result,
|
||||||
|
notes: this.state.finalizeNotes,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.notification.add(res.error || "Finalize failed", {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.check = res.check;
|
||||||
|
this.state.showFinalize = false;
|
||||||
|
this.notification.add(
|
||||||
|
result === "pass"
|
||||||
|
? "QC passed. MO can now be marked Done."
|
||||||
|
: result === "fail"
|
||||||
|
? "QC failed. Go to the MO to decide scrap/rework."
|
||||||
|
: "QC flagged for rework.",
|
||||||
|
{ type: result === "pass" ? "success" : "warning" },
|
||||||
|
);
|
||||||
|
await this.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(
|
||||||
|
err && err.message ? err.message : String(err),
|
||||||
|
{ type: "danger" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Navigation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
async openJob() {
|
||||||
|
if (!this.state.check || !this.state.check.job_id) return;
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "fp.job",
|
||||||
|
res_id: this.state.check.job_id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Helpers used by the template
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
resultBadgeClass(result) {
|
||||||
|
return {
|
||||||
|
pass: "o_fp_qc_badge_pass",
|
||||||
|
fail: "o_fp_qc_badge_fail",
|
||||||
|
na: "o_fp_qc_badge_na",
|
||||||
|
pending: "o_fp_qc_badge_pending",
|
||||||
|
}[result || "pending"] || "o_fp_qc_badge_pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTypeIcon(type) {
|
||||||
|
return {
|
||||||
|
visual: "fa-eye",
|
||||||
|
dimensional: "fa-arrows-h",
|
||||||
|
thickness: "fa-bar-chart",
|
||||||
|
adhesion: "fa-link",
|
||||||
|
hardness: "fa-diamond",
|
||||||
|
salt_spray: "fa-tint",
|
||||||
|
functional: "fa-cogs",
|
||||||
|
other: "fa-circle-o",
|
||||||
|
}[type] || "fa-circle-o";
|
||||||
|
}
|
||||||
|
|
||||||
|
get progressPercent() {
|
||||||
|
if (!this.state.check || !this.state.check.line_count) return 0;
|
||||||
|
const done = this.state.check.lines_passed +
|
||||||
|
this.state.check.lines_failed;
|
||||||
|
return Math.round((done / this.state.check.line_count) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canFinalize() {
|
||||||
|
if (!this.state.check) return false;
|
||||||
|
if (["passed", "failed"].includes(this.state.check.state)) return false;
|
||||||
|
// Required items must be resolved
|
||||||
|
const pendingRequired = this.state.lines.filter(
|
||||||
|
(l) => l.required && (l.result === "pending" || !l.result),
|
||||||
|
);
|
||||||
|
if (pendingRequired.length > 0) return false;
|
||||||
|
// Thickness PDF requirement
|
||||||
|
if (this.state.check.require_thickness_report_pdf &&
|
||||||
|
!this.state.check.has_thickness_pdf) return false;
|
||||||
|
// Thickness readings requirement
|
||||||
|
if (this.state.check.require_thickness_readings &&
|
||||||
|
this.state.check.thickness_reading_count === 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get anyFailed() {
|
||||||
|
return this.state.lines.some((l) => l.result === "fail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("actions").add("fp_qc_checklist", FpQcChecklist);
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// Fusion Plating — Mobile QC Checklist styles
|
||||||
|
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||||
|
//
|
||||||
|
// Built on the shop-floor design system tokens (_fp_shopfloor_tokens.scss).
|
||||||
|
// Same language as Tablet Station / Plant Overview: no borders, shadow-
|
||||||
|
// based elevation, 48 px touch targets, three-layer contrast.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.o_fp_qc {
|
||||||
|
background-color: $fp-page;
|
||||||
|
color: $fp-ink;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: $fp-space-4;
|
||||||
|
font-family: $fp-font-stack;
|
||||||
|
font-size: $fp-text-base;
|
||||||
|
|
||||||
|
// ---------- State ----------
|
||||||
|
.o_fp_qc_state_loading,
|
||||||
|
.o_fp_qc_state_error {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: $fp-space-10 auto;
|
||||||
|
@include fp-card($fp-elev-2);
|
||||||
|
padding: $fp-space-7;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
font-size: $fp-text-2xl;
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { color: $fp-ink-soft; margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_state_error .fa { color: $fp-bad; }
|
||||||
|
|
||||||
|
// ---------- Header ----------
|
||||||
|
.o_fp_qc_header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $fp-space-4;
|
||||||
|
margin-bottom: $fp-space-5;
|
||||||
|
|
||||||
|
.o_fp_qc_header_left {
|
||||||
|
display: flex;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_back {
|
||||||
|
width: $fp-touch-min;
|
||||||
|
height: $fp-touch-min;
|
||||||
|
border-radius: $fp-radius-md;
|
||||||
|
background-color: $fp-card;
|
||||||
|
box-shadow: $fp-elev-1;
|
||||||
|
border: none;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
font-size: $fp-text-md;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: box-shadow $fp-dur $fp-ease;
|
||||||
|
|
||||||
|
@include fp-hover-only {
|
||||||
|
&:hover { box-shadow: $fp-elev-2; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_title_block {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_breadcrumb {
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
margin-bottom: $fp-space-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_title {
|
||||||
|
font-size: $fp-text-2xl;
|
||||||
|
font-weight: $fp-weight-semibold;
|
||||||
|
margin: 0 0 $fp-space-1 0;
|
||||||
|
color: $fp-ink;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_sub {
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_sep {
|
||||||
|
margin: 0 $fp-space-2;
|
||||||
|
color: $fp-ink-faint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_ref { font-weight: $fp-weight-medium; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_state_chip {
|
||||||
|
padding: $fp-space-2 $fp-space-4;
|
||||||
|
border-radius: $fp-radius-pill;
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
font-weight: $fp-weight-semibold;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.o_fp_qc_chip_draft { @include fp-pill('--bs-info'); }
|
||||||
|
&.o_fp_qc_chip_in_progress { @include fp-pill('--bs-warning'); }
|
||||||
|
&.o_fp_qc_chip_passed { @include fp-pill('--bs-success'); }
|
||||||
|
&.o_fp_qc_chip_failed { @include fp-pill('--bs-danger'); }
|
||||||
|
&.o_fp_qc_chip_rework { @include fp-pill('--bs-secondary'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Progress card ----------
|
||||||
|
.o_fp_qc_progress_card {
|
||||||
|
@include fp-card($fp-elev-2);
|
||||||
|
padding: $fp-space-5 $fp-space-6;
|
||||||
|
margin-bottom: $fp-space-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_progress_numbers {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-6;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: $fp-space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_progress_big {
|
||||||
|
font-size: $fp-text-3xl;
|
||||||
|
font-weight: $fp-weight-bold;
|
||||||
|
color: $fp-accent;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_progress_break {
|
||||||
|
display: flex;
|
||||||
|
gap: $fp-space-6;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_counter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
.o_fp_qc_counter_n {
|
||||||
|
font-size: $fp-text-xl;
|
||||||
|
font-weight: $fp-weight-bold;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_counter_l {
|
||||||
|
font-size: $fp-text-xs;
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.o_fp_qc_counter_pass .o_fp_qc_counter_n { color: $fp-ok; }
|
||||||
|
&.o_fp_qc_counter_fail .o_fp_qc_counter_n { color: $fp-bad; }
|
||||||
|
&.o_fp_qc_counter_pending .o_fp_qc_counter_n { color: $fp-ink-mute; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_progress_bar {
|
||||||
|
height: 6px;
|
||||||
|
background-color: $fp-card-soft;
|
||||||
|
border-radius: $fp-radius-pill;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_progress_fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: $fp-accent;
|
||||||
|
border-radius: $fp-radius-pill;
|
||||||
|
transition: width $fp-dur $fp-ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Thickness card ----------
|
||||||
|
.o_fp_qc_thickness_card {
|
||||||
|
@include fp-card($fp-elev-1);
|
||||||
|
padding: $fp-space-4 $fp-space-5;
|
||||||
|
margin-bottom: $fp-space-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_thickness_head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-4;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_thickness_title {
|
||||||
|
font-size: $fp-text-md;
|
||||||
|
font-weight: $fp-weight-semibold;
|
||||||
|
|
||||||
|
.fa { color: $fp-accent; margin-right: $fp-space-2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_thickness_sub {
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
margin-top: $fp-space-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Checklist ----------
|
||||||
|
.o_fp_qc_list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
margin-bottom: $fp-space-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item {
|
||||||
|
@include fp-card($fp-elev-1);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow $fp-dur $fp-ease,
|
||||||
|
transform $fp-dur $fp-ease;
|
||||||
|
|
||||||
|
&.o_fp_qc_item_pass {
|
||||||
|
// Left accent strip — subtle indicator that doesn't scream at you
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, $fp-ok 4px, transparent 4px) $fp-card;
|
||||||
|
}
|
||||||
|
&.o_fp_qc_item_fail {
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, $fp-bad 4px, transparent 4px) $fp-card;
|
||||||
|
}
|
||||||
|
&.o_fp_qc_item_na {
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, $fp-ink-faint 4px, transparent 4px) $fp-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.o_fp_qc_item_open { box-shadow: $fp-elev-2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-4;
|
||||||
|
padding: $fp-space-4 $fp-space-5;
|
||||||
|
min-height: $fp-touch-min + $fp-space-3;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@include fp-hover-only {
|
||||||
|
&:hover { background-color: color-mix(in srgb, #{$fp-accent} 4%, transparent); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: $fp-radius-md;
|
||||||
|
background-color: $fp-card-soft;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
font-size: $fp-text-md;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_name {
|
||||||
|
font-size: $fp-text-md;
|
||||||
|
font-weight: $fp-weight-medium;
|
||||||
|
color: $fp-ink;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_optional {
|
||||||
|
margin-left: $fp-space-2;
|
||||||
|
font-size: $fp-text-xs;
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_meta {
|
||||||
|
display: flex;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: $fp-space-1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_value {
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_item_photo_ind {
|
||||||
|
color: $fp-accent;
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px $fp-space-2;
|
||||||
|
font-size: $fp-text-xs;
|
||||||
|
font-weight: $fp-weight-semibold;
|
||||||
|
border-radius: $fp-radius-sm;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_badge_pass { @include fp-pill('--bs-success'); }
|
||||||
|
.o_fp_qc_badge_fail { @include fp-pill('--bs-danger'); }
|
||||||
|
.o_fp_qc_badge_na { @include fp-pill('--bs-secondary'); }
|
||||||
|
.o_fp_qc_badge_pending { @include fp-pill('--bs-info'); }
|
||||||
|
|
||||||
|
.o_fp_qc_chevron {
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Expanded detail ----------
|
||||||
|
.o_fp_qc_item_detail {
|
||||||
|
padding: $fp-space-4 $fp-space-5 $fp-space-5;
|
||||||
|
border-top: 1px solid color-mix(in srgb, #{$fp-border} 60%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $fp-space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_guidance {
|
||||||
|
background-color: $fp-card-soft;
|
||||||
|
padding: $fp-space-3 $fp-space-4;
|
||||||
|
border-radius: $fp-radius-md;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
font-size: $fp-text-sm;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_value_row,
|
||||||
|
.o_fp_qc_notes_row,
|
||||||
|
.o_fp_qc_photo_row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $fp-space-2;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: $fp-text-xs;
|
||||||
|
font-weight: $fp-weight-semibold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_value_input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
height: $fp-touch-min;
|
||||||
|
padding: 0 $fp-space-4;
|
||||||
|
font-size: $fp-text-lg;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background-color: $fp-card-soft;
|
||||||
|
border: none;
|
||||||
|
border-radius: $fp-radius-md;
|
||||||
|
color: $fp-ink;
|
||||||
|
|
||||||
|
&:focus { @include fp-focus-ring; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_uom {
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
font-size: $fp-text-md;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_range {
|
||||||
|
font-size: $fp-text-xs;
|
||||||
|
color: $fp-ink-mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_notes_row textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: $fp-space-3 $fp-space-4;
|
||||||
|
font-size: $fp-text-base;
|
||||||
|
background-color: $fp-card-soft;
|
||||||
|
border: none;
|
||||||
|
border-radius: $fp-radius-md;
|
||||||
|
color: $fp-ink;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus { @include fp-focus-ring; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_actions_row {
|
||||||
|
display: flex;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Buttons ----------
|
||||||
|
.o_fp_qc_btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-2;
|
||||||
|
min-height: $fp-touch-min;
|
||||||
|
padding: 0 $fp-space-5;
|
||||||
|
font-size: $fp-text-md;
|
||||||
|
font-weight: $fp-weight-semibold;
|
||||||
|
border: none;
|
||||||
|
border-radius: $fp-radius-md;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform $fp-dur-fast $fp-ease,
|
||||||
|
box-shadow $fp-dur $fp-ease,
|
||||||
|
background-color $fp-dur $fp-ease;
|
||||||
|
|
||||||
|
&:active:not([disabled]) { transform: scale(0.97); }
|
||||||
|
&[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.fa { font-size: $fp-text-md; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_btn_primary {
|
||||||
|
background-color: $fp-accent;
|
||||||
|
color: white;
|
||||||
|
box-shadow: $fp-elev-1;
|
||||||
|
@include fp-hover-only {
|
||||||
|
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_btn_pass,
|
||||||
|
.o_fp_qc_btn_pass_lg {
|
||||||
|
background-color: $fp-ok;
|
||||||
|
color: white;
|
||||||
|
box-shadow: $fp-elev-1;
|
||||||
|
@include fp-hover-only {
|
||||||
|
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_btn_fail,
|
||||||
|
.o_fp_qc_btn_fail_lg {
|
||||||
|
background-color: $fp-bad;
|
||||||
|
color: white;
|
||||||
|
box-shadow: $fp-elev-1;
|
||||||
|
@include fp-hover-only {
|
||||||
|
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_btn_ghost,
|
||||||
|
.o_fp_qc_btn_ghost_lg {
|
||||||
|
background-color: $fp-card-soft;
|
||||||
|
color: $fp-ink-soft;
|
||||||
|
@include fp-hover-only {
|
||||||
|
&:hover:not([disabled]) {
|
||||||
|
background-color: color-mix(in srgb, #{$fp-ink-soft} 10%, $fp-card-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_qc_btn_pass_lg,
|
||||||
|
.o_fp_qc_btn_fail_lg,
|
||||||
|
.o_fp_qc_btn_ghost_lg {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 60px;
|
||||||
|
font-size: $fp-text-lg;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sign-off footer ----------
|
||||||
|
.o_fp_qc_footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: $fp-space-4;
|
||||||
|
background: color-mix(in srgb, $fp-page 85%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
padding: $fp-space-4;
|
||||||
|
border-radius: $fp-radius-lg;
|
||||||
|
box-shadow: $fp-elev-2;
|
||||||
|
display: flex;
|
||||||
|
gap: $fp-space-3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Responsive ----------
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
padding: $fp-space-3;
|
||||||
|
|
||||||
|
.o_fp_qc_header .o_fp_qc_title { font-size: $fp-text-xl; }
|
||||||
|
.o_fp_qc_progress_big { font-size: $fp-text-2xl; }
|
||||||
|
.o_fp_qc_footer {
|
||||||
|
flex-direction: column;
|
||||||
|
.o_fp_qc_btn_pass_lg,
|
||||||
|
.o_fp_qc_btn_fail_lg,
|
||||||
|
.o_fp_qc_btn_ghost_lg { width: 100%; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
-->
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_plating_quality.FpQcChecklist">
|
||||||
|
<div class="o_fp_qc">
|
||||||
|
|
||||||
|
<!-- ===== Loading / error ===== -->
|
||||||
|
<t t-if="state.loading">
|
||||||
|
<div class="o_fp_qc_state_loading">
|
||||||
|
<i class="fa fa-spinner fa-spin"/>
|
||||||
|
<span>Loading QC…</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="state.error">
|
||||||
|
<div class="o_fp_qc_state_error">
|
||||||
|
<i class="fa fa-exclamation-triangle"/>
|
||||||
|
<p><t t-esc="state.error"/></p>
|
||||||
|
<button class="btn btn-primary" t-on-click="refresh">Retry</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="state.check">
|
||||||
|
<!-- ===== Header ===== -->
|
||||||
|
<div class="o_fp_qc_header">
|
||||||
|
<div class="o_fp_qc_header_left">
|
||||||
|
<button class="o_fp_qc_back" t-on-click="openJob"
|
||||||
|
t-if="state.check.job_id"
|
||||||
|
title="Back to Job">
|
||||||
|
<i class="fa fa-arrow-left"/>
|
||||||
|
</button>
|
||||||
|
<div class="o_fp_qc_title_block">
|
||||||
|
<div class="o_fp_qc_breadcrumb">
|
||||||
|
<span><t t-esc="state.check.job_name"/></span>
|
||||||
|
<t t-if="state.check.partner_name">
|
||||||
|
<span class="o_fp_qc_sep">·</span>
|
||||||
|
<span><t t-esc="state.check.partner_name"/></span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<h1 class="o_fp_qc_title">
|
||||||
|
<t t-esc="state.check.template_name or 'QC Checklist'"/>
|
||||||
|
</h1>
|
||||||
|
<div class="o_fp_qc_sub">
|
||||||
|
<span class="o_fp_qc_ref"><t t-esc="state.check.name"/></span>
|
||||||
|
<t t-if="state.check.inspector_name">
|
||||||
|
<span class="o_fp_qc_sep">·</span>
|
||||||
|
<span>Inspector: <t t-esc="state.check.inspector_name"/></span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_state_chip"
|
||||||
|
t-att-class="'o_fp_qc_chip_' + state.check.state">
|
||||||
|
<t t-esc="state.check.state.replace('_', ' ').toUpperCase()"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Progress ===== -->
|
||||||
|
<div class="o_fp_qc_progress_card">
|
||||||
|
<div class="o_fp_qc_progress_numbers">
|
||||||
|
<div class="o_fp_qc_progress_big">
|
||||||
|
<t t-esc="progressPercent"/>%
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_progress_break">
|
||||||
|
<div class="o_fp_qc_counter o_fp_qc_counter_pass">
|
||||||
|
<span class="o_fp_qc_counter_n">
|
||||||
|
<t t-esc="state.check.lines_passed"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_fp_qc_counter_l">Pass</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_counter o_fp_qc_counter_fail">
|
||||||
|
<span class="o_fp_qc_counter_n">
|
||||||
|
<t t-esc="state.check.lines_failed"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_fp_qc_counter_l">Fail</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_counter o_fp_qc_counter_pending">
|
||||||
|
<span class="o_fp_qc_counter_n">
|
||||||
|
<t t-esc="state.check.lines_pending"/>
|
||||||
|
</span>
|
||||||
|
<span class="o_fp_qc_counter_l">Pending</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_progress_bar">
|
||||||
|
<div class="o_fp_qc_progress_fill"
|
||||||
|
t-att-style="'width:' + progressPercent + '%'"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Thickness PDF (if required) ===== -->
|
||||||
|
<t t-if="state.check.require_thickness_report_pdf or state.check.require_thickness_readings">
|
||||||
|
<div class="o_fp_qc_thickness_card">
|
||||||
|
<div class="o_fp_qc_thickness_head">
|
||||||
|
<div>
|
||||||
|
<div class="o_fp_qc_thickness_title">
|
||||||
|
<i class="fa fa-bar-chart"/>
|
||||||
|
Thickness Report
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_thickness_sub">
|
||||||
|
<t t-if="state.check.has_thickness_pdf">
|
||||||
|
PDF uploaded · <t t-esc="state.check.thickness_reading_count"/> reading(s) extracted
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
Upload Fischerscope / XDAL 600 PDF export
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_primary"
|
||||||
|
t-on-click="triggerPdfUpload"
|
||||||
|
t-att-disabled="state.saving">
|
||||||
|
<i class="fa fa-upload"/>
|
||||||
|
<t t-if="state.check.has_thickness_pdf">Replace PDF</t>
|
||||||
|
<t t-else="">Upload PDF</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ===== Checklist ===== -->
|
||||||
|
<div class="o_fp_qc_list">
|
||||||
|
<t t-foreach="state.lines" t-as="line" t-key="line.id">
|
||||||
|
<div class="o_fp_qc_item"
|
||||||
|
t-att-class="{
|
||||||
|
'o_fp_qc_item_pass': line.result == 'pass',
|
||||||
|
'o_fp_qc_item_fail': line.result == 'fail',
|
||||||
|
'o_fp_qc_item_na': line.result == 'na',
|
||||||
|
'o_fp_qc_item_pending': line.result == 'pending' or !line.result,
|
||||||
|
'o_fp_qc_item_open': state.expandedLineId == line.id,
|
||||||
|
}">
|
||||||
|
<div class="o_fp_qc_item_row"
|
||||||
|
t-on-click="() => this.toggleExpanded(line)">
|
||||||
|
<div class="o_fp_qc_item_icon">
|
||||||
|
<i class="fa" t-att-class="checkTypeIcon(line.check_type)"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_item_body">
|
||||||
|
<div class="o_fp_qc_item_name">
|
||||||
|
<t t-esc="line.name"/>
|
||||||
|
<t t-if="!line.required">
|
||||||
|
<span class="o_fp_qc_item_optional">(optional)</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qc_item_meta">
|
||||||
|
<span class="o_fp_qc_badge"
|
||||||
|
t-att-class="resultBadgeClass(line.result)">
|
||||||
|
<t t-esc="(line.result or 'pending').toUpperCase()"/>
|
||||||
|
</span>
|
||||||
|
<t t-if="line.requires_value and line.value">
|
||||||
|
<span class="o_fp_qc_item_value">
|
||||||
|
<t t-esc="line.value"/>
|
||||||
|
<t t-esc="line.value_uom"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<t t-if="line.requires_photo and line.has_photo">
|
||||||
|
<span class="o_fp_qc_item_photo_ind">
|
||||||
|
<i class="fa fa-camera"/>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="o_fp_qc_chevron fa"
|
||||||
|
t-att-class="state.expandedLineId == line.id ? 'fa-chevron-up' : 'fa-chevron-down'"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="state.expandedLineId == line.id">
|
||||||
|
<div class="o_fp_qc_item_detail">
|
||||||
|
<t t-if="line.description">
|
||||||
|
<div class="o_fp_qc_guidance">
|
||||||
|
<t t-esc="line.description"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="line.requires_value">
|
||||||
|
<div class="o_fp_qc_value_row">
|
||||||
|
<label>Measured Value</label>
|
||||||
|
<div class="o_fp_qc_value_input">
|
||||||
|
<input type="number" step="0.0001"
|
||||||
|
t-att-value="line.value or ''"
|
||||||
|
t-att-placeholder="line.value_uom or ''"
|
||||||
|
t-on-input="(ev) => this.onValueInput(line, ev)"/>
|
||||||
|
<span class="o_fp_qc_uom"><t t-esc="line.value_uom"/></span>
|
||||||
|
</div>
|
||||||
|
<t t-if="line.value_min or line.value_max">
|
||||||
|
<div class="o_fp_qc_range">
|
||||||
|
Range: <t t-esc="line.value_min"/> – <t t-esc="line.value_max"/>
|
||||||
|
<t t-esc="line.value_uom"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="line.requires_photo">
|
||||||
|
<div class="o_fp_qc_photo_row">
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
|
||||||
|
t-on-click="() => this.triggerPhoto(line)">
|
||||||
|
<i class="fa fa-camera"/>
|
||||||
|
<t t-if="line.has_photo">Replace photo</t>
|
||||||
|
<t t-else="">Add photo</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div class="o_fp_qc_notes_row">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea rows="2"
|
||||||
|
t-att-value="line.notes or ''"
|
||||||
|
t-on-input="(ev) => this.onNotesInput(line, ev)"
|
||||||
|
placeholder="Optional — anything the inspector saw that matters"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_qc_actions_row">
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_pass"
|
||||||
|
t-on-click="() => this.markLine(line, 'pass')"
|
||||||
|
t-att-disabled="state.saving">
|
||||||
|
<i class="fa fa-check"/>
|
||||||
|
Pass
|
||||||
|
</button>
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_fail"
|
||||||
|
t-on-click="() => this.markLine(line, 'fail')"
|
||||||
|
t-att-disabled="state.saving">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
Fail
|
||||||
|
</button>
|
||||||
|
<t t-if="!line.required">
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
|
||||||
|
t-on-click="() => this.markLine(line, 'na')"
|
||||||
|
t-att-disabled="state.saving">
|
||||||
|
N/A
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
<t t-if="line.result != 'pending'">
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
|
||||||
|
t-on-click="() => this.markLine(line, 'pending')"
|
||||||
|
t-att-disabled="state.saving">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Sign-off bar ===== -->
|
||||||
|
<t t-if="state.check.state != 'passed' and state.check.state != 'failed'">
|
||||||
|
<div class="o_fp_qc_footer">
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_pass_lg"
|
||||||
|
t-on-click="() => this.finalize('pass')"
|
||||||
|
t-att-disabled="!canFinalize or state.saving">
|
||||||
|
<i class="fa fa-check"/>
|
||||||
|
<span>Sign Off — PASS</span>
|
||||||
|
</button>
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_fail_lg"
|
||||||
|
t-on-click="() => this.finalize('fail')"
|
||||||
|
t-att-disabled="state.saving">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
<span>Fail QC</span>
|
||||||
|
</button>
|
||||||
|
<button class="o_fp_qc_btn o_fp_qc_btn_ghost_lg"
|
||||||
|
t-on-click="() => this.finalize('rework')"
|
||||||
|
t-att-disabled="state.saving or !anyFailed">
|
||||||
|
Send to Rework
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ===== Hidden file inputs ===== -->
|
||||||
|
<input type="file" t-ref="fileInput"
|
||||||
|
accept="image/*" capture="environment"
|
||||||
|
style="display:none"
|
||||||
|
t-on-change="onPhotoSelected"/>
|
||||||
|
<input type="file" t-ref="pdfInput"
|
||||||
|
accept="application/pdf"
|
||||||
|
style="display:none"
|
||||||
|
t-on-change="onPdfSelected"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Admin-facing views for QC checklist templates. Manager privilege
|
||||||
|
group required to create / edit templates.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="fp_qc_checklist_template_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.qc.checklist.template.list</field>
|
||||||
|
<field name="model">fp.qc.checklist.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="QC Checklist Templates" decoration-muted="not active">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="require_inspector_signoff" widget="boolean_toggle"/>
|
||||||
|
<field name="require_thickness_readings" widget="boolean_toggle"/>
|
||||||
|
<field name="require_thickness_report_pdf" widget="boolean_toggle"/>
|
||||||
|
<field name="check_count"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_qc_checklist_template_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.qc.checklist.template.form</field>
|
||||||
|
<field name="model">fp.qc.checklist.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="QC Template">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_checks" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-list">
|
||||||
|
<field name="check_count" widget="statinfo"
|
||||||
|
string="QC Instances"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<widget name="web_ribbon" title="Archived"
|
||||||
|
invisible="active" bg_color="text-bg-danger"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" placeholder="e.g. Aerospace / Nadcap QC"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Scope">
|
||||||
|
<field name="partner_id"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
placeholder="Global default (leave blank)"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<group string="Gate Policy">
|
||||||
|
<field name="require_inspector_signoff"/>
|
||||||
|
<field name="require_thickness_readings"/>
|
||||||
|
<field name="require_thickness_report_pdf"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Checklist Items" name="items">
|
||||||
|
<field name="line_ids">
|
||||||
|
<list string="Items" editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="check_type"/>
|
||||||
|
<field name="required" widget="boolean_toggle"/>
|
||||||
|
<field name="requires_value" widget="boolean_toggle"/>
|
||||||
|
<field name="value_min" optional="hide"/>
|
||||||
|
<field name="value_max" optional="hide"/>
|
||||||
|
<field name="value_uom" optional="hide"/>
|
||||||
|
<field name="requires_photo" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="check_type"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="required"/>
|
||||||
|
<field name="requires_photo"/>
|
||||||
|
<field name="requires_value"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Acceptance Range"
|
||||||
|
invisible="not requires_value">
|
||||||
|
<field name="value_min"/>
|
||||||
|
<field name="value_max"/>
|
||||||
|
<field name="value_uom"/>
|
||||||
|
</group>
|
||||||
|
<group string="Guidance">
|
||||||
|
<field name="description" nolabel="1"
|
||||||
|
placeholder="Inspection guidance shown to the operator on tap..."/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Notes" name="notes">
|
||||||
|
<field name="notes" nolabel="1"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_qc_checklist_template_search" model="ir.ui.view">
|
||||||
|
<field name="name">fp.qc.checklist.template.search</field>
|
||||||
|
<field name="model">fp.qc.checklist.template</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<filter name="global_default" string="Global Default"
|
||||||
|
domain="[('partner_id', '=', False)]"/>
|
||||||
|
<filter name="per_customer" string="Per Customer"
|
||||||
|
domain="[('partner_id', '!=', False)]"/>
|
||||||
|
<filter name="inactive" string="Archived"
|
||||||
|
domain="[('active', '=', False)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="by_customer" string="Customer"
|
||||||
|
context="{'group_by': 'partner_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_qc_checklist_template" model="ir.actions.act_window">
|
||||||
|
<field name="name">QC Checklist Templates</field>
|
||||||
|
<field name="res_model">fp.qc.checklist.template</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="fp_qc_checklist_template_search"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="fp_quality_check_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.plating.quality.check.list</field>
|
||||||
|
<field name="model">fusion.plating.quality.check</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="QC Checks"
|
||||||
|
decoration-info="state == 'draft'"
|
||||||
|
decoration-warning="state == 'in_progress'"
|
||||||
|
decoration-success="state == 'passed'"
|
||||||
|
decoration-danger="state == 'failed'"
|
||||||
|
decoration-muted="state == 'rework'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="job_id"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="template_id"/>
|
||||||
|
<field name="lines_passed"/>
|
||||||
|
<field name="lines_failed"/>
|
||||||
|
<field name="lines_pending"/>
|
||||||
|
<field name="line_count" optional="hide"/>
|
||||||
|
<field name="inspector_id"/>
|
||||||
|
<field name="completed_at"/>
|
||||||
|
<field name="state" widget="badge"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_quality_check_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.plating.quality.check.form</field>
|
||||||
|
<field name="model">fusion.plating.quality.check</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Quality Check">
|
||||||
|
<header>
|
||||||
|
<button name="action_open_tablet" type="object"
|
||||||
|
string="Open Mobile Checklist" class="btn-primary"
|
||||||
|
invisible="state in ('passed', 'failed')"/>
|
||||||
|
<button name="action_start" type="object"
|
||||||
|
string="Start Inspection" class="btn-secondary"
|
||||||
|
invisible="state != 'draft'"/>
|
||||||
|
<button name="action_pass" type="object"
|
||||||
|
string="Mark Passed" class="btn-success"
|
||||||
|
invisible="state in ('passed', 'failed', 'draft')"/>
|
||||||
|
<button name="action_fail" type="object"
|
||||||
|
string="Mark Failed" class="btn-danger"
|
||||||
|
invisible="state in ('passed', 'failed', 'draft')"
|
||||||
|
confirm="This marks the QC as FAILED and blocks the MO from closing. Continue?"/>
|
||||||
|
<button name="action_rework" type="object"
|
||||||
|
string="Send to Rework" class="btn-warning"
|
||||||
|
invisible="state in ('passed', 'failed', 'draft')"/>
|
||||||
|
<button name="action_reset_to_draft" type="object"
|
||||||
|
string="Reset to Draft"
|
||||||
|
invisible="state == 'draft'"
|
||||||
|
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||||
|
<button name="action_spawn_retry" type="object"
|
||||||
|
string="Create Retry QC" class="btn-secondary"
|
||||||
|
invisible="state != 'failed'"
|
||||||
|
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||||
|
<field name="state" widget="statusbar"
|
||||||
|
statusbar_visible="draft,in_progress,passed"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_open_tablet" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-tablet">
|
||||||
|
<div class="o_stat_info">
|
||||||
|
<span class="o_stat_text">Tablet</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<widget name="web_ribbon" title="PASSED"
|
||||||
|
invisible="state != 'passed'" bg_color="text-bg-success"/>
|
||||||
|
<widget name="web_ribbon" title="FAILED"
|
||||||
|
invisible="state != 'failed'" bg_color="text-bg-danger"/>
|
||||||
|
<widget name="web_ribbon" title="REWORK"
|
||||||
|
invisible="state != 'rework'" bg_color="text-bg-warning"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" readonly="1"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Job">
|
||||||
|
<field name="job_id" readonly="1"/>
|
||||||
|
<field name="partner_id" readonly="1"/>
|
||||||
|
<field name="template_id"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
readonly="state != 'draft'"/>
|
||||||
|
</group>
|
||||||
|
<group string="Sign-off">
|
||||||
|
<field name="inspector_id" readonly="1"/>
|
||||||
|
<field name="started_at" readonly="1"/>
|
||||||
|
<field name="completed_at" readonly="1"/>
|
||||||
|
<field name="overall_result" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Progress">
|
||||||
|
<group>
|
||||||
|
<field name="line_count" readonly="1"/>
|
||||||
|
<field name="lines_passed" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="lines_failed" readonly="1"/>
|
||||||
|
<field name="lines_pending" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Checklist" name="lines">
|
||||||
|
<field name="line_ids">
|
||||||
|
<list editable="bottom"
|
||||||
|
decoration-success="result == 'pass'"
|
||||||
|
decoration-danger="result == 'fail'"
|
||||||
|
decoration-muted="result == 'na'">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="check_type"/>
|
||||||
|
<field name="required" widget="boolean_toggle"/>
|
||||||
|
<field name="requires_value" column_invisible="1"/>
|
||||||
|
<field name="value" optional="show"
|
||||||
|
invisible="not requires_value"/>
|
||||||
|
<field name="value_uom" optional="hide"/>
|
||||||
|
<field name="value_min" optional="hide"/>
|
||||||
|
<field name="value_max" optional="hide"/>
|
||||||
|
<field name="value_in_range" widget="boolean_toggle"
|
||||||
|
optional="hide"/>
|
||||||
|
<field name="requires_photo" column_invisible="1"/>
|
||||||
|
<field name="photo_attachment_id"
|
||||||
|
invisible="not requires_photo"
|
||||||
|
widget="many2one_binary" optional="show"/>
|
||||||
|
<field name="notes" optional="hide"/>
|
||||||
|
<field name="result" widget="badge"
|
||||||
|
decoration-success="result == 'pass'"
|
||||||
|
decoration-danger="result == 'fail'"
|
||||||
|
decoration-muted="result == 'na'"
|
||||||
|
decoration-info="result == 'pending'"/>
|
||||||
|
<button name="action_mark_pass" type="object"
|
||||||
|
icon="fa-check" title="Pass"
|
||||||
|
invisible="result == 'pass'"/>
|
||||||
|
<button name="action_mark_fail" type="object"
|
||||||
|
icon="fa-times" title="Fail"
|
||||||
|
invisible="result == 'fail'"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Thickness Report" name="thickness">
|
||||||
|
<group>
|
||||||
|
<field name="thickness_report_pdf_id"
|
||||||
|
widget="many2one_binary"
|
||||||
|
help="Upload the Fischerscope / XDAL 600 PDF — readings will be auto-extracted."/>
|
||||||
|
<field name="thickness_reading_count" readonly="1"/>
|
||||||
|
<field name="require_thickness_readings" readonly="1"/>
|
||||||
|
<field name="require_thickness_report_pdf" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<field name="thickness_reading_ids">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="reading_number"/>
|
||||||
|
<field name="position_label"/>
|
||||||
|
<field name="nip_mils"/>
|
||||||
|
<field name="ni_percent"/>
|
||||||
|
<field name="p_percent"/>
|
||||||
|
<field name="auto_extracted" widget="boolean_toggle"/>
|
||||||
|
<field name="operator_id"/>
|
||||||
|
<field name="reading_datetime"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Notes" name="notes">
|
||||||
|
<field name="notes" nolabel="1"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_quality_check_search" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.plating.quality.check.search</field>
|
||||||
|
<field name="model">fusion.plating.quality.check</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="job_id"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="inspector_id"/>
|
||||||
|
<filter name="draft" string="Draft"
|
||||||
|
domain="[('state', '=', 'draft')]"/>
|
||||||
|
<filter name="in_progress" string="In Progress"
|
||||||
|
domain="[('state', '=', 'in_progress')]"/>
|
||||||
|
<filter name="passed" string="Passed"
|
||||||
|
domain="[('state', '=', 'passed')]"/>
|
||||||
|
<filter name="failed" string="Failed"
|
||||||
|
domain="[('state', '=', 'failed')]"/>
|
||||||
|
<filter name="rework" string="Rework"
|
||||||
|
domain="[('state', '=', 'rework')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="my_inspections" string="My Inspections"
|
||||||
|
domain="[('inspector_id', '=', uid)]"/>
|
||||||
|
<group>
|
||||||
|
<filter name="by_customer" string="Customer"
|
||||||
|
context="{'group_by': 'partner_id'}"/>
|
||||||
|
<filter name="by_state" string="Status"
|
||||||
|
context="{'group_by': 'state'}"/>
|
||||||
|
<filter name="by_template" string="Template"
|
||||||
|
context="{'group_by': 'template_id'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_quality_check" model="ir.actions.act_window">
|
||||||
|
<field name="name">Quality Checks</field>
|
||||||
|
<field name="res_model">fusion.plating.quality.check</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="fp_quality_check_search"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===== Menu — add QC Checks + QC Templates under Quality ===== -->
|
||||||
|
<menuitem id="menu_fp_quality_check"
|
||||||
|
name="Quality Checks"
|
||||||
|
parent="fusion_plating_quality.menu_fp_quality"
|
||||||
|
action="action_fp_quality_check"
|
||||||
|
sequence="7"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_config_qc_template"
|
||||||
|
name="QC Checklist Templates"
|
||||||
|
parent="fusion_plating.menu_fp_config"
|
||||||
|
action="action_fp_qc_checklist_template"
|
||||||
|
sequence="85"
|
||||||
|
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Extend res.partner with the QC requirement flag + template picker.
|
||||||
|
Adds a "Quality Control" group to the "Plating Documents" tab that
|
||||||
|
fusion_plating_certificates opens on the partner form.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_partner_form_fp_qc" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.form.fp.qc</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="fusion_plating_certificates.view_partner_form_fp_document_prefs"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//page[@name='fp_document_prefs']" position="inside">
|
||||||
|
<group string="Quality Control"
|
||||||
|
name="fp_qc_prefs_group">
|
||||||
|
<p class="text-muted" colspan="2">
|
||||||
|
When QC sign-off is required, confirming a Manufacturing Order
|
||||||
|
auto-creates a checklist from the active template. The MO
|
||||||
|
cannot be marked Done until an inspector passes the QC.
|
||||||
|
</p>
|
||||||
|
<group>
|
||||||
|
<field name="x_fc_requires_qc" widget="boolean_toggle"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="x_fc_qc_template_id"
|
||||||
|
options="{'no_create': True}"
|
||||||
|
invisible="not x_fc_requires_qc"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user