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 |
|
||||
| 7 | IoT tuning (per-sensor polling interval + ingest rate-limit; entech seeded with 25 tanks / 50 sensors) | **Shipped 2026-04-22** | client transcript D |
|
||||
| 8 | Receiving / Inspection / QC flow restructure (fp.receiving = box count only; new fp.racking.inspection per MO; WO soft gate; delivery box-parity warning) | **Shipped 2026-04-22** | client transcript E |
|
||||
| 9 | Process variants per part + persistent draft order wizard + tax per line + payment terms wired + chatter + nicer breadcrumbs across plating models | **Shipped 2026-04-26** | various wizard/UX |
|
||||
| 10 | Quote → Direct Order promotion (won quotes consolidate onto a single PO instead of spawning standalone 1-line SOs) | **Shipped 2026-04-26** | redundancy concern |
|
||||
| 11 | **MRP cutout — bridge_mrp deletion + MRP module uninstall** (7-phase migration: relocate models, swap inherits, drop legacy FK columns, uninstall mrp + 10 cascade modules) | **Shipped 2026-04-26** | bridge_mrp removal |
|
||||
| 12 | **Native Quality — full Odoo `quality_control` replacement + RMA + integration polish** | **In flight** (planned) | quality dependency removal |
|
||||
| ∞ | First-off / last-off QC | Deferred | client transcript F |
|
||||
| ∞ | VEC machine auto-ingest (Word-format thickness report from network-connected XRF; different machine from Fischerscope) | Deferred | client transcript G |
|
||||
| ∞ | RMA customer portal submission | Deferred (Sub 12 phase 2) | follow-on to Sub 12 |
|
||||
|
||||
### Sub 2 Locked Decisions (2026-04-21)
|
||||
|
||||
@@ -435,3 +440,274 @@ rewrite code as new requirements surface. Each sub-project has its own design do
|
||||
3. Read the corresponding spec in `docs/superpowers/specs/YYYY-MM-DD-sub<N>-*-design.md`.
|
||||
4. Read the implementation plan if one exists.
|
||||
5. Continue from the next un-checked step.
|
||||
|
||||
---
|
||||
|
||||
## Sub 11 — MRP Cutout (shipped 2026-04-26)
|
||||
|
||||
The Odoo `mrp` module + 10 cascade dependents have been **uninstalled**. `fusion_plating_bridge_mrp` is gone. The plating shop runs entirely on `fp.job` / `fp.job.step`. Document this so a fresh session doesn't try to re-add MRP refs.
|
||||
|
||||
### Final state
|
||||
- **0 rows** in `mrp_production`, `mrp_workorder`, `mrp_workcenter`
|
||||
- **205+** `fp.job` rows, **1,800+** `fp.job.step` rows in production
|
||||
- 0 custom-table FKs to MRP
|
||||
- Modules uninstalled: `mrp`, `mrp_workorder`, `mrp_account`, `sale_mrp`, `purchase_mrp`, `quality_mrp`, `quality_mrp_workorder`, `project_mrp*`, `fusion_manufacturing`, `fusion_plating_bridge_mrp`
|
||||
|
||||
### Where things ended up after Sub 11
|
||||
|
||||
| Model / asset | Old home | New home |
|
||||
|---|---|---|
|
||||
| `fp.work.role`, `fp.operator.proficiency`, `hr.employee` shop-roles, `fusion.plating.process.node.x_fc_work_role_id` | `fusion_plating_bridge_mrp` | `fusion_plating` (core) |
|
||||
| `fp.qc.checklist.template` (+line) | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||
| `fusion.plating.quality.check` (+line) | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||
| `fp.thickness.reading.quality_check_id` link + `auto_extracted` | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||
| `res.partner.x_fc_requires_qc` + `x_fc_qc_template_id` | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||
| `fp.job.consumption` | `fusion_plating_bridge_mrp` | `fusion_plating_jobs` |
|
||||
| `sale.order.x_fc_workflow_stage` + `x_fc_assigned_manager_id` + workflow buttons | `fusion_plating_bridge_mrp` | `fusion_plating_jobs` |
|
||||
| QC tablet OWL (`fp_qc_checklist.js/.xml/.scss`) + `/fp/qc/*` controller | `fusion_plating_bridge_mrp` | `fusion_plating_quality` |
|
||||
| Production Priorities kanban | `fusion_plating_bridge_mrp` (mrp.workorder) | `fusion_plating_jobs` (fp.job.step) |
|
||||
|
||||
### Hard rules going forward
|
||||
1. **Never re-introduce `'mrp'` as a manifest dep.** Use `fp.job` for jobs, `fp.job.step` for operations.
|
||||
2. **`x_fc_job_id` is the canonical job link**, not `production_id`. Drop legacy MO refs as you find them.
|
||||
3. **`fusion_plating_quality` depends on `fusion_plating_shopfloor`** for SCSS tokens (`$fp-page`, `$fp-card`, `$fp-accent`). Don't strip that dep — the QC tablet bundle breaks without it.
|
||||
4. **The QC tablet OWL template namespace is `fusion_plating_quality.FpQcChecklist`** (was `fusion_plating_bridge_mrp.FpQcChecklist`). Don't rename back.
|
||||
|
||||
---
|
||||
|
||||
## Sub 12 — Native Quality Module (in flight, ~4 working days)
|
||||
|
||||
**Goal**: Build a complete native quality stack matching Odoo `quality_control` functionality plus plating-specific extensions (RMA, CAPA effectiveness, holds, 8D reports), with **zero dependency** on Odoo's `quality` / `quality_control`. After Sub 12 lands, those modules + `fusion_plating_bridge_quality` get uninstalled.
|
||||
|
||||
### Module choice
|
||||
**Enrich `fusion_plating_quality`** — no new modules. Existing module already owns NCR / CAPA / Hold / Check / Calibration / AVL / FAIR / Audit / Doc Control / Customer Spec / Contract Review.
|
||||
|
||||
### Locked decisions (don't re-ask in fresh session)
|
||||
| Q | Decision |
|
||||
|---|---|
|
||||
| RMA portal submission | **Deferred to phase 2.** Internal-only RMA in Sub 12. |
|
||||
| 8D format | **Full 8D** (D1–D8 sections in the combined NCR + CAPA PDF). |
|
||||
| Quality Dashboard | **5 tabs** (Holds / Checks / NCRs / CAPAs / RMAs) in one client action with a summary header that totals open + overdue across all five. |
|
||||
| Auto-NCR + auto-Hold on RMA receive | **Automatic**, with a manager-only "skip this RMA's auto-spawn" toggle on the RMA record. |
|
||||
| Auto-CAPA on NCR closure | **Automatic when severity in (high, critical)**, with a manager-only override on the NCR. |
|
||||
| Quality team model | Build a dedicated `fp.quality.team` rather than reusing `res.groups`. Teams need their own kanban grouping + per-team escalation chains, which groups don't model well. |
|
||||
| Stage model vs. state field on NCR | **Both.** Keep the existing `state` Selection (used by code paths). Add a parallel `stage_id` Many2one to `fp.quality.alert.stage` for the kanban draggable view. Computed bidirectional sync (stage ↔ state). |
|
||||
| Trigger-based quality.point | Build a new `fp.quality.point` model. Trigger types: `manual`, `receiving_done`, `job_step_done`, `job_done`. Existing `fp.qc.checklist.template` STAYS — it's the *template* a point fires; the point is the *trigger rule*. |
|
||||
| RMA back-link to original SO line | Required field. Always carry the original SO line so cert / part / coating context follows the return. |
|
||||
| Module choice (one or many) | **Single module** — enrich `fusion_plating_quality`. |
|
||||
|
||||
### Phase A — RMA model (~1 day)
|
||||
**File**: `fusion_plating_quality/models/fp_rma.py`
|
||||
|
||||
#### Model: `fusion.plating.rma`
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | Char | Sequence `RMA/YYYY/NNNN` |
|
||||
| `partner_id` | M2O `res.partner` | Required |
|
||||
| `sale_order_id` | M2O `sale.order` | The original order being returned |
|
||||
| `sale_order_line_ids` | M2M `sale.order.line` | Specific lines being returned (subset of the SO) |
|
||||
| `original_job_ids` | M2O `fp.job` (compute from SO lines) | For navigation only |
|
||||
| `state` | Selection | `draft / authorised / shipped_to_us / received / triaged / resolving / resolved / closed / cancelled` |
|
||||
| `trigger_source` | Selection | `customer_complaint / qc_fail_post_ship / inspection_post_delivery / other` |
|
||||
| `severity` | Selection | `low / medium / high / critical` |
|
||||
| `complaint_description` | Html | What the customer reported |
|
||||
| `triage_findings` | Html | What we found on inspection |
|
||||
| `resolution_type` | Selection | `replace / rework / refund / scrap` |
|
||||
| `resolution_notes` | Html | Free-form notes on the chosen path |
|
||||
| `replacement_job_id` | M2O `fp.job` | When replace/rework — the new job created |
|
||||
| `refund_invoice_id` | M2O `account.move` | When refund — the credit note |
|
||||
| `inbound_receiving_id` | M2O `fp.receiving` | The receiving record auto-created when carrier delivers |
|
||||
| `inbound_picking_id` | M2O `stock.picking` | Optional — if a stock.picking is also created |
|
||||
| `linked_ncr_ids` | O2M `fusion.plating.ncr` (inverse `rma_id`) | NCRs spawned from this RMA |
|
||||
| `linked_capa_ids` | O2M `fusion.plating.capa` (related via NCRs) | Read-only roll-up |
|
||||
| `linked_hold_ids` | O2M `fusion.plating.quality.hold` (inverse `rma_id`) | Holds placed on returned parts |
|
||||
| `qty_returned` | Integer | Total units customer is returning |
|
||||
| `qty_received` | Integer | Counted on receipt |
|
||||
| `customer_tracking` | Char | Customer's outbound tracking # |
|
||||
| `our_tracking` | Char | Our return-to-shop tracking # |
|
||||
| `carrier_id` | M2O `delivery.carrier` | Optional |
|
||||
| `qr_code` | Binary (compute) | QR encoding `/fp/rma/<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',
|
||||
'version': '19.0.8.7.1',
|
||||
'version': '19.0.9.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -100,6 +100,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/fp_job_views.xml',
|
||||
'views/fp_job_step_views.xml',
|
||||
'views/fp_jobs_menu.xml',
|
||||
'data/fp_work_role_data.xml',
|
||||
'views/fp_work_role_views.xml',
|
||||
'data/fp_recipe_enp_alum_basic.xml',
|
||||
'data/fp_recipe_enp_steel_basic.xml',
|
||||
'data/fp_recipe_enp_sp.xml',
|
||||
|
||||
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 res_company
|
||||
from . import res_config_settings
|
||||
|
||||
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp via
|
||||
# fusion_plating_jobs to core, so other downstream modules
|
||||
# (fusion_plating_cgp, etc.) that touch hr.employee can see the
|
||||
# shop-roles fields without a transitive dep on jobs.
|
||||
from . import fp_work_role
|
||||
from . import fp_proficiency
|
||||
from . import hr_employee
|
||||
from . import fp_process_node_inherit
|
||||
|
||||
@@ -40,3 +40,15 @@ class FpJobStepTimeLog(models.Model):
|
||||
log.duration_minutes = delta.total_seconds() / 60.0
|
||||
else:
|
||||
log.duration_minutes = 0.0
|
||||
|
||||
@api.depends('user_id', 'date_started', 'duration_minutes')
|
||||
def _compute_display_name(self):
|
||||
for log in self:
|
||||
user = log.user_id.name or 'User'
|
||||
when = log.date_started.strftime('%Y-%m-%d %H:%M') if log.date_started else ''
|
||||
mins = ('%.0f min' % log.duration_minutes) if log.duration_minutes else 'open'
|
||||
rec_bits = [user]
|
||||
if when:
|
||||
rec_bits.append(when)
|
||||
rec_bits.append(mins)
|
||||
log.display_name = ' · '.join(rec_bits)
|
||||
|
||||
@@ -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_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
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',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.2.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Group parts into rack or barrel loads for tank processing.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -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')]",
|
||||
tracking=True,
|
||||
)
|
||||
workorder_id = fields.Many2one(
|
||||
'mrp.workorder', string='Work Order',
|
||||
help='The WO this batch ran through. Used for material traceability.',
|
||||
tracking=True,
|
||||
)
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', string='Manufacturing Order',
|
||||
related='workorder_id.production_id', store=True, readonly=True,
|
||||
)
|
||||
# Phase 6 (Sub 11) — workorder_id / production_id retired (MRP gone).
|
||||
# Native equivalents: x_fc_step_id (fp.job.step) + x_fc_job_id (fp.job)
|
||||
# are added by fusion_plating_jobs and carry the same traceability.
|
||||
part_count = fields.Integer(string='Part Count')
|
||||
start_time = fields.Datetime(string='Process Start', tracking=True)
|
||||
end_time = fields.Datetime(string='Process End', tracking=True)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.12.2.0',
|
||||
'version': '19.0.13.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
@@ -59,33 +59,30 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_work_role_data.xml',
|
||||
# Phase 1 (Sub 11) — fp_work_role_data + fp_qc_data relocated
|
||||
# to fusion_plating_jobs.
|
||||
'data/fp_cron_data.xml',
|
||||
'data/fp_qc_data.xml',
|
||||
'wizard/fp_recipe_config_wizard_views.xml',
|
||||
'views/mrp_workcenter_views.xml',
|
||||
'views/mrp_workorder_views.xml',
|
||||
'views/fp_qc_template_views.xml',
|
||||
'views/fp_quality_check_views.xml',
|
||||
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs / fusion_plating_quality.
|
||||
# 'views/fp_qc_template_views.xml',
|
||||
# 'views/fp_quality_check_views.xml',
|
||||
# 'views/fp_job_consumption_views.xml',
|
||||
# 'views/fp_work_role_views.xml',
|
||||
'views/mrp_production_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_quality_hold_views.xml',
|
||||
'views/fp_batch_views.xml',
|
||||
'views/fp_workorder_priority_views.xml',
|
||||
'views/fp_job_consumption_views.xml',
|
||||
'views/fp_work_role_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
# Phase 3 (Sub 11) — replaced by native fp.job.step priority kanban
|
||||
# in fusion_plating_jobs/views/fp_step_priority_views.xml.
|
||||
# 'views/fp_workorder_priority_views.xml',
|
||||
# Phase 4 (Sub 11) — relocated to fusion_plating_quality.
|
||||
# 'views/res_partner_views.xml',
|
||||
'views/fp_serial_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
# Depends on _fp_shopfloor_tokens.scss being loaded first —
|
||||
# shopfloor is in depends, so its tokens bundle-concatenate
|
||||
# before this file and define $fp-card / $fp-accent / etc.
|
||||
'fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss',
|
||||
'fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml',
|
||||
'fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js',
|
||||
],
|
||||
# Phase 2 (Sub 11) — QC tablet OWL relocated to fusion_plating_quality.
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import fp_qc_controller
|
||||
# Phase 2 (Sub 11) — QC controller relocated to fusion_plating_quality.
|
||||
# from . import fp_qc_controller
|
||||
|
||||
@@ -11,16 +11,35 @@ from . import fp_portal_job
|
||||
from . import fp_quality_hold
|
||||
from . import fp_delivery
|
||||
from . import fp_batch
|
||||
# fusion.plating.job.node.override (mrp.production-bound) — kept here
|
||||
# until Phase 5 deletes the bridge module. The native fp.job-bound
|
||||
# override is `fp.job.node.override` in fusion_plating_jobs (different
|
||||
# model, different table).
|
||||
from . import fp_job_node_override
|
||||
from . import fp_job_consumption
|
||||
# Phase 1 (Sub 11) — fp.job.consumption is now in fusion_plating_jobs.
|
||||
# bridge_mrp can't depend on jobs (would create a cycle through
|
||||
# notifications/reports), so the legacy production_id/workorder_id
|
||||
# fields are gone for good. mrp.production has 0 rows in native mode
|
||||
# so the loss of the back-link is data-safe.
|
||||
# from . import fp_job_consumption
|
||||
from . import account_move
|
||||
from . import sale_order
|
||||
from . import fp_work_role
|
||||
from . import hr_employee
|
||||
from . import fp_proficiency
|
||||
from . import fp_process_node
|
||||
from . import fp_qc_template
|
||||
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||
# from . import fp_work_role
|
||||
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||
# from . import hr_employee
|
||||
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||
# from . import fp_proficiency
|
||||
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs (fp.work.role lives there).
|
||||
# from . import fp_process_node
|
||||
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
|
||||
# from . import fp_qc_template
|
||||
# Phase 1 (Sub 11) — model relocated to fusion_plating_quality.
|
||||
# This file now contains only a thin inherit that restores the
|
||||
# legacy production_id back-link until Phase 5 retires the bridge.
|
||||
from . import fp_quality_check
|
||||
from . import fp_thickness_reading
|
||||
from . import res_partner
|
||||
# Phase 1 (Sub 11) — relocated to fusion_plating_quality.
|
||||
# from . import fp_thickness_reading
|
||||
# Phase 4 (Sub 11) — relocated to fusion_plating_quality.
|
||||
# from . import res_partner
|
||||
from . import fp_serial
|
||||
|
||||
@@ -2,85 +2,24 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Phase 1 (Sub 11) — the model proper now lives in
|
||||
# fusion_plating_jobs. This file restores the legacy production_id +
|
||||
# workorder_id back-links so bridge_mrp's mrp.production O2M
|
||||
# (x_fc_consumption_ids) keeps resolving until Phase 5 deletes the
|
||||
# bridge module.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpJobConsumption(models.Model):
|
||||
"""A single consumable drawdown charged to a manufacturing order.
|
||||
|
||||
Sources include bath replenishment applied against a job, masking tape
|
||||
rolls, PPE, nickel salts — anything that has a cost and should roll
|
||||
into job costing.
|
||||
|
||||
Kept deliberately lightweight: one row per event, cost derived from
|
||||
`product.standard_price` at log time (snapshot, not reactive).
|
||||
"""
|
||||
_name = 'fp.job.consumption'
|
||||
_description = 'Fusion Plating — Job Consumption'
|
||||
_order = 'logged_date desc, id desc'
|
||||
_inherit = 'fp.job.consumption'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', string='Manufacturing Order',
|
||||
required=True, ondelete='cascade',
|
||||
ondelete='cascade', index=True,
|
||||
)
|
||||
workorder_id = fields.Many2one(
|
||||
'mrp.workorder', string='Work Order',
|
||||
domain="[('production_id', '=', production_id)]",
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product', string='Product', required=True,
|
||||
domain="[('sale_ok', '=', False)]",
|
||||
)
|
||||
product_name = fields.Char(
|
||||
string='Product Name (snapshot)',
|
||||
help='Free-text product label if no inventory product is linked.',
|
||||
)
|
||||
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
|
||||
uom_id = fields.Many2one(
|
||||
'uom.uom', string='UoM',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
unit_cost = fields.Monetary(
|
||||
string='Unit Cost (snapshot)', currency_field='currency_id',
|
||||
help='Taken from product.standard_price at log time.',
|
||||
)
|
||||
total_cost = fields.Monetary(
|
||||
string='Total Cost', currency_field='currency_id',
|
||||
compute='_compute_total_cost', store=True,
|
||||
)
|
||||
logged_date = fields.Datetime(
|
||||
string='Logged', default=fields.Datetime.now,
|
||||
)
|
||||
logged_by_id = fields.Many2one(
|
||||
'res.users', string='Logged By', default=lambda self: self.env.user,
|
||||
)
|
||||
source = fields.Selection(
|
||||
[('replenishment', 'Bath Replenishment'),
|
||||
('masking', 'Masking Material'),
|
||||
('ppe', 'PPE / Consumables'),
|
||||
('chemistry', 'Process Chemistry'),
|
||||
('other', 'Other')],
|
||||
string='Source', default='other', required=True,
|
||||
)
|
||||
replenishment_id = fields.Many2one(
|
||||
'fusion.plating.bath.replenishment.suggestion',
|
||||
string='Replenishment Suggestion',
|
||||
ondelete='set null',
|
||||
)
|
||||
notes = fields.Char(string='Notes')
|
||||
|
||||
@api.depends('quantity', 'unit_cost')
|
||||
def _compute_total_cost(self):
|
||||
for rec in self:
|
||||
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product(self):
|
||||
if self.product_id:
|
||||
self.product_name = self.product_id.display_name
|
||||
self.unit_cost = self.product_id.standard_price or 0.0
|
||||
self.uom_id = self.product_id.uom_id or False
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpJobNodeOverride(models.Model):
|
||||
@@ -58,6 +58,14 @@ class FpJobNodeOverride(models.Model):
|
||||
help='Whether this optional step is active for this job.',
|
||||
)
|
||||
|
||||
@api.depends('production_id', 'node_id', 'included')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
mo = rec.production_id.name or '(no MO)'
|
||||
node = rec.node_id.display_name or '(no node)'
|
||||
tag = 'included' if rec.included else 'excluded'
|
||||
rec.display_name = '%s · %s [%s]' % (mo, node, tag)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_production_node',
|
||||
'unique(production_id, node_id)',
|
||||
|
||||
@@ -2,621 +2,22 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Per-MO QC instance.
|
||||
#
|
||||
# Phase 1 (Sub 11) — the QC model proper now lives in
|
||||
# fusion_plating_quality. This file restores the legacy production_id
|
||||
# back-link on fusion.plating.quality.check so bridge_mrp's
|
||||
# mrp.production O2M (x_fc_qc_check_ids) keeps resolving until Phase 5
|
||||
# deletes the bridge module entirely.
|
||||
|
||||
When an MO confirms and the customer requires QC, we clone the active
|
||||
checklist template into a `fusion.plating.quality.check` with one line
|
||||
per template line. The inspector picks it up on the tablet, walks the
|
||||
checks, and signs off — which unblocks `mrp.production.button_mark_done`.
|
||||
|
||||
The QC also owns the Fischerscope / XDAL 600 thickness report PDF.
|
||||
When the operator uploads one, we extract per-reading data server-side
|
||||
and auto-create `fp.thickness.reading` rows so the CoC PDF picks them up.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpQualityCheck(models.Model):
|
||||
_name = 'fusion.plating.quality.check'
|
||||
_description = 'Fusion Plating — Quality Check'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc'
|
||||
_inherit = 'fusion.plating.quality.check'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference', required=True, copy=False, readonly=True,
|
||||
default=lambda self: self._default_name(), tracking=True,
|
||||
)
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', string='Manufacturing Order',
|
||||
required=True, ondelete='cascade', tracking=True,
|
||||
index=True,
|
||||
ondelete='cascade', index=True,
|
||||
help='Legacy MRP back-link. Native flow uses job_id; this stays '
|
||||
'for bridge_mrp until Phase 5 cuts the module.',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
compute='_compute_partner_id', store=True,
|
||||
)
|
||||
template_id = fields.Many2one(
|
||||
'fp.qc.checklist.template', string='Template',
|
||||
help='The checklist template these lines were cloned from.',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('in_progress', 'In Progress'),
|
||||
('passed', 'Passed'),
|
||||
('failed', 'Failed'),
|
||||
('rework', 'Rework Required'),
|
||||
],
|
||||
string='Status', default='draft', required=True, tracking=True,
|
||||
)
|
||||
overall_result = fields.Selection(
|
||||
[('pass', 'Pass'), ('fail', 'Fail'), ('partial', 'Partial Pass')],
|
||||
string='Result', tracking=True,
|
||||
help='Summary outcome — set when inspector signs off.',
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fusion.plating.quality.check.line', 'check_id',
|
||||
string='Check Items',
|
||||
)
|
||||
line_count = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
lines_passed = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
lines_failed = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
lines_pending = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
|
||||
inspector_id = fields.Many2one(
|
||||
'res.users', string='Inspector',
|
||||
help='Whoever signed the QC off. Filled when state moves to '
|
||||
'passed/failed.',
|
||||
tracking=True,
|
||||
)
|
||||
started_at = fields.Datetime(
|
||||
string='Started', help='First time inspector opened this check.',
|
||||
)
|
||||
completed_at = fields.Datetime(
|
||||
string='Completed', help='When the check was signed off.',
|
||||
tracking=True,
|
||||
)
|
||||
notes = fields.Html(string='Inspector Notes')
|
||||
|
||||
# Fischerscope / XDAL 600 PDF + auto-extracted readings
|
||||
thickness_report_pdf_id = fields.Many2one(
|
||||
'ir.attachment', string='Thickness Report PDF',
|
||||
help='Upload the Fischerscope / XDAL 600 export. On upload we '
|
||||
'parse the PDF and auto-create fp.thickness.reading rows.',
|
||||
)
|
||||
thickness_reading_ids = fields.One2many(
|
||||
'fp.thickness.reading', 'quality_check_id',
|
||||
string='Thickness Readings',
|
||||
)
|
||||
thickness_reading_count = fields.Integer(
|
||||
compute='_compute_thickness_count',
|
||||
)
|
||||
|
||||
# Cached gate-policy flags from the template (denormalized so
|
||||
# button_mark_done doesn't have to reach through a potentially-null
|
||||
# template).
|
||||
require_thickness_readings = fields.Boolean(
|
||||
related='template_id.require_thickness_readings',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
require_thickness_report_pdf = fields.Boolean(
|
||||
related='template_id.require_thickness_report_pdf',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
require_inspector_signoff = fields.Boolean(
|
||||
related='template_id.require_inspector_signoff',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', related='production_id.company_id',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computed
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends('production_id.origin')
|
||||
def _compute_partner_id(self):
|
||||
SO = self.env['sale.order']
|
||||
for rec in self:
|
||||
partner = False
|
||||
mo = rec.production_id
|
||||
if mo and mo.origin:
|
||||
so = SO.search([('name', '=', mo.origin)], limit=1)
|
||||
if so:
|
||||
partner = so.partner_id
|
||||
rec.partner_id = partner
|
||||
|
||||
@api.depends('line_ids.result')
|
||||
def _compute_line_stats(self):
|
||||
for rec in self:
|
||||
rec.line_count = len(rec.line_ids)
|
||||
rec.lines_passed = len(rec.line_ids.filtered(
|
||||
lambda l: l.result == 'pass'
|
||||
))
|
||||
rec.lines_failed = len(rec.line_ids.filtered(
|
||||
lambda l: l.result == 'fail'
|
||||
))
|
||||
rec.lines_pending = len(rec.line_ids.filtered(
|
||||
lambda l: l.result in (False, 'pending')
|
||||
))
|
||||
|
||||
@api.depends('thickness_reading_ids')
|
||||
def _compute_thickness_count(self):
|
||||
for rec in self:
|
||||
rec.thickness_reading_count = len(rec.thickness_reading_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create + sequence
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.plating.quality.check',
|
||||
)
|
||||
return seq or 'QC/NEW'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == '/':
|
||||
vals['name'] = self._default_name()
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory — spawn a QC from a template
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def create_for_production(self, production, template=None):
|
||||
"""Spin up a QC record for an MO, cloning lines from the template.
|
||||
|
||||
If no template is passed, we try to resolve one from the MO's
|
||||
customer. Returns the created check, or an empty recordset if
|
||||
no template matches (=> no QC required for this customer).
|
||||
"""
|
||||
self = self.sudo()
|
||||
if template is None:
|
||||
partner = False
|
||||
if production.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', production.origin)], limit=1,
|
||||
)
|
||||
if so:
|
||||
partner = so.partner_id
|
||||
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
|
||||
partner,
|
||||
)
|
||||
if not template:
|
||||
return self.browse() # empty — no QC required
|
||||
|
||||
# Avoid duplicates — one active (non-failed) check per MO
|
||||
existing = self.search([
|
||||
('production_id', '=', production.id),
|
||||
('state', '!=', 'failed'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
check = self.create({
|
||||
'production_id': production.id,
|
||||
'template_id': template.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
Line = self.env['fusion.plating.quality.check.line']
|
||||
for tline in template.line_ids.sorted('sequence'):
|
||||
Line.create({
|
||||
'check_id': check.id,
|
||||
'sequence': tline.sequence,
|
||||
'name': tline.name,
|
||||
'description': tline.description,
|
||||
'check_type': tline.check_type,
|
||||
'required': tline.required,
|
||||
'requires_value': tline.requires_value,
|
||||
'value_min': tline.value_min,
|
||||
'value_max': tline.value_max,
|
||||
'value_uom': tline.value_uom,
|
||||
'requires_photo': tline.requires_photo,
|
||||
'result': 'pending',
|
||||
})
|
||||
production.message_post(
|
||||
body=_('QC checklist "%s" created — %d items to inspect.') % (
|
||||
template.name, len(template.line_ids),
|
||||
),
|
||||
)
|
||||
return check
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_start(self):
|
||||
for rec in self:
|
||||
if rec.state == 'draft':
|
||||
rec.write({
|
||||
'state': 'in_progress',
|
||||
'started_at': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
rec.message_post(body=_('QC started.'))
|
||||
|
||||
def action_pass(self):
|
||||
for rec in self:
|
||||
rec._ensure_all_required_complete()
|
||||
rec.write({
|
||||
'state': 'passed',
|
||||
'overall_result': 'pass',
|
||||
'completed_at': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
rec.message_post(body=Markup(
|
||||
'<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.4 — Consumables tied to jobs
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_consumption_ids = fields.One2many(
|
||||
'fp.job.consumption', 'production_id',
|
||||
string='Consumables Log',
|
||||
)
|
||||
# Phase 1 (Sub 11) — fp.job.consumption relocated to
|
||||
# fusion_plating_jobs. The MO-side O2M would create a circular
|
||||
# dependency (bridge_mrp → jobs → notifications → bridge_mrp), and
|
||||
# mrp.production has 0 rows in native mode, so the field is gone.
|
||||
# The native fp.job analogue carries consumption via
|
||||
# fp.job.consumption.job_id.
|
||||
x_fc_consumables_cost = fields.Monetary(
|
||||
string='Consumables Cost', compute='_compute_job_costs',
|
||||
store=True, currency_field='x_fc_currency_id',
|
||||
@@ -228,7 +230,7 @@ class MrpProduction(models.Model):
|
||||
|
||||
def _compute_consumption_count(self):
|
||||
for mo in self:
|
||||
mo.x_fc_consumption_count = len(mo.x_fc_consumption_ids)
|
||||
mo.x_fc_consumption_count = 0
|
||||
|
||||
@api.depends('origin')
|
||||
def _compute_sale_order_id(self):
|
||||
@@ -305,7 +307,6 @@ class MrpProduction(models.Model):
|
||||
}
|
||||
|
||||
@api.depends(
|
||||
'x_fc_consumption_ids.total_cost',
|
||||
'workorder_ids.duration',
|
||||
'workorder_ids.workcenter_id.costs_hour',
|
||||
'origin',
|
||||
@@ -314,7 +315,8 @@ class MrpProduction(models.Model):
|
||||
SO = self.env['sale.order']
|
||||
for mo in self:
|
||||
currency = mo.company_id.currency_id
|
||||
consumables = sum(mo.x_fc_consumption_ids.mapped('total_cost'))
|
||||
# Phase 1 (Sub 11) — consumption now lives on fp.job, not MO.
|
||||
consumables = 0.0
|
||||
labour = 0.0
|
||||
for wo in mo.workorder_ids:
|
||||
rate = wo.workcenter_id.costs_hour or 0.0
|
||||
@@ -1218,29 +1220,35 @@ class MrpProduction(models.Model):
|
||||
def _resolve_mo_process_tree(self):
|
||||
"""Resolve which process-tree root to walk for this MO.
|
||||
|
||||
Sub 3 — prefers the linked part's cloned tree
|
||||
(SO line's x_fc_part_catalog_id.default_process_id); falls back
|
||||
to the legacy x_fc_recipe_id for MOs without a linked part or
|
||||
without a composed part tree.
|
||||
Resolution priority (Sub 9 — process variants):
|
||||
1. SO line's `x_fc_process_variant_id` (per-order variant pick)
|
||||
2. Linked part's `default_process_id` (the part's default variant)
|
||||
3. Legacy `x_fc_recipe_id` (coating config / product match)
|
||||
|
||||
Single entry point so Sub 4 / Sub 5 updates touch one method.
|
||||
Multi-line MOs: first line wins. Variants are part-scoped, and a
|
||||
single MO is bound to a single part group via x_fc_wo_group_tag,
|
||||
so first-line semantics match how the WO walker batches.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Resolve part via SO lines (MO's origin → sale.order → first
|
||||
# line's part). mrp.production has no direct part link; the
|
||||
# relationship lives on sale.order.line.
|
||||
part = False
|
||||
if self.origin:
|
||||
line = False
|
||||
if 'x_fc_sale_order_line_ids' in self._fields and self.x_fc_sale_order_line_ids:
|
||||
line = self.x_fc_sale_order_line_ids[0]
|
||||
elif self.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', self.origin)], limit=1,
|
||||
)
|
||||
if so and so.order_line:
|
||||
first_line = so.order_line[0]
|
||||
if 'x_fc_part_catalog_id' in first_line._fields:
|
||||
part = first_line.x_fc_part_catalog_id
|
||||
if part and part.default_process_id:
|
||||
return part.default_process_id
|
||||
# Fallback — legacy recipe lookup (coating config / product match)
|
||||
line = so.order_line[0]
|
||||
|
||||
if line:
|
||||
if ('x_fc_process_variant_id' in line._fields
|
||||
and line.x_fc_process_variant_id):
|
||||
return line.x_fc_process_variant_id
|
||||
if ('x_fc_part_catalog_id' in line._fields
|
||||
and line.x_fc_part_catalog_id
|
||||
and line.x_fc_part_catalog_id.default_process_id):
|
||||
return line.x_fc_part_catalog_id.default_process_id
|
||||
|
||||
return self.x_fc_recipe_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -159,11 +159,10 @@ class MrpWorkorder(models.Model):
|
||||
'manager; the Tablet Station shows only WOs assigned to the '
|
||||
'logged-in user.',
|
||||
)
|
||||
x_fc_work_role_id = fields.Many2one(
|
||||
'fp.work.role', string='Role',
|
||||
help='Shop role required to perform this step (copied from the '
|
||||
'recipe operation on WO generation).',
|
||||
)
|
||||
# Phase 1 (Sub 11) — fp.work.role relocated to fusion_plating_jobs.
|
||||
# bridge_mrp can't depend on jobs (cycle through notifications →
|
||||
# bridge_mrp), so the legacy WO field is gone. mrp.workorder has 0
|
||||
# rows in native mode, so nothing breaks.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timer audit — surface the who / when of the timer on the WO header.
|
||||
|
||||
@@ -45,17 +45,17 @@ class SaleOrder(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_workflow_stage = fields.Selection(
|
||||
[
|
||||
('draft', 'Quotation — awaiting confirmation'),
|
||||
('awaiting_parts', 'Parts en route'),
|
||||
('inspecting', 'Inspecting received parts'),
|
||||
('accept_parts', 'Ready to accept parts'),
|
||||
('assign_work', 'Ready to assign manager'),
|
||||
('in_production', 'In production'),
|
||||
('ready_to_ship', 'Production complete — ready to ship'),
|
||||
('shipped', 'Shipped — awaiting invoice'),
|
||||
('invoicing', 'Awaiting invoice / payment'),
|
||||
('draft', 'Quote'),
|
||||
('awaiting_parts', 'Parts'),
|
||||
('inspecting', 'Inspecting'),
|
||||
('accept_parts', 'Accept'),
|
||||
('assign_work', 'Assign'),
|
||||
('in_production', 'Production'),
|
||||
('ready_to_ship', 'Ready'),
|
||||
('shipped', 'Shipped'),
|
||||
('invoicing', 'Invoicing'),
|
||||
('paid', 'Paid'),
|
||||
('complete', 'Complete'),
|
||||
('complete', 'Done'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
compute='_compute_workflow_stage',
|
||||
@@ -199,13 +199,30 @@ class SaleOrder(models.Model):
|
||||
) % (tag or 'single-line'))
|
||||
continue
|
||||
|
||||
# Recipe: first line's coating -> recipe_id.
|
||||
# Recipe priority (Sub 9):
|
||||
# 1. Line's explicit process variant
|
||||
# 2. Line's part default variant
|
||||
# 3. Line's coating recipe_id
|
||||
# 4. Any recipe-type process node (last-ditch fallback)
|
||||
recipe = False
|
||||
for ln in lines:
|
||||
cc = ln.x_fc_coating_config_id
|
||||
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
|
||||
recipe = cc.recipe_id
|
||||
if ('x_fc_process_variant_id' in ln._fields
|
||||
and ln.x_fc_process_variant_id):
|
||||
recipe = ln.x_fc_process_variant_id
|
||||
break
|
||||
if not recipe:
|
||||
for ln in lines:
|
||||
pc = ln.x_fc_part_catalog_id
|
||||
if (pc and 'default_process_id' in pc._fields
|
||||
and pc.default_process_id):
|
||||
recipe = pc.default_process_id
|
||||
break
|
||||
if not recipe:
|
||||
for ln in lines:
|
||||
cc = ln.x_fc_coating_config_id
|
||||
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
|
||||
recipe = cc.recipe_id
|
||||
break
|
||||
if not recipe:
|
||||
recipe = self.env['fusion.plating.process.node'].search(
|
||||
[('node_type', '=', 'recipe')], limit=1,
|
||||
|
||||
@@ -5,30 +5,10 @@ access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_worko
|
||||
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_job_node_override_legacy_operator,fusion.plating.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_job_node_override_legacy_supervisor,fusion.plating.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_node_override_legacy_manager,fusion.plating.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -95,7 +95,6 @@
|
||||
string="Assigned To"
|
||||
required="1"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_work_role_id" readonly="1"/>
|
||||
<field name="x_fc_wo_kind" widget="badge" readonly="1"
|
||||
decoration-info="x_fc_wo_kind == 'wet'"
|
||||
decoration-warning="x_fc_wo_kind == 'bake'"
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<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">
|
||||
<button name="action_view_productions" type="object"
|
||||
class="oe_stat_button" icon="fa-industry"
|
||||
invisible="x_fc_production_count == 0">
|
||||
class="oe_stat_button" icon="fa-industry">
|
||||
<field name="x_fc_production_count" widget="statinfo"
|
||||
string="Manufacturing"/>
|
||||
</button>
|
||||
@@ -53,12 +54,20 @@
|
||||
</button>
|
||||
</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 =====
|
||||
One (sometimes two) visible at a time. Pattern mirrors
|
||||
fusion_claims ADP handling — invisible bindings key off
|
||||
the computed x_fc_workflow_stage selector. -->
|
||||
<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"/>
|
||||
|
||||
<button name="action_fp_mark_inspected"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.4.0.0',
|
||||
'version': '19.0.5.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
@@ -27,7 +27,6 @@ Includes Fischerscope thickness measurement data capture.
|
||||
'fusion_plating_portal',
|
||||
'fusion_plating_batch',
|
||||
'fusion_plating_configurator',
|
||||
'mrp',
|
||||
'sale_management',
|
||||
],
|
||||
'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)]",
|
||||
)
|
||||
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')
|
||||
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
|
||||
process_description = fields.Char(
|
||||
@@ -84,23 +85,32 @@ class FpCertificate(models.Model):
|
||||
string='Baths Used',
|
||||
)
|
||||
|
||||
@api.depends('production_id')
|
||||
@api.depends('sale_order_id')
|
||||
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')
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
Job = self.env.get('fp.job')
|
||||
empty_batch = self.env['fusion.plating.batch']
|
||||
for rec in self:
|
||||
if Batch is not None and rec.production_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:
|
||||
if Batch is None or Job is None or not rec.sale_order_id:
|
||||
rec.batch_ids = empty_batch
|
||||
rec.batch_count = 0
|
||||
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(
|
||||
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
|
||||
string='Status', default='draft', tracking=True, required=True,
|
||||
@@ -289,12 +299,12 @@ class FpCertificate(models.Model):
|
||||
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
|
||||
'requires actual thickness readings on every CoC '
|
||||
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
|
||||
'against MO %(mo)s via the Tablet Station before '
|
||||
'issuing.'
|
||||
'against the job for SO %(so)s via the Tablet Station '
|
||||
'before issuing.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_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.message_post(body=_('Certificate issued.'))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpThicknessReading(models.Model):
|
||||
@@ -20,9 +20,8 @@ class FpThicknessReading(models.Model):
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', ondelete='cascade',
|
||||
)
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', string='Manufacturing Order',
|
||||
)
|
||||
# Phase 6 (Sub 11) — production_id retired (MRP module gone).
|
||||
# Thickness readings link via certificate_id and quality_check_id.
|
||||
reading_number = fields.Integer(
|
||||
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(
|
||||
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="partner_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="production_id"/>
|
||||
<field name="portal_job_id"/>
|
||||
<field name="issue_date"/>
|
||||
</group>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.14.2.0',
|
||||
'version': '19.0.17.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -49,12 +49,13 @@ Provides:
|
||||
'views/fp_quote_configurator_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'views/fp_sale_description_template_views.xml',
|
||||
'wizard/fp_direct_order_wizard_views.xml',
|
||||
'wizard/fp_add_from_so_wizard_views.xml',
|
||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||
'wizard/fp_quote_promote_wizard_views.xml',
|
||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
],
|
||||
'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):
|
||||
"""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')
|
||||
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()
|
||||
if not part:
|
||||
return {'ok': False, 'error': 'Part not found'}
|
||||
@@ -134,6 +156,7 @@ class FpPartComposerController(http.Controller):
|
||||
},
|
||||
'has_tree': bool(root),
|
||||
'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')
|
||||
def load_template(self, part_id, template_id):
|
||||
"""Clone a shared template into a part-scoped tree.
|
||||
def load_template(self, part_id, template_id, variant_label=None,
|
||||
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
|
||||
deep-clones the template subtree with part ownership set. Finally
|
||||
pins ``part.default_process_id`` to the new root.
|
||||
Unlike the previous behaviour (wipe & replace), this now adds a
|
||||
variant alongside any existing ones. The first variant created
|
||||
becomes the default; subsequent variants only become default if
|
||||
``make_default`` is true.
|
||||
|
||||
The whole operation runs inside a savepoint — if anything fails
|
||||
partway through, the part is left in its previous state.
|
||||
If ``variant_label`` is omitted, the controller uses the
|
||||
template's name as the label.
|
||||
"""
|
||||
part = request.env['fp.part.catalog'].browse(int(part_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':
|
||||
return {'ok': False, 'error': 'Template must be a recipe-type node'}
|
||||
|
||||
label = (variant_label or tpl.name or 'Variant').strip()
|
||||
|
||||
try:
|
||||
with request.env.cr.savepoint():
|
||||
# 1. Delete any prior part-owned tree for this part.
|
||||
# parent_id has ondelete='cascade', so deleting root(s)
|
||||
# wipes their descendants. Use search so we don't assume
|
||||
# only default_process_id's tree exists.
|
||||
prior = request.env['fusion.plating.process.node'].search([
|
||||
('part_catalog_id', '=', part.id),
|
||||
])
|
||||
if prior:
|
||||
prior.unlink()
|
||||
# First variant on this part is always the default.
|
||||
is_first = not part.process_variant_ids
|
||||
make_default_flag = bool(make_default) or is_first
|
||||
|
||||
# 2. Deep-clone the template subtree with part ownership.
|
||||
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.
|
||||
part.default_process_id = new_root.id
|
||||
if make_default_flag:
|
||||
# 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([
|
||||
('part_catalog_id', '=', part.id),
|
||||
('id', 'child_of', new_root.id),
|
||||
])
|
||||
|
||||
_logger.info(
|
||||
'Part Composer: cloned template %s (%s) → part %s (%s), %s nodes, by uid %s',
|
||||
tpl.id, tpl.name, part.id, part.display_name,
|
||||
node_count, request.env.uid,
|
||||
'Part Composer: variant "%s" cloned from template %s onto part %s (default=%s, %s nodes), uid %s',
|
||||
label, tpl.id, part.id, make_default_flag, node_count, request.env.uid,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'root_id': new_root.id,
|
||||
'node_count': node_count,
|
||||
'variants': _list_variants(part),
|
||||
}
|
||||
except Exception as exc:
|
||||
_logger.exception('Part Composer load_template failed')
|
||||
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"/>
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
# composes a process. The Composer client action sets this to the
|
||||
# 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(
|
||||
'fusion.plating.process.node',
|
||||
string='Default Process',
|
||||
domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe')]",
|
||||
help='Root of this part\'s composed process tree. Use the '
|
||||
'Compose button to edit. When a job runs for this part, '
|
||||
'work orders are generated from this tree.',
|
||||
domain="[('part_catalog_id', '=', id), ('node_type', '=', 'recipe'), "
|
||||
"('parent_id', '=', False)]",
|
||||
help='Root of this part\'s default process variant. Use the '
|
||||
'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) ----
|
||||
x_fc_default_coating_config_id = fields.Many2one(
|
||||
@@ -404,6 +426,28 @@ class FpPartCatalog(models.Model):
|
||||
'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):
|
||||
self.ensure_one()
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class 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.')
|
||||
|
||||
@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 = [
|
||||
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
|
||||
'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 '
|
||||
'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'
|
||||
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):
|
||||
"""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()
|
||||
if self.state != 'draft':
|
||||
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.',
|
||||
)
|
||||
|
||||
x_fc_workorder_count = fields.Integer(
|
||||
string='Active WOs',
|
||||
compute='_compute_workorder_count',
|
||||
# NB. The compute lives in fusion_plating_bridge_mrp. We keep a
|
||||
# stub field here so configurator's SO view (loaded before
|
||||
# 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 ----
|
||||
x_fc_wo_completion = fields.Char(
|
||||
string='WO Progress',
|
||||
@@ -307,34 +341,6 @@ class SaleOrder(models.Model):
|
||||
- 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):
|
||||
self.ensure_one()
|
||||
return {
|
||||
|
||||
@@ -60,6 +60,19 @@ class SaleOrderLine(models.Model):
|
||||
string='Linked Quote',
|
||||
help='Quote that seeded this line. Links back for audit trail.',
|
||||
)
|
||||
# Sub 9 — process variant override per line. NULL means "use the
|
||||
# part's default variant". Domain restricts to root recipe nodes
|
||||
# owned by the chosen part.
|
||||
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(
|
||||
string='Archived',
|
||||
default=False,
|
||||
@@ -226,6 +239,16 @@ class SaleOrderLine(models.Model):
|
||||
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
|
||||
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')
|
||||
def _onchange_coating_clears_thickness(self):
|
||||
"""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_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_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_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
|
||||
|
||||
|
@@ -4,9 +4,9 @@
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Thin wrapper around the existing recipe tree editor. Gives a part
|
||||
// its own composed process tree by cloning a shared template, then
|
||||
// hands off to the fp_recipe_tree_editor action for edits.
|
||||
// Sub 9 — multi-variant Composer. Each part can carry several recipe trees
|
||||
// (e.g. "Standard ENP", "Selective Masking", "Rework"). One is the default;
|
||||
// estimators may pick a non-default variant on a per-order basis.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL: static template + static props = ["*"]
|
||||
@@ -27,8 +27,6 @@ export class FpPartProcessComposer extends Component {
|
||||
this.action = useService("action");
|
||||
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) || {};
|
||||
this.partId = params.part_id || null;
|
||||
|
||||
@@ -38,9 +36,11 @@ export class FpPartProcessComposer extends Component {
|
||||
part: null,
|
||||
hasTree: false,
|
||||
rootId: null,
|
||||
variants: [],
|
||||
templates: [],
|
||||
selectedTemplateId: null,
|
||||
loadingTemplate: false,
|
||||
newVariantLabel: "",
|
||||
busy: false,
|
||||
});
|
||||
|
||||
onMounted(() => this.refresh());
|
||||
@@ -67,10 +67,9 @@ export class FpPartProcessComposer extends Component {
|
||||
this.state.part = stateRes.part;
|
||||
this.state.hasTree = stateRes.has_tree;
|
||||
this.state.rootId = stateRes.root_id || null;
|
||||
this.state.variants = stateRes.variants || [];
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
async onLoadTemplate() {
|
||||
if (!this.state.selectedTemplateId) return;
|
||||
const confirmReplace = this.state.hasTree
|
||||
? window.confirm("This will replace the current process tree for this part. Continue?")
|
||||
: true;
|
||||
if (!confirmReplace) return;
|
||||
onNewLabelInput(ev) {
|
||||
this.state.newVariantLabel = ev.target.value || "";
|
||||
}
|
||||
|
||||
this.state.loadingTemplate = true;
|
||||
try {
|
||||
async onAddVariantFromTemplate() {
|
||||
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", {
|
||||
part_id: this.partId,
|
||||
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(
|
||||
`Template loaded — ${res.node_count} nodes cloned into this part's tree.`,
|
||||
{ type: "success" }
|
||||
`Variant "${label}" added (${res.node_count} nodes).`,
|
||||
{ type: "success" },
|
||||
);
|
||||
this.state.newVariantLabel = "";
|
||||
await this.refresh();
|
||||
// Hand off directly to the tree editor so the user can
|
||||
// immediately start customising.
|
||||
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) {
|
||||
this.notification.add(
|
||||
`Load failed: ${err.message || err}`,
|
||||
{ type: "danger" }
|
||||
);
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
} finally {
|
||||
this.state.loadingTemplate = false;
|
||||
this.state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
openRecipeEditor(rootId) {
|
||||
const id = rootId || this.state.rootId;
|
||||
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({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_recipe_tree_editor",
|
||||
@@ -137,10 +200,6 @@ export class FpPartProcessComposer extends Component {
|
||||
}
|
||||
|
||||
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({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.part.catalog",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
OWL template for the part-scoped Process Composer client action.
|
||||
Sub 9 — multi-variant Composer.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
@@ -36,53 +37,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_part_composer_loader">
|
||||
<label>Load Existing Process:</label>
|
||||
<select class="form-select" 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>
|
||||
<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 class="o_fp_part_composer_variants mt-3">
|
||||
<h4>Process Variants</h4>
|
||||
<p class="text-muted small">
|
||||
Add as many variants as you need (e.g. "Standard", "Selective Masking", "Rework").
|
||||
One variant is the default; order lines may pick another at entry time.
|
||||
</p>
|
||||
<t t-if="state.variants.length === 0">
|
||||
<div class="o_fp_part_composer_empty">
|
||||
<i class="fa fa-cogs fa-2x"/>
|
||||
<p>No variants yet. Pick a template below and add the first one.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_fp_part_composer_empty">
|
||||
<i class="fa fa-cogs fa-3x"/>
|
||||
<p>No process composed yet.</p>
|
||||
<p class="text-muted">
|
||||
Pick a template above and click <strong>Load</strong> to get started.
|
||||
</p>
|
||||
</div>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Default</th>
|
||||
<th>Label</th>
|
||||
<th>Recipe Name</th>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
action="action_fp_direct_order_wizard"
|
||||
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"
|
||||
name="Quotations"
|
||||
parent="menu_fp_sales"
|
||||
|
||||
@@ -167,20 +167,30 @@
|
||||
<page string="Process" name="process">
|
||||
<group>
|
||||
<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>
|
||||
<div class="mt-2">
|
||||
<button name="action_open_part_composer" type="object"
|
||||
string="Compose"
|
||||
icon="fa-wrench"
|
||||
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>
|
||||
<p class="text-muted mt-3">
|
||||
The <strong>Compose</strong> button opens the Process Composer where you can
|
||||
load a shared template and customise it for this part. When a job runs for
|
||||
this part, work orders are generated from the composed tree.
|
||||
The <strong>Compose</strong> button opens the Process Composer where you can add
|
||||
multiple process <em>variants</em> for this part — for example "Standard ENP",
|
||||
"Selective Masking", "Rework". One variant is flagged as default; estimators
|
||||
may pick a different variant on a per-order basis.
|
||||
</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 string="Dimensions & Complexity" name="dimensions">
|
||||
<group>
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Quote Configurator">
|
||||
<header>
|
||||
<button name="action_create_quotation"
|
||||
string="Create Quotation"
|
||||
<button name="action_promote_to_direct_order"
|
||||
string="Add to Direct Order"
|
||||
type="object"
|
||||
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"
|
||||
string="Recalculate"
|
||||
type="object"
|
||||
|
||||
@@ -62,10 +62,9 @@
|
||||
<button name="action_view_workorders"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-cogs"
|
||||
invisible="x_fc_workorder_count == 0">
|
||||
icon="fa-cogs">
|
||||
<field name="x_fc_workorder_count" widget="statinfo"
|
||||
string="Active WOs"/>
|
||||
string="Work Orders"/>
|
||||
</button>
|
||||
<button name="action_view_ncrs"
|
||||
type="object"
|
||||
@@ -93,6 +92,7 @@
|
||||
<field name="x_fc_configurator_id" readonly="1"/>
|
||||
<field name="x_fc_part_catalog_id"/>
|
||||
<field name="x_fc_coating_config_id"/>
|
||||
<field name="x_fc_process_summary" readonly="1"/>
|
||||
</group>
|
||||
<group string="RFQ / PO">
|
||||
<field name="x_fc_po_number"/>
|
||||
@@ -182,17 +182,29 @@
|
||||
</group>
|
||||
</page>
|
||||
</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">
|
||||
<field name="x_fc_part_catalog_id" optional="show"/>
|
||||
<field name="x_fc_description_template_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"
|
||||
optional="show"/>
|
||||
<field name="x_fc_internal_description"
|
||||
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
|
||||
optional="hide"/>
|
||||
<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"
|
||||
options="{'no_create': True}"
|
||||
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_add_from_so_wizard
|
||||
from . import fp_add_from_quote_wizard
|
||||
from . import fp_quote_promote_wizard
|
||||
from . import fp_part_catalog_import_wizard
|
||||
|
||||
@@ -45,17 +45,7 @@ class FpAddFromQuoteWizard(models.TransientModel):
|
||||
for q in self.quote_ids:
|
||||
if not q.part_catalog_id or not q.coating_config_id:
|
||||
continue
|
||||
final = q.estimator_override_price or q.calculated_price
|
||||
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,
|
||||
})
|
||||
Line._create_from_quote(q, wizard)
|
||||
copied += 1
|
||||
|
||||
if not copied:
|
||||
|
||||
@@ -7,7 +7,8 @@ from odoo import _, api, fields, models
|
||||
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'
|
||||
_description = 'Fusion Plating - Direct Order Line'
|
||||
_order = 'sequence, id'
|
||||
@@ -59,38 +60,50 @@ class FpDirectOrderLine(models.TransientModel):
|
||||
string='Additional Treatments',
|
||||
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
|
||||
# for this line. Resolution priority:
|
||||
# 1. Part's composed process (fp.part.catalog.default_process_id)
|
||||
# — a part-scoped customisation set via the Process Composer.
|
||||
# 2. 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.
|
||||
# 1. Explicit process_variant_id (estimator pick)
|
||||
# 2. Part's default variant (fp.part.catalog.default_process_id)
|
||||
# 3. Primary Treatment's default recipe (fp.coating.config.recipe_id)
|
||||
effective_process_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process',
|
||||
compute='_compute_effective_process',
|
||||
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.",
|
||||
help='Process tree that will generate work orders for this line.',
|
||||
)
|
||||
effective_process_source = fields.Char(
|
||||
compute='_compute_effective_process',
|
||||
help='Tells the estimator whether the process comes from the '
|
||||
'part (customised) or the coating (shared default).',
|
||||
help='Tells the estimator where the process comes from: '
|
||||
'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')
|
||||
def _compute_effective_process(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
|
||||
if rec.part_catalog_id else False)
|
||||
if part_proc:
|
||||
rec.effective_process_id = part_proc
|
||||
rec.effective_process_source = 'Part (customised)'
|
||||
rec.effective_process_source = 'Part default'
|
||||
continue
|
||||
cc_proc = (rec.coating_config_id.recipe_id
|
||||
if rec.coating_config_id else False)
|
||||
@@ -101,6 +114,14 @@ class FpDirectOrderLine(models.TransientModel):
|
||||
rec.effective_process_id = 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 ----
|
||||
quantity = fields.Integer(string='Qty', default=1, required=True)
|
||||
currency_id = fields.Many2one(related='wizard_id.currency_id')
|
||||
@@ -113,6 +134,19 @@ class FpDirectOrderLine(models.TransientModel):
|
||||
currency_field='currency_id',
|
||||
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 ----
|
||||
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
|
||||
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
|
||||
# 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')
|
||||
def _onchange_lookup_price(self):
|
||||
@@ -343,6 +398,30 @@ class FpDirectOrderLine(models.TransientModel):
|
||||
_apply(match)
|
||||
|
||||
# ---- 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):
|
||||
"""Return the part to use for the SO line, optionally bumping revision."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -7,23 +7,60 @@ from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDirectOrderWizard(models.TransientModel):
|
||||
class FpDirectOrderWizard(models.Model):
|
||||
"""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
|
||||
sale.order.line per wizard line. The user reviews the resulting
|
||||
quotation, makes any adjustments, and clicks Send / Confirm
|
||||
manually. The wizard does NOT auto-confirm and does NOT auto-email
|
||||
the customer — that was deliberately removed in Sub 1 after the
|
||||
client requested a review step before anything leaves the shop.
|
||||
the customer.
|
||||
"""
|
||||
_name = 'fp.direct.order.wizard'
|
||||
_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 ----
|
||||
# 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(
|
||||
'res.partner', string='Customer', required=True,
|
||||
'res.partner', string='Customer',
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
tracking=True,
|
||||
)
|
||||
partner_invoice_id = fields.Many2one(
|
||||
'res.partner', string='Invoice Address',
|
||||
@@ -46,7 +83,7 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
string='Planned Start', default=fields.Date.context_today,
|
||||
)
|
||||
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) ----
|
||||
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
|
||||
# underlying SO is confirmed with a chase activity scheduled for
|
||||
# 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_filename = fields.Char(string='PO Filename')
|
||||
po_pending = fields.Boolean(
|
||||
@@ -101,6 +138,16 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
progress_initial_percent = fields.Float(
|
||||
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 = fields.Text(string='Internal Notes')
|
||||
@@ -121,6 +168,17 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
# ---- Missing info banner ----
|
||||
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 ----
|
||||
@api.depends('line_ids.line_subtotal', 'line_ids.quantity')
|
||||
def _compute_totals(self):
|
||||
@@ -151,7 +209,7 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
# ---- Onchange ----
|
||||
@api.onchange('partner_id')
|
||||
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:
|
||||
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
|
||||
@@ -159,11 +217,93 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
addrs = self.partner_id.address_get(['invoice', 'delivery'])
|
||||
self.partner_invoice_id = addrs.get('invoice') 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:
|
||||
self.partner_invoice_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 ----
|
||||
@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):
|
||||
"""Open a sub-wizard to copy lines from a prior sale.order."""
|
||||
self.ensure_one()
|
||||
@@ -207,6 +347,8 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
Sub 1 in the Fine-Tuning Initiative roadmap (CLAUDE.md).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.partner_id:
|
||||
raise UserError(_('Pick a customer before confirming.'))
|
||||
if not self.line_ids:
|
||||
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_deposit_percent': self.deposit_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_is_blanket_order': self.is_blanket_order,
|
||||
'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_is_one_off': line.is_one_off,
|
||||
'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.
|
||||
# Revision snapshot auto-fills on SO-line create from the part.
|
||||
'x_fc_serial_id': line.serial_id.id or False,
|
||||
'x_fc_job_number': line.job_number 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
|
||||
@@ -324,6 +474,27 @@ class FpDirectOrderWizard(models.TransientModel):
|
||||
# auto-email to the client.
|
||||
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
|
||||
# during the build loop so rev-bumped lines write defaults to
|
||||
# the NEW revision, not the pre-bump one.
|
||||
|
||||
@@ -6,12 +6,32 @@
|
||||
<field name="model">fp.direct.order.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<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"
|
||||
role="alert">
|
||||
role="alert"
|
||||
invisible="state != 'draft'">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Changes are not saved until you click
|
||||
<strong>Create & Confirm Order</strong>. Closing this
|
||||
window (Esc or X) discards your entries.
|
||||
This draft is auto-saved as you edit. You can navigate away
|
||||
(open the part form, the Process Composer, etc.) and return
|
||||
via <strong>Sales → Direct Order Drafts</strong>.
|
||||
</div>
|
||||
<div class="alert alert-warning mb-0"
|
||||
role="alert"
|
||||
@@ -20,11 +40,23 @@
|
||||
<field name="missing_info_msg" readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<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">
|
||||
<h1>New Direct Order</h1>
|
||||
<p class="text-muted">
|
||||
Skip the quotation stage - create a confirmed order
|
||||
when the customer has already sent a PO.
|
||||
<label for="name" class="o_form_label"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
<field name="user_id" readonly="state != 'draft'"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +102,8 @@
|
||||
<group string="Fulfilment & Invoicing">
|
||||
<field name="delivery_method"/>
|
||||
<field name="invoice_strategy"/>
|
||||
<field name="payment_term_id"
|
||||
options="{'no_create': True}"/>
|
||||
<label for="deposit_percent"
|
||||
invisible="invoice_strategy != 'deposit'"/>
|
||||
<div class="o_row"
|
||||
@@ -112,12 +146,20 @@
|
||||
options="{'no_create_edit': True}"/>
|
||||
<field name="description_template_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"
|
||||
optional="hide"/>
|
||||
<field name="line_description"
|
||||
string="Customer-Facing"
|
||||
optional="hide"/>
|
||||
<field name="internal_description"
|
||||
optional="hide"/>
|
||||
<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"
|
||||
string="Process"
|
||||
readonly="1"
|
||||
@@ -141,6 +183,10 @@
|
||||
<field name="unit_price"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="tax_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
optional="show"/>
|
||||
<field name="line_subtotal"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
@@ -163,6 +209,10 @@
|
||||
<field name="coating_config_id"/>
|
||||
<field name="treatment_ids"
|
||||
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"
|
||||
string="Effective Process"
|
||||
readonly="1"/>
|
||||
@@ -178,6 +228,9 @@
|
||||
<field name="unit_price"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="tax_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="line_subtotal"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
@@ -199,8 +252,9 @@
|
||||
</group>
|
||||
<group string="Line Description">
|
||||
<field name="description_template_id"
|
||||
options="{'no_create': True, 'no_open': True}"
|
||||
placeholder="Start typing to search saved descriptions..."/>
|
||||
domain="[('part_catalog_id', '=', part_catalog_id)]"
|
||||
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"
|
||||
string="Customer-Facing"/>
|
||||
<field name="line_description"
|
||||
@@ -245,29 +299,100 @@
|
||||
</notebook>
|
||||
|
||||
</sheet>
|
||||
<footer>
|
||||
<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>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</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">
|
||||
<field name="name">New Direct Order</field>
|
||||
<field name="res_model">fp.direct.order.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<!-- Use Odoo's built-in extra-large dialog size so the line
|
||||
table (10+ columns) isn't squeezed into ellipsis at the
|
||||
default modal width. Roughly 30% wider than the default. -->
|
||||
<field name="context">{'dialog_size': 'extra-large'}</field>
|
||||
<field name="target">current</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 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>
|
||||
|
||||
</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)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpInvoiceStrategyDefault(models.Model):
|
||||
@@ -33,6 +33,17 @@ class FpInvoiceStrategyDefault(models.Model):
|
||||
)
|
||||
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 = [
|
||||
('fp_invoice_strategy_partner_uniq', 'unique(partner_id)',
|
||||
'Only one invoice strategy default per customer.'),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.5.1.0',
|
||||
'version': '19.0.6.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'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',
|
||||
'views/res_config_settings_views.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/legacy_menu_hide.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.
|
||||
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'):
|
||||
return result
|
||||
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_qc_check_if_needed()
|
||||
job._fp_create_racking_inspection()
|
||||
@@ -526,36 +532,28 @@ class FpJob(models.Model):
|
||||
def _fp_create_racking_inspection(self):
|
||||
"""Auto-create a draft racking inspection on job confirm.
|
||||
|
||||
Mirrors bridge_mrp's behaviour for MO confirm. Best-effort: the
|
||||
legacy fp.racking.inspection model still requires a production_id
|
||||
(mrp.production), so we can only create one when this job is
|
||||
bound to an MO via bridge_mrp. Otherwise we skip cleanly — Phase
|
||||
9 will flip the required-FK to fp.job.
|
||||
Phase 9 — production_id is now optional on fp.racking.inspection,
|
||||
so we always create one bound by `x_fc_job_id`. When the job is
|
||||
also linked to an MO (legacy bridge_mrp coexistence), populate
|
||||
production_id too so legacy reports keep working.
|
||||
|
||||
Idempotent — if an inspection already exists for this job, skip.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.racking.inspection' not in self.env:
|
||||
return
|
||||
Inspection = self.env['fp.racking.inspection'].sudo()
|
||||
# The model still requires production_id today. If the job has
|
||||
# no MO link (which it won't in pure-native mode), skip rather
|
||||
# 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,
|
||||
)
|
||||
if 'x_fc_job_id' not in Inspection._fields:
|
||||
# Schema not yet upgraded — skip.
|
||||
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:
|
||||
vals = {'production_id': production.id}
|
||||
if 'x_fc_job_id' in Inspection._fields:
|
||||
vals['x_fc_job_id'] = self.id
|
||||
Inspection.create(vals)
|
||||
except Exception as e:
|
||||
_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.
|
||||
# Both coexist during the migration period.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
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.',
|
||||
)
|
||||
|
||||
@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_id, node_id)',
|
||||
'A job can only have one override per recipe node.',
|
||||
|
||||
@@ -13,6 +13,34 @@ from odoo.exceptions import UserError
|
||||
class FpJobStep(models.Model):
|
||||
_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):
|
||||
"""Pause an in-progress step (operator break, end of shift).
|
||||
|
||||
|
||||
@@ -2,18 +2,55 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 3 — parallel job link on fp.racking.inspection.
|
||||
# Coexists with the legacy production_id (mrp.production) link.
|
||||
# Phase 3 / Phase 9 — native-job link on fp.racking.inspection.
|
||||
# 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):
|
||||
_inherit = 'fp.racking.inspection'
|
||||
|
||||
x_fc_job_id = fields.Many2one(
|
||||
'fp.job',
|
||||
string='Plating Job',
|
||||
index=True,
|
||||
help='Native fp.job link. Coexists with the legacy production_id.',
|
||||
)
|
||||
# x_fc_job_id is declared in the base receiving module so its views
|
||||
# can reference it. We add help/depends here.
|
||||
|
||||
@api.depends('x_fc_job_id.name', 'partner_id.name')
|
||||
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.
|
||||
|
||||
import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -18,6 +21,147 @@ _logger = logging.getLogger(__name__)
|
||||
class SaleOrder(models.Model):
|
||||
_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):
|
||||
result = super().action_confirm()
|
||||
# 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':
|
||||
for so in self:
|
||||
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
|
||||
|
||||
def _fp_auto_create_job(self):
|
||||
@@ -121,3 +281,94 @@ class SaleOrder(models.Model):
|
||||
self.name, job.name, qty, (recipe.name if recipe else '-'),
|
||||
)
|
||||
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_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_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',
|
||||
'version': '19.0.5.0.0',
|
||||
'version': '19.0.6.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -20,12 +20,10 @@
|
||||
'fusion_plating_certificates',
|
||||
'fusion_plating_receiving',
|
||||
'fusion_plating_invoicing',
|
||||
'fusion_plating_bridge_mrp',
|
||||
'fusion_plating_logistics',
|
||||
'fusion_plating_reports',
|
||||
'sale_management',
|
||||
'account',
|
||||
'mrp',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
|
||||
@@ -179,58 +179,10 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 4. Manufacturing Complete (Info, #2B6CB0) -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_mo_complete" model="mail.template">
|
||||
<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>
|
||||
<!-- Phase 5 (Sub 11) — fp_mail_template_mo_complete removed.
|
||||
The native equivalent fires from fp.job.button_mark_done via
|
||||
fp.notification.template's `job_complete` trigger, defined
|
||||
in fp_notification_template_data.xml. -->
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 5. Shipped / Delivered (Success, #38a169) -->
|
||||
|
||||
@@ -10,5 +10,7 @@ from . import sale_order
|
||||
from . import fp_receiving
|
||||
from . import account_move
|
||||
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
|
||||
|
||||
@@ -12,19 +12,18 @@ class FpDelivery(models.Model):
|
||||
def action_mark_delivered(self):
|
||||
res = super().action_mark_delivered()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
Job = self.env.get('fp.job')
|
||||
for rec in self:
|
||||
if not rec.partner_id:
|
||||
continue
|
||||
so = False
|
||||
if rec.job_ref:
|
||||
# Delivery's job_ref is the MO name; find the SO via MO origin.
|
||||
mo = self.env['mrp.production'].search(
|
||||
[('name', '=', rec.job_ref)], limit=1,
|
||||
)
|
||||
if mo and mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
# Native: fp.job direct link.
|
||||
if Job is not None and 'x_fc_job_id' in rec._fields and rec.x_fc_job_id:
|
||||
so = rec.x_fc_job_id.sale_order_id or False
|
||||
elif Job is not None and rec.job_ref:
|
||||
job = Job.search([('name', '=', rec.job_ref)], limit=1)
|
||||
if job:
|
||||
so = job.sale_order_id or False
|
||||
# Sub 6 — pass the delivery address so location-scoped
|
||||
# contacts receive the 'shipped' notification.
|
||||
Dispatch._dispatch(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
from .fp_notification_template import TRIGGER_EVENTS
|
||||
|
||||
@@ -29,3 +29,20 @@ class FpNotificationLog(models.Model):
|
||||
)
|
||||
error_message = fields.Text(string='Error Message')
|
||||
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.
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality (QMS)',
|
||||
'version': '19.0.2.3.0',
|
||||
'version': '19.0.3.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||
@@ -67,6 +67,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'depends': [
|
||||
'fusion_plating',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_plating_certificates', # fp.thickness.reading link from QC
|
||||
'fusion_plating_shopfloor', # _fp_shopfloor_tokens.scss for QC tablet
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
@@ -74,6 +76,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_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_ncr_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_doc_control_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/res_partner_qc_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_contract_review_views.xml',
|
||||
'views/fp_part_catalog_views.xml',
|
||||
'views/fp_quality_check_views.xml',
|
||||
'reports/fp_contract_review_report.xml',
|
||||
'reports/fp_contract_review_template.xml',
|
||||
'views/fp_menu.xml',
|
||||
@@ -97,6 +103,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'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,
|
||||
|
||||
@@ -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_partner
|
||||
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 -----
|
||||
# NOTE: workorder_id, production_id, and portal_job_id live in
|
||||
# fusion_plating_bridge_mrp (which depends on mrp and
|
||||
# fusion_plating_portal). Keeping them here would force hard
|
||||
# dependencies and break minimal CE-only installs.
|
||||
# Phase 1 (Sub 11) — native plating-job link replaces the legacy
|
||||
# workorder_id / production_id pair that lived in bridge_mrp.
|
||||
# The bridge fields stay during the migration window so existing
|
||||
# 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')
|
||||
|
||||
# ----- 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 '
|
||||
'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_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_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