This commit is contained in:
gsinghpal
2026-04-27 00:11:18 -04:00
parent d9f58b9851
commit f08f328688
116 changed files with 9891 additions and 359 deletions

View File

@@ -100,6 +100,87 @@ These modules have **source code in this repo** but are **intentionally NOT inst
- SCSS class prefix: `o_fp_*` (shopfloor: `o_fp_po_*`, `o_fp_pt_*`; recipes: `o_fp_recipe_*`)
- Monetary fields: always pair with `currency_id` field on the same model
## Smart Buttons — Anatomy + Conventions
Smart buttons sit in the `<div class="oe_button_box" name="button_box">` at the top of a form view. Every smart button MUST follow this canonical pattern so the row stays visually consistent — icon on top, count in the middle, label on the bottom.
### Canonical button shape
```xml
<button name="action_view_holds" <!-- method on the underlying model -->
type="object"
class="oe_stat_button" <!-- mandatory — drives the box styling -->
icon="fa-hand-paper-o" <!-- Font Awesome 4.x class, always fa-* -->
invisible="x_fc_hold_count == 0"> <!-- optional; see "Conditional visibility" -->
<field name="x_fc_hold_count" widget="statinfo" string="Holds"/>
</button>
```
What each piece does:
- `name=` — the Python method called on click (an `action_view_X` returning a window action dict).
- `class="oe_stat_button"` — REQUIRED. Without it the button doesn't get the stat-box styling and renders as a plain action button.
- `icon=` — Font Awesome 4 (`fa-cogs`, `fa-truck`, `fa-list-alt`, `fa-th-large`, etc.). Pick one that telegraphs the target model.
- `<field widget="statinfo">` — REQUIRED for the count-on-top label-below format. Don't use `string="Foo"` on the `<button>` itself when you want a count — that produces a label-only button (the empty `BOM Items` issue we fixed in v19.0.17.6.0).
### Don'ts (every one of these is a real bug we shipped + reverted)
- **Don't use `string="Label"` on `<button>` if the button has a meaningful count** — you get a plain `Label` button with no number. Use the `<field widget="statinfo">` form instead.
- **Don't anchor smart-button xpath to a model that may not exist** (e.g. `//button[@name='action_view_mrp_production']``mrp.production` is gone post-Sub 11). Anchor to a stable button this same view adds (e.g. `action_view_pickings`) or to `//div[hasclass('oe_button_box')]` directly.
- **Don't add a smart button that always shows zero** because the underlying field/model is gone (the dead `Work Orders` button we removed in 19.0.17.4.0). If the count is structurally zero, drop the button entirely.
- **Don't compute counts via `env.get('model')`** — `Environment` in Odoo 19 has no `get`. Use `'model.name' in self.env` then `self.env['model.name']` (see Critical Rules — Odoo 19).
- **Don't put the same data behind two different buttons.** "Plating Jobs" and "Work Orders" were both fp.job lookups — we kept Plating Jobs and dropped Work Orders.
### Conditional visibility
If a button is only meaningful for some SOs (e.g. `BOM Items` is noise on a single-part SO; `By Job Group` is noise on an SO with no group tags), HIDE it conditionally rather than letting it render as `0 Foo`:
```xml
invisible="x_fc_distinct_part_count < 2" <!-- BOM Items: 2+ parts -->
invisible="not x_fc_has_wo_group_tag" <!-- By Job Group: at least one tag -->
invisible="x_fc_ncr_count == 0" <!-- NCRs: only when there are open ones -->
```
Add the supporting boolean / count as a stored or non-stored compute on the model. Group multiple visibility helpers in ONE compute method to keep the `_compute_smart_button_visibility` chain cheap (one pass over `order_line`).
### Ordering / placement
- **Always-visible meaningful buttons go first** — they're the workflow signals an operator scans for first (Receiving, Plating Jobs, Holds, Checks).
- **NCRs / RMAs sit in the middle** — visible only when present (so they pop only when there's actual quality work).
- **Conditional / multi-lens analytical buttons go LAST** (BOM Items, By Job Group). They overflow into the `More ▾` dropdown when the row is full, which is fine — they're the "I'm zooming into a complex SO" tools, not the daily-driver buttons.
To add a button at the end of the row regardless of where the inherited view positions things, use a second xpath:
```xml
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button .../>
</xpath>
```
`position="inside"` appends to the end of the button box.
### Action method shape
```python
def action_view_holds(self):
self.ensure_one()
return {
'name': _('Holds'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form', # always 'list,form' or 'kanban,list,form'
'domain': [('job_id', '=', self.id)], # filter to this record's data
'context': {'default_job_id': self.id}, # so the Create button pre-fills
}
```
Always include a `context` with `default_*` keys for the Create button on the empty-list state — otherwise the operator hits Create on an empty list and gets a blank form with no link back to the source record.
### Smart-button row checklist before merge
- [ ] Uses `class="oe_stat_button"` and `widget="statinfo"` if it shows a count
- [ ] Has an `icon=` (FA 4 class)
- [ ] Has an `invisible=` clause if the count is structurally zero in some scenarios
- [ ] Action method returns a window action with `view_mode`, `domain`, and `context.default_*`
- [ ] Conditional/analytical buttons are pushed to the end of the button box via a second `position="inside"` xpath
- [ ] No two buttons surface the same underlying records (no MRP/native duplicates)
## Process Recipe System (NEW — v19.0.2.x)
**Model**: `fusion.plating.process.node` (in `fusion_plating` core)
- Hierarchical tree with `_parent_store = True`
@@ -711,3 +792,88 @@ 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;
```
---
## Battle Tests — Real-World Operator Scenario Coverage
Persona-driven shop-floor scenarios that surfaced bugs / workflow holes. Every scenario has:
- A test script in `fusion_plating_quality/scripts/bt_s*.py` you can re-run end-to-end on entech (or any DB)
- A fix shipped at a specific module version
- A description of how a real operator would trip the gap and what the system now does
### How to re-run any scenario
```bash
# From a fresh shell, point at the entech DB:
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"exec(open(\\\"/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_sN_NAME.py\\\").read())\" | su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'"
```
Each script is self-contained — builds a fresh SO + job, walks the scenario, asserts the fix is in effect.
### Scenario index
| ID | Persona / Scenario | Gap before | Fix shipped | Module version | Test script |
|----|---|---|---|---|---|
| **S1** | Carlos forgot to click Start; realizes 2h later | `date_started` readonly + no way to back-date | `action_recompute_duration_from_timelogs` on `fp.job.step` re-sums after timelog edits | `fusion_plating_jobs 19.0.6.10.0` | `bt_s2_*` (covered with S2) |
| **S2** | Carlos finished step physically; forgot Finish; went home (12h ghost) | Same as S1 | Same fix — supervisor edits the timelog row, clicks Recompute Duration | `fusion_plating_jobs 19.0.6.10.0` | `battle_test_v2.py` Fix 4 |
| **S3** | Two operators tap Start on same step | ✓ already blocked correctly | n/a | — | `battle_test.py` |
| **S4** | Out-of-order step finish (intentional for parallel tanks) | Allowed by design (parallel work). Use S14 for opt-in serial | n/a | — | `battle_test.py` |
| **S5** | Manager takes over a stuck step (operator on vacation) | ✓ reassign + finish work — added audit in S9 | See S9 | — | `battle_test.py` |
| **S6** | Bake window expired; operator wants to start anyway | Silently allowed → no audit | `action_start_bake` blocks `missed_window`; manager-only `action_force_start_missed` overrides + posts chatter audit | `fusion_plating_shopfloor 19.0.24.1.0` | `battle_test_v2.py` Fix 1 |
| **S7** | Step ran 12× expected duration | Silent | Chatter warning posted on the job at 1.5×+ overrun | `fusion_plating_jobs 19.0.6.10.0` | `battle_test_v2.py` Fix 2 |
| **S8** | Job closed with `qty_done=0` despite `qty=5` | Silent — invoiced for parts that may not exist | `button_mark_done` blocks until `qty_done + qty_scrapped == qty`. Manager bypass `fp_skip_qty_reconcile=True` | `fusion_plating_jobs 19.0.6.10.0` | `battle_test_v2.py` Fix 3 |
| **S9** | Bob takes over Carlos's in_progress step | Silent reassignment (only step's own chatter logged) | `write()` override on `fp.job.step` posts to JOB chatter when `assigned_user_id` changes on active state | `fusion_plating_jobs 19.0.6.11.0` | `bt_s9_reassign.py` |
| **S10** | Operator paused for lunch, never resumed → 14 stale-paused steps in prod | No alert / cron / activity | Daily cron `_cron_nudge_stale_paused` (24h threshold) — schedules `mail.activity` on parent job for the manager. Idempotent | `fusion_plating_jobs 19.0.6.12.0` | `bt_s10_stale_paused.py` |
| **S11** | Rectifier dies mid-plating → operator has no abort+retry path | Only options: cancel (kills step) or pause+writetank+start (no audit) | New `action_abort_for_retry(reason, new_tank_id)` — closes timelog, swaps tank, posts chatter, resets to `ready` | `fusion_plating_jobs 19.0.6.13.0` | `bt_s11_verify.py` |
| **S12** | Sarah edits SO line qty 5→8 mid-job | Silent — Carlos plates 5, invoice ships 8 | `sale.order.line.write` posts warning to job chatter; new `action_sync_qty_from_so` button on job for explicit propagation | `fusion_plating_jobs 19.0.6.14.0` | `bt_s12_verify.py` |
| **S13** | Recipe author wrote detailed step instructions; operator never sees them on tablet | Tablet payload omitted `instructions`/`thickness_target`/`dwell_time_minutes`/`bake_setpoint_temp`/`requires_signoff` | All 5 fields added to `/fp/shopfloor/scan` response AND `_step_payload` for tablet_overview | `fusion_plating_shopfloor 19.0.24.2.0` | `bt_s13_verify.py` |
| **S14** | No way to enforce serial-required steps (e.g. acid etch → plating) | Out-of-order start always allowed | New `requires_predecessor_done` Boolean on `fusion.plating.process.node` → related on `fp.job.step` → `button_start` blocks if any earlier-sequence step isn't done/skipped/cancelled. Manager bypass `fp_skip_predecessor_check=True` | `fusion_plating 19.0.9.2.0`, `fusion_plating_jobs 19.0.6.15.0` | `bt_s14_verify.py` |
| **S15** | Job marked done but bake.window still `awaiting_bake` | **Compliance bomb** — parts ship without bake record | `button_mark_done` blocks if any linked `fusion.plating.bake.window` is `awaiting_bake` or `bake_in_progress`. Manager bypass `fp_skip_bake_gate=True` for documented customer deviation | `fusion_plating_jobs 19.0.6.16.0` | `bt_s15_bake_close.py` |
| **S16** | 45 phantom in_progress steps in DB (operator clocked Start, never moved) | No alert / cron / activity | Hourly cron `_cron_nudge_stale_in_progress` (8h threshold) — sister to S10 cron | `fusion_plating_jobs 19.0.6.17.0` | `bt_s16_phantom_inprogress.py` |
| **S17** | Operator drops parts, bumps `qty_scrapped` 0→2 | Silent — no AS9100 disposition record | `fp.job.write` hook auto-spawns `fusion.plating.quality.hold` for the scrap delta. Operator updates description with cause | `fusion_plating_jobs 19.0.6.18.0` | `bt_s17_scrap_ncr.py` |
| **S18** | CoC issuance broken in 4 places — operator can't actually email a cert | (a) auto-spawn left every useful field blank → Issue blocked on missing spec_reference; (b) Issue button never generated PDF → `attachment_id` stayed empty; (c) Send to Customer opened email composer with no attachment; (d) auto-spawn had no idempotency → dupes on `button_mark_done` retry | `_fp_create_certificates` now pre-fills `spec_reference` (from coating), `part_number`, `quantity_shipped` (qty scrap), `po_number`, `customer_job_no`, `process_description`, `entech_wo_number`, `sale_order_id`. Idempotency check skips dupes. `action_issue` now renders the EN CoC PDF via new `_fp_render_and_attach_pdf` and sets `attachment_id` so Send to Customer attaches it automatically. Smart button "Certificates" already on the job form (visible when count > 0) so Tom finds the cert from the job he just closed | `fusion_plating_certificates 19.0.5.1.0`, `fusion_plating_jobs 19.0.6.19.0` | `bt_s18_cert_flow.py` |
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
### Manager-bypass context flags
When you need to override a guard (documented customer deviation, emergency rework, etc.), set the context key on the call. All bypasses post to chatter with the user name for audit:
| Flag | Skips |
|------|-------|
| `fp_skip_step_gate=True` | step-completion check on `button_mark_done` (S5/S8 era) |
| `fp_skip_qc_gate=True` | QC checklist requirement on `button_mark_done` |
| `fp_skip_qty_reconcile=True` | qty_done + qty_scrapped == qty check on `button_mark_done` |
| `fp_skip_bake_gate=True` | bake.window pending check on `button_mark_done` (S15) |
| `fp_skip_predecessor_check=True` | requires_predecessor_done check on `button_start` (S14) |
| `fp_skip_missed_window=True` | missed_window block on `bake.window.action_start_bake` (S6) |
### Daily / hourly crons added by battle tests
| Cron | Schedule | What it does |
|------|----------|--------------|
| `Fusion Plating: Nudge stale paused steps` | daily | 24h threshold, schedules activity on job for stale `paused` steps |
| `Fusion Plating: Nudge stale in-progress steps` | hourly | 8h threshold, sister cron for `in_progress` (phantom-time guard) |
| `Fusion Plating: Update Bake Window states` | every 5 min | (pre-existing) flips awaiting_bake → missed_window past required_by |
### Open scenarios — flagged for next session
- **S20** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
- **S21** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
- **S22** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
- **S23** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
- **S24** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
- **S25** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
- **S26** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
### Where the test scripts live
`K:/Github/Odoo-Modules/fusion_plating/fusion_plating_quality/scripts/`
- `battle_test.py` — original S1S8 (mixed, some not-bug scenarios)
- `battle_test_v2.py` — re-verify of S6/S7/S8/S2 fixes
- `bt_s9_reassign.py` through `bt_s17_scrap_ncr.py` — one script per scenario
- `bt_s18_cert_flow.py` — full CSR→operator→QC→shipper cert issuance + Send to Customer
- `bt_s19_fischer_merge.py` — uploads fake Fischerscope PDF to QC, asserts CoC + thickness merged into 2-page output
- `step_internal_full.py` — full pause/resume/skip/bake-spawn walk
To re-test the whole battle suite after a future change, run each `bt_s*.py` in sequence and confirm green.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.9.0.0',
'version': '19.0.9.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -248,6 +248,9 @@ class FpJob(models.Model):
"Job %s is in state '%s' - only draft jobs can be confirmed."
) % (job.name, job.state))
job.state = 'confirmed'
# Step auto-promote happens in the fusion_plating_jobs override
# AFTER _generate_steps_from_recipe runs — at this point step_ids
# is empty for any newly-confirmed job.
return True
def action_cancel(self):

View File

@@ -132,6 +132,12 @@ class FpJobStep(models.Model):
related='recipe_node_id.customer_visible',
store=True,
)
requires_predecessor_done = fields.Boolean(
related='recipe_node_id.requires_predecessor_done',
store=True,
help='If True, button_start blocks until every earlier-sequence '
'step in this job is done/skipped/cancelled.',
)
# ------------------------------------------------------------------
# Cost rollup (Task 1.6)

View File

@@ -170,6 +170,16 @@ class FpProcessNode(models.Model):
default=False,
help='Quality hold point — requires operator sign-off.',
)
requires_predecessor_done = fields.Boolean(
string='Requires Predecessor Done',
default=False,
help='If checked, this step cannot start until ALL earlier-'
'sequence steps in the job are done / skipped / cancelled. '
'Use for serial-required operations (e.g. Plating must '
'follow Acid Etch with no time gap — passivation layer '
'forms in seconds). Leaving unchecked allows parallel '
'work across tanks (the default).',
)
opt_in_out = fields.Selection(
[
('disabled', 'Required'),

View File

@@ -51,10 +51,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
# the Manager Desk's "show only clocked-in workers" filter
# working out of the box.
'hr_attendance',
'mrp',
'mrp_workorder',
'mrp_account',
'sale_mrp',
# mrp / mrp_workorder / mrp_account / sale_mrp deps dropped post-
# Sub 11. This module is itself uninstalled; the manifest is kept
# on disk for archaeology only. Listing those deps here would let
# Odoo silently re-pull them on any addons rescan.
'account',
],
'data': [

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.5.0.0',
'version': '19.0.5.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -3,9 +3,13 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpCertificate(models.Model):
"""Unified certificate registry.
@@ -307,8 +311,170 @@ class FpCertificate(models.Model):
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
})
rec.state = 'issued'
# Generate the CoC PDF and attach it so action_send_to_customer
# has something to email. Without this the workflow goes:
# Issue → Send → opens composer with no attachment → operator
# closes confused. Best-effort: if the report renders, attach;
# if it fails, log + continue (cert is still issued).
try:
rec._fp_render_and_attach_pdf()
except Exception as e:
_logger.warning(
'Cert %s: PDF render failed: %s', rec.name, e,
)
rec.message_post(body=_('Certificate issued.'))
def _fp_render_and_attach_pdf(self):
"""Render the CoC PDF via the bound report action, OPTIONALLY
merge the Fischerscope thickness report PDF (uploaded by the
QC tablet operator) as page 2, and attach the result.
Without the merge, a customer who specs "CoC must include the
XRF report" gets two separate PDFs to chase down. AS9100 wants
the supporting evidence inline with the cert.
Tries the EN-language CoC report first, falls back to the
generic action_report_coc. Idempotent — skips if attachment_id
is already set. PDF merge is best-effort: corrupt Fischerscope
upload or missing pypdf falls back to CoC-only with a warning.
"""
import base64
import io
self.ensure_one()
if self.attachment_id:
return self.attachment_id
report = (
self.env.ref(
'fusion_plating_reports.action_report_coc_en',
raise_if_not_found=False,
)
or self.env.ref(
'fusion_plating_reports.action_report_coc',
raise_if_not_found=False,
)
)
if not report:
_logger.warning(
'Cert %s: no CoC report action found, cannot render PDF',
self.name,
)
return False
coc_pdf_bytes, _content_type = report._render_qweb_pdf(
report.report_name, res_ids=self.ids,
)
# Try to append the Fischerscope thickness-report PDF as page 2.
merged_bytes = self._fp_merge_thickness_into_pdf(coc_pdf_bytes)
final_pdf = merged_bytes or coc_pdf_bytes
att = self.env['ir.attachment'].sudo().create({
'name': '%s.pdf' % (self.name or 'certificate'),
'type': 'binary',
'datas': base64.b64encode(final_pdf),
'mimetype': 'application/pdf',
'res_model': self._name,
'res_id': self.id,
})
self.attachment_id = att.id
return att
def _fp_merge_thickness_into_pdf(self, coc_pdf_bytes):
"""Look up the linked QC check, find its thickness_report_pdf_id
(Fischerscope / XDAL 600 XRF export), and return a merged PDF
with the CoC first + Fischerscope appended as page 2+.
Returns None when:
- cert isn't a CoC, or
- no fp.job linked, or
- no fp.quality.check on the job has a PDF uploaded, or
- pypdf / PyPDF2 not installed, or
- either PDF fails to parse.
Caller falls back to CoC-only when None is returned.
"""
import io
import base64 as _b64
self.ensure_one()
if self.certificate_type != 'coc':
return None
# Find the linked job. fp.certificate has either x_fc_job_id
# (preferred — added by fusion_plating_jobs) or job_id (older).
job = False
if 'x_fc_job_id' in self._fields:
job = self.x_fc_job_id
if not job and 'job_id' in self._fields:
job = self.job_id
if not job:
return None
# Find a passed QC on this job with an uploaded Fischerscope PDF.
# Prefer state=passed; fall through to any with a PDF.
QC = self.env.get('fusion.plating.quality.check')
if QC is None:
return None
qc = QC.sudo().search([
('job_id', '=', job.id),
('state', '=', 'passed'),
('thickness_report_pdf_id', '!=', False),
], order='completed_at desc', limit=1)
if not qc:
qc = QC.sudo().search([
('job_id', '=', job.id),
('thickness_report_pdf_id', '!=', False),
], order='create_date desc', limit=1)
if not qc or not qc.thickness_report_pdf_id:
return None
fischer_bytes = _b64.b64decode(
qc.thickness_report_pdf_id.datas or b''
)
if not fischer_bytes:
return None
# Merge — pypdf is the modern name; PyPDF2 still works on older
# Odoo bundles. Either is fine.
try:
from pypdf import PdfWriter
writer_cls = PdfWriter
use_append = True
except ImportError:
try:
from PyPDF2 import PdfMerger
writer_cls = PdfMerger
use_append = False
except ImportError:
_logger.warning(
'Cert %s: neither pypdf nor PyPDF2 installed, '
'cannot append Fischerscope PDF to CoC.',
self.name,
)
return None
try:
if use_append:
# pypdf 3.x — PdfWriter.append() handles bytes/streams
writer = writer_cls()
writer.append(io.BytesIO(coc_pdf_bytes))
writer.append(io.BytesIO(fischer_bytes))
out = io.BytesIO()
writer.write(out)
merged = out.getvalue()
else:
# PyPDF2 — PdfMerger.append + write
merger = writer_cls()
merger.append(io.BytesIO(coc_pdf_bytes))
merger.append(io.BytesIO(fischer_bytes))
out = io.BytesIO()
merger.write(out)
merger.close()
merged = out.getvalue()
except Exception:
_logger.exception(
'PDF merge failed for cert %s — Fischerscope PDF may '
'be corrupt / encrypted / malformed. Falling back to '
'CoC-only.', self.name,
)
return None
self.message_post(body=_(
'Fischerscope thickness report from QC %s appended to CoC PDF.'
) % qc.name)
return merged
def action_void(self):
for rec in self:
if rec.state != 'issued':

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.17.0.0',
'version': '19.0.17.13.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -167,23 +167,37 @@ class FpPartCatalog(models.Model):
'Compose button to edit. When an order does not pick a '
'specific variant, this one is used.',
)
process_variant_ids = fields.One2many(
# Computed instead of plain One2many because the One2many `domain=`
# was silently NOT being applied — `part.process_variant_ids` was
# returning every node (root + children) for the part instead of
# only the root recipe variants. Computing explicitly via search
# is bulletproof and survives the Odoo 19 ORM rewrites. The store
# is False because the underlying recipe-tree topology can change
# outside this model (composer, drag/drop in editor, etc.) and we
# want fresh reads.
process_variant_ids = fields.Many2many(
'fusion.plating.process.node',
'part_catalog_id',
compute='_compute_process_variant_ids',
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).',
help='Root 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',
compute='_compute_process_variant_ids',
)
@api.depends('process_variant_ids')
def _compute_process_variant_count(self):
@api.depends_context('uid')
def _compute_process_variant_ids(self):
Node = self.env['fusion.plating.process.node']
for rec in self:
rec.process_variant_count = len(rec.process_variant_ids)
variants = Node.search([
('part_catalog_id', '=', rec.id),
('parent_id', '=', False),
('node_type', '=', 'recipe'),
])
rec.process_variant_ids = variants
rec.process_variant_count = len(variants)
# ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one(
@@ -360,21 +374,25 @@ class FpPartCatalog(models.Model):
[('part_catalog_id', '=', part.id)])
def _compute_workorder_count(self):
SaleOrder = self.env['sale.order']
Production = self.env['mrp.production']
MrpWO = self.env.get('mrp.workorder')
# Sub 11 — MRP gone; count fp.job.step rows scoped to this part's SOs.
for part in self:
if MrpWO is None:
part.workorder_count = 0
continue
if 'fp.job' not in self.env or 'fp.job.step' not in self.env:
return
SaleOrder = self.env['sale.order']
Job = self.env['fp.job'].sudo()
Step = self.env['fp.job.step'].sudo()
for part in self:
so_names = SaleOrder.search(
[('x_fc_part_catalog_id', '=', part.id)]
).mapped('name')
if not so_names:
part.workorder_count = 0
continue
mos = Production.search([('origin', 'in', so_names)])
part.workorder_count = sum(len(m.workorder_ids) for m in mos)
jobs = Job.search([('origin', 'in', so_names)])
if not jobs:
continue
part.workorder_count = Step.search_count(
[('job_id', 'in', jobs.ids)])
def _compute_revision_count(self):
for part in self:
@@ -460,18 +478,20 @@ class FpPartCatalog(models.Model):
}
def action_view_workorders(self):
# Sub 11 — MRP gone; navigate to fp.job.step rows scoped to this part.
self.ensure_one()
so_names = self.env['sale.order'].search(
[('x_fc_part_catalog_id', '=', self.id)]
).mapped('name')
mos = self.env['mrp.production'].search([('origin', 'in', so_names)])
wo_ids = mos.mapped('workorder_ids').ids
if 'fp.job' not in self.env or 'fp.job.step' not in self.env:
return False
jobs = self.env['fp.job'].sudo().search([('origin', 'in', so_names)])
return {
'type': 'ir.actions.act_window',
'name': _('Work Orders — %s') % (self.part_number or self.name),
'res_model': 'mrp.workorder',
'domain': [('id', 'in', wo_ids)],
'view_mode': 'list,form,kanban',
'res_model': 'fp.job.step',
'domain': [('job_id', 'in', jobs.ids)],
'view_mode': 'list,form',
}
def action_view_revisions(self):

View File

@@ -605,6 +605,15 @@ class FpQuoteConfigurator(models.Model):
'name': '%s%s (x%d)' % (coating_name, part_name, self.quantity),
'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price,
# Sub 11 fix — propagate part + coating to the LINE too.
# fusion_plating_jobs._fp_auto_create_job filters lines
# by x_fc_part_catalog_id; without it, no fp.job spawns.
'x_fc_part_catalog_id': (
self.part_catalog_id.id if self.part_catalog_id else False
),
'x_fc_coating_config_id': (
self.coating_config_id.id if self.coating_config_id else False
),
})],
}
so = self.env['sale.order'].create(so_vals)

View File

@@ -146,6 +146,37 @@ class SaleOrder(models.Model):
# top of this stub during its own load pass.
x_fc_workorder_count = fields.Integer(string='Work Orders')
# Smart-button visibility helpers (post-Sub 11). The BOM Items kanban
# is only useful when the SO carries 2+ distinct parts; the By Job
# Group kanban is only useful when at least one line is tagged with
# x_fc_wo_group_tag. Default-hidden otherwise so the smart-button
# row stays clean for the typical single-part SO.
x_fc_distinct_part_count = fields.Integer(
string='# Distinct Parts',
compute='_compute_smart_button_visibility',
)
x_fc_has_wo_group_tag = fields.Boolean(
string='Has Job Group Tag',
compute='_compute_smart_button_visibility',
)
x_fc_wo_group_count = fields.Integer(
string='# Job Groups',
compute='_compute_smart_button_visibility',
help='Distinct x_fc_wo_group_tag values across this SO\'s lines.',
)
@api.depends('order_line.x_fc_part_catalog_id',
'order_line.x_fc_wo_group_tag')
def _compute_smart_button_visibility(self):
for rec in self:
parts = rec.order_line.mapped('x_fc_part_catalog_id')
rec.x_fc_distinct_part_count = len(parts)
tags = {
t for t in rec.order_line.mapped('x_fc_wo_group_tag') if t
}
rec.x_fc_has_wo_group_tag = bool(tags)
rec.x_fc_wo_group_count = len(tags)
# 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(
@@ -192,42 +223,45 @@ class SaleOrder(models.Model):
@api.depends('name')
def _compute_wo_completion(self):
"""Batched: one grouped query across all records in self."""
"""Batched: one grouped query across all records in self.
Sub 11 — MRP is gone; we count fp.job.step completion instead of
mrp.workorder. The selection is the same shape: completed steps
out of total steps across every fp.job for this SO.
"""
for rec in self:
rec.x_fc_wo_completion = '0/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)],
['production_id.origin', 'state'],
['production_id', 'state'],
lazy=False,
if 'fp.job.step' not in self.env or 'fp.job' not in self.env:
return
Job = self.env['fp.job'].sudo()
Step = self.env['fp.job.step'].sudo()
jobs = Job.search([('origin', 'in', names)])
if not jobs:
return
job_to_origin = {j.id: j.origin for j in jobs}
# Odoo 19 — use _read_group with aggregates=['__count'].
rows = Step._read_group(
domain=[('job_id', 'in', jobs.ids)],
groupby=['job_id', 'state'],
aggregates=['__count'],
)
# Build {origin: {'done': n, 'total': n}}
# read_group returns production_id as (id, name) tuples; we need
# to translate back to origin. Do a small lookup.
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {} # {origin: [total, done]}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
for job_rec, state_val, count in rows:
origin = job_to_origin.get(job_rec.id)
if not origin:
continue
cnt = r['__count']
bucket = totals.setdefault(origin, [0, 0])
bucket[0] += cnt
if r['state'] == 'done':
bucket[1] += cnt
bucket[0] += count
if state_val == 'done':
bucket[1] += count
for rec in self:
if not rec.name:
continue
tot, done = totals.get(rec.name, [0, 0])
rec.x_fc_wo_completion = '%d/%d' % (done, tot) if tot else '0/0'
rec.x_fc_wo_completion = f'{done}/{tot}' if tot else '0/0'
# ---- Phase F: quotes list view polish ----
x_fc_follow_up_date = fields.Date(

View File

@@ -56,16 +56,16 @@
</button>
</xpath>
<!-- After standard Manufacturing: Active WOs, NCRs, Files, BOM Items, By WO.
BOM Items and By WO are last so Odoo's button box overflows them into More. -->
<xpath expr="//button[@name='action_view_mrp_production']" position="after">
<button name="action_view_workorders"
type="object"
class="oe_stat_button"
icon="fa-cogs">
<field name="x_fc_workorder_count" widget="statinfo"
string="Work Orders"/>
</button>
<!-- Sub 11 — MRP gone. The "Work Orders" button used to count
mrp.workorder; removed because Plating Jobs (added by
fusion_plating_jobs) now counts the canonical fp.job.step
rows. NCRs surfaces only when there's at least one open;
BOM Items and By Job Group only when the SO is actually
multi-part / tagged (otherwise both render one column with
one card — pure noise). Anchored after Transfers; the two
conditional ones go last so the typical clean SO shows
just the meaningful buttons up front. -->
<xpath expr="//button[@name='action_view_pickings']" position="after">
<button name="action_view_ncrs"
type="object"
class="oe_stat_button"
@@ -74,16 +74,29 @@
<field name="x_fc_ncr_count" widget="statinfo"
string="NCRs"/>
</button>
</xpath>
<!-- Push BOM Items / By Job Group to the end of the button
box (after the Plating Jobs / Holds row added by jobs +
quality). They sit hidden by default and only surface
when the SO actually has multi-part lines or job-group
tags. -->
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_bom_items"
type="object"
class="oe_stat_button"
icon="fa-list-alt"
invisible="x_fc_distinct_part_count &lt; 2">
<field name="x_fc_distinct_part_count" widget="statinfo"
string="BOM Items"/>
</button>
<button name="action_view_wo_perspective"
type="object"
class="oe_stat_button"
icon="fa-th-large"
string="By WO"/>
invisible="not x_fc_has_wo_group_tag">
<field name="x_fc_wo_group_count" widget="statinfo"
string="Job Groups"/>
</button>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">

View File

@@ -116,11 +116,61 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self):
"""Clear variant pick when the part changes (variants are part-scoped)."""
"""Clear variant pick when the part changes (variants are part-scoped).
Pre-fill coating + treatments from the part's saved defaults so
Sarah doesn't re-pick the same coating every repeat customer.
Defaults only apply when the line currently has no coating set
— editing an existing line with a chosen coating doesn't get
clobbered.
For BRAND-NEW parts (no defaults saved yet) auto-tick
`push_to_defaults` so Sarah's first coating pick gets persisted
back to the part. Without this Sarah has to remember to tick the
toggle herself, and the second order doesn't pre-fill.
Returns a warning popup explaining what's happening.
"""
warning = None
for rec in self:
# Variant clear (original behaviour).
if (rec.process_variant_id
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
rec.process_variant_id = False
if not rec.part_catalog_id:
continue
part = rec.part_catalog_id
has_default_coating = bool(getattr(
part, 'x_fc_default_coating_config_id', False))
has_default_treatments = bool(getattr(
part, 'x_fc_default_treatment_ids', False))
# Pre-fill default coating if the line is empty.
if not rec.coating_config_id and has_default_coating:
rec.coating_config_id = part.x_fc_default_coating_config_id
# Pre-fill default treatments if any are configured.
if not rec.treatment_ids and has_default_treatments:
rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
# New-part auto-suggest: if neither default exists, this is
# likely a first-time use of the part. Auto-tick the
# push_to_defaults toggle so whatever Sarah picks becomes
# the saved default — surface a warning popup so she knows.
# `is_one_off` always wins (operator opted out of catalog
# persistence), so don't auto-tick in that case.
if (not has_default_coating
and not has_default_treatments
and not rec.is_one_off
and not rec.push_to_defaults):
rec.push_to_defaults = True
warning = {
'title': _('First-Time Part — Defaults Will Be Saved'),
'message': _(
'%(part)s has no saved coating / treatments. '
'The coating + treatments you pick on this line '
'will be saved as the part\'s defaults so the '
'next order auto-fills them. Untick "Save as '
'Default" on the line if you don\'t want this.'
) % {'part': part.display_name or part.part_number or '(part)'},
}
return {'warning': warning} if warning else None
# ---- Qty / price ----
quantity = fields.Integer(string='Qty', default=1, required=True)

View File

@@ -209,37 +209,63 @@ class FpDirectOrderWizard(models.Model):
# ---- Onchange ----
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Seed invoice defaults + addresses + payment terms when customer changes."""
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
"""Seed invoice defaults + addresses + payment terms when customer
changes. Also surface an account-hold warning so Sarah doesn't
build a full quote for a customer she can't ship to.
"""
if not self.partner_id:
self.partner_invoice_id = False
self.partner_shipping_id = False
self.payment_term_id = False
self._apply_strategy_payment_term()
return
# Legacy partner-field defaults (pre-Sub-5).
if '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
if self.partner_id:
# Addresses.
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
# Per-customer invoice strategy default (fp.invoice.strategy.default).
# Pull strategy + deposit even when payment_term_id is empty — the
# previous condition `if isd and isd.payment_term_id` silently
# skipped the strategy fill for net-terms customers without
# explicit terms configured.
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.
term = False
if isd:
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
term = isd.payment_term_id
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()
# Account-hold early warning. Hard block lives in action_confirm
# but Sarah deserves to know NOW before she builds 5 lines.
if getattr(self.partner_id, 'x_fc_account_hold', False):
return {
'warning': {
'title': _('Customer on Account Hold'),
'message': _(
'%s is currently on account hold. You can still '
'build the quotation, but it cannot be confirmed '
'until the hold is cleared by accounting.'
) % self.partner_id.display_name,
}
}
@api.onchange('invoice_strategy')
def _onchange_invoice_strategy(self):
"""Map the strategy onto sensible payment terms."""
@@ -247,12 +273,15 @@ class FpDirectOrderWizard(models.Model):
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.
- cod_prepay → Immediate Payment
- net_terms / deposit / progress → fall back to a 30-day
term when nothing is set. Without ANY payment term Odoo
blocks invoice posting, which silently strands SOs at the
invoicing step. Better to default to net-30 and let the
estimator override if the customer's terms are different.
Never overwrites an explicit user choice — only fills the gap.
"""
Pt = self.env['account.payment.term']
for rec in self:
if rec.invoice_strategy == 'cod_prepay':
immediate = rec.env.ref(
@@ -261,6 +290,20 @@ class FpDirectOrderWizard(models.Model):
)
if immediate:
rec.payment_term_id = immediate.id
elif rec.invoice_strategy in ('net_terms', 'deposit', 'progress') \
and not rec.payment_term_id:
# Try canonical Net-30, then any term named "30 Days",
# then any term at all as last-ditch.
term = rec.env.ref(
'account.account_payment_term_30days',
raise_if_not_found=False,
)
if not term:
term = Pt.search([('name', 'ilike', '30 Days')], limit=1)
if not term:
term = Pt.search([], limit=1)
if term:
rec.payment_term_id = term.id
# ---- Actions ----
@api.model
@@ -351,6 +394,17 @@ class FpDirectOrderWizard(models.Model):
raise UserError(_('Pick a customer before confirming.'))
if not self.line_ids:
raise UserError(_('Add at least one part line before confirming.'))
# Account-hold hard block — same policy as sale.order.action_confirm
# but enforced earlier so the wizard doesn't waste Sarah's time.
# Manager override allowed via context key fp_skip_account_hold=True.
if (getattr(self.partner_id, 'x_fc_account_hold', False)
and not self.env.context.get('fp_skip_account_hold')
and not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager')):
raise UserError(_(
'Customer %s is on account hold. Have a manager clear the '
'hold (or override) before creating the order.'
) % self.partner_id.display_name)
# Accept EITHER a PO (document + number) OR the PO Pending
# flag. Customers who haven't sent paperwork yet use Pending;

View File

@@ -141,9 +141,9 @@
<field name="is_missing_info" column_invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="part_catalog_id"
context="{'default_partner_id': parent.partner_id}"
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
options="{'no_create_edit': True}"/>
options="{'no_quick_create': True}"/>
<field name="description_template_id"
domain="[('part_catalog_id', '=', part_catalog_id)]"
context="{'default_part_catalog_id': part_catalog_id}"

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Invoicing',
'version': '19.0.3.0.0',
'version': '19.0.3.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
'description': """

View File

@@ -19,25 +19,23 @@ class FpDelivery(models.Model):
def action_mark_delivered(self):
res = super().action_mark_delivered()
SaleOrder = self.env['sale.order']
MrpProduction = self.env['mrp.production']
# Sub 11 — MRP gone; resolve via delivery.job_ref → fp.job.name → fp.job.origin.
Job = self.env['fp.job'] if 'fp.job' in self.env else None
for delivery in self:
# Resolve the sale order via delivery.job_ref → MO.name → MO.origin
so = False
if delivery.job_ref:
mo = MrpProduction.search(
if delivery.job_ref and Job is not None:
job = Job.sudo().search(
[('name', '=', delivery.job_ref)], limit=1,
)
if mo and mo.origin:
if job and job.origin:
so = SaleOrder.search(
[('name', '=', mo.origin)], limit=1,
[('name', '=', job.origin)], limit=1,
)
if not so:
# Fallback: find by partner + recently-confirmed with matching strategy
continue
strategy = so.x_fc_invoice_strategy
if strategy not in ('progress', 'net_terms'):
continue
# Skip if already billed in full
if so.invoice_status == 'invoiced':
continue
so._create_final_balance_invoice()

View File

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

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Daily cron — nudge supervisor for steps stuck in `paused` state
longer than 24 hours. Schedules a mail.activity on the parent job
so the manager sees a TODO. Idempotent — re-running the same day
won't double-schedule.
-->
<odoo noupdate="1">
<record id="ir_cron_nudge_stale_paused_steps" model="ir.cron">
<field name="name">Fusion Plating: Nudge stale paused steps</field>
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
<field name="state">code</field>
<field name="code">model._cron_nudge_stale_paused(threshold_hours=24)</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<!-- Twin cron for in_progress steps. Lower threshold (8h) because
an in_progress step has an open timelog row accumulating
phantom hours every minute it sits idle. -->
<record id="ir_cron_nudge_stale_in_progress_steps" model="ir.cron">
<field name="name">Fusion Plating: Nudge stale in-progress steps</field>
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
<field name="state">code</field>
<field name="code">model._cron_nudge_stale_in_progress(threshold_hours=8)</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -12,6 +12,7 @@ from . import fp_portal_job
from . import account_move
from . import res_config_settings
from . import sale_order
from . import sale_order_line
# Phase 3 — parallel job/step links on dependent modules' models.
from . import fp_batch

View File

@@ -4,8 +4,14 @@
#
# Phase 3 — parallel job link on fp.certificate.
# Coexists with bridge_mrp's production_id link.
#
# v19.0.6.20.0 — surface the Fischerscope PDF on the cert form so
# operators can SEE that the thickness report will be (or has been)
# merged into the CoC. The merge logic itself lives in
# fusion_plating_certificates/models/fp_certificate.py — this file
# only adds the human-readable indicators.
from odoo import fields, models
from odoo import api, fields, models
class FpCertificate(models.Model):
@@ -17,3 +23,95 @@ class FpCertificate(models.Model):
index=True,
help="Native fp.job link. Coexists with bridge_mrp's production_id.",
)
# ---- Fischerscope thickness-PDF visibility (S19) ---------------------
# These three fields are computed from the linked job's QC checks so
# the cert form can show the operator BEFORE issuing whether a
# Fischerscope report is on file and will be appended as page 2.
x_fc_thickness_qc_id = fields.Many2one(
'fusion.plating.quality.check',
string='Linked QC (Thickness)',
compute='_compute_fischer_visibility',
help='Quality check on the linked plating job that has a '
'Fischerscope / XDAL 600 thickness PDF uploaded. Used to '
'merge that PDF into the CoC on Issue.',
)
x_fc_thickness_pdf_id = fields.Many2one(
'ir.attachment',
string='Fischerscope PDF',
compute='_compute_fischer_visibility',
help='Thickness report PDF that will be appended as page 2 of '
'the CoC when the certificate is issued.',
)
x_fc_thickness_status = fields.Selection(
[
('none', 'No PDF Uploaded'),
('pending', 'Will Append on Issue'),
('merged', 'Merged into CoC'),
],
string='Thickness Report',
compute='_compute_fischer_visibility',
help='none = QC has no Fischerscope upload · '
'pending = will be appended when Issue is clicked · '
'merged = already in the issued CoC PDF',
)
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
def _compute_fischer_visibility(self):
QC = self.env.get('fusion.plating.quality.check')
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
empty_att = self.env['ir.attachment']
for rec in self:
qc = empty_qc
pdf = empty_att
status = 'none'
if QC is not None and rec.x_fc_job_id:
# Same lookup the merge method uses — passed-first,
# then any QC with a PDF.
qc = QC.sudo().search([
('job_id', '=', rec.x_fc_job_id.id),
('state', '=', 'passed'),
('thickness_report_pdf_id', '!=', False),
], order='completed_at desc', limit=1)
if not qc:
qc = QC.sudo().search([
('job_id', '=', rec.x_fc_job_id.id),
('thickness_report_pdf_id', '!=', False),
], order='create_date desc', limit=1)
if qc and qc.thickness_report_pdf_id:
pdf = qc.thickness_report_pdf_id
if rec.state == 'issued' and rec.attachment_id:
status = 'merged'
else:
status = 'pending'
rec.x_fc_thickness_qc_id = qc or empty_qc
rec.x_fc_thickness_pdf_id = pdf or empty_att
rec.x_fc_thickness_status = status
def action_view_thickness_qc(self):
"""Smart-button target — open the linked QC for inspection."""
self.ensure_one()
if not self.x_fc_thickness_qc_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_thickness_qc_id.name,
'res_model': 'fusion.plating.quality.check',
'res_id': self.x_fc_thickness_qc_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_open_job(self):
"""Smart-button target — open the linked plating job."""
self.ensure_one()
if not self.x_fc_job_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_job_id.name,
'res_model': 'fp.job',
'res_id': self.x_fc_job_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -14,7 +14,7 @@ import logging
from markupsafe import Markup
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@@ -263,6 +263,95 @@ class FpJob(models.Model):
'name': self.portal_job_id.name,
}
def write(self, vals):
"""Write hook: when qty_scrapped INCREASES, auto-spawn a
fusion.plating.quality.hold for the scrapped delta. AS9100 /
Nadcap need a disposition record per scrap event — without
this the operator silently bumps qty_scrapped, no paper trail,
auditor can't reconstruct what happened.
Idempotent per write: one hold per increase event. Operator
fills hold_reason + description on the spawned record.
"""
from markupsafe import Markup as _Markup
scrap_deltas = {}
if 'qty_scrapped' in vals:
new = vals['qty_scrapped'] or 0
for job in self:
old = job.qty_scrapped or 0
if new > old:
scrap_deltas[job.id] = (old, new)
result = super().write(vals)
if not scrap_deltas:
return result
Hold = (self.env['fusion.plating.quality.hold']
if 'fusion.plating.quality.hold' in self.env else None)
if Hold is None:
return result
Facility = self.env['fusion.plating.facility']
for job in self:
if job.id not in scrap_deltas:
continue
old, new = scrap_deltas[job.id]
delta = new - old
facility = job.facility_id or Facility.search([
('company_id', '=', job.company_id.id),
], limit=1) or Facility.search([], limit=1)
part_ref = (
job.part_catalog_id.part_number if job.part_catalog_id
else job.product_id.default_code or job.name
)
try:
hold = Hold.create({
'job_id': job.id,
'part_ref': (part_ref or job.name)[:64],
'qty_on_hold': int(delta),
'qty_original': int(job.qty or 0),
'mark_for_scrap': True,
'hold_reason': 'other',
'description': _(
'Auto-spawned from job %s scrap update by %s: '
'qty_scrapped went from %g to %g (delta %g). '
'OPERATOR: replace this text with the actual '
'reason (drop / contamination / out-of-spec / etc).'
) % (job.name, self.env.user.name, old, new, delta),
'facility_id': facility.id if facility else False,
})
job.message_post(body=_Markup(_(
'⚠️ Scrap auto-Hold spawned: <b>%s</b> for %g part(s). '
'Operator must update description with the cause.'
)) % (hold.name, delta))
except Exception as e:
_logger.warning(
'Job %s: failed to auto-spawn scrap hold: %s',
job.name, e,
)
return result
def action_sync_qty_from_so(self):
"""Pull the SO qty into the job's qty field after a mid-job
SO line edit. Posts chatter so the audit trail captures who
synced + what the previous value was.
Manual action because qty changes mid-job have physical-world
consequences (rack more parts, stop early, scrap excess) — the
supervisor must explicitly acknowledge by clicking the button.
"""
from markupsafe import Markup
for job in self:
if not job.sale_order_id:
continue
so_qty = sum(job.sale_order_id.order_line.mapped('product_uom_qty'))
old = job.qty
if abs(old - so_qty) < 0.0001:
continue
job.qty = so_qty
job.message_post(body=Markup(_(
'Job qty synced from SO by <b>%s</b>: %g%g%+g). '
'Operator: confirm physical scope matches.'
)) % (self.env.user.name, old, so_qty, so_qty - old))
return True
# ------------------------------------------------------------------
# Recipe → fp.job.step generation (Task 2.4)
#
@@ -523,6 +612,15 @@ class FpJob(models.Model):
# short-circuits when steps already exist.
if job.recipe_id and not job.step_ids:
job._generate_steps_from_recipe()
# Promote freshly-generated 'pending' steps to 'ready' so the
# operator has a Start button when they open the job. Without
# this the floor stalls — every step is parked in pending with
# no UI affordance to move it forward.
pending_steps = job.step_ids.filtered(
lambda s: s.state == 'pending'
)
if pending_steps:
pending_steps.write({'state': 'ready'})
job._fp_create_portal_job()
job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection()
@@ -576,13 +674,13 @@ class FpJob(models.Model):
self.portal_job_id = portal.id
def _fp_create_qc_check_if_needed(self):
"""If customer has x_fc_requires_qc=True, create a QC check.
"""If customer has x_fc_requires_qc=True, spawn a QC check via
the canonical fp.quality.check.create_for_job() entry point.
The fusion.plating.quality.check model lives in
fusion_plating_bridge_mrp; we runtime-detect it to avoid a
depends-on-bridge_mrp cycle. If the model isn't registered, log
a warning and skip — bridge_mrp can be installed later without
breaking this flow.
Sub 11 — model relocated from bridge_mrp to fusion_plating_quality.
create_for_job resolves the template (customer-specific or default),
clones every template line, returns an existing record if one is
already open, and posts a chatter trail.
"""
self.ensure_one()
partner = self.partner_id
@@ -593,31 +691,13 @@ class FpJob(models.Model):
if not wants_qc:
return
if 'fusion.plating.quality.check' not in self.env:
_logger.warning(
"Job %s: customer wants QC but fusion.plating.quality.check "
"model not registered (bridge_mrp deferral).", self.name,
)
return
QC = self.env['fusion.plating.quality.check'].sudo()
# Try to create with the most likely required fields. If the
# model has a different schema than expected, this may need
# adjustment when bridge_mrp's QC model lands here.
QC = self.env['fusion.plating.quality.check']
try:
qc_vals = {
'partner_id': partner.id,
'state': 'pending',
}
# Try the new field name first; fallback to mrp-bound.
if 'job_id' in QC._fields:
qc_vals['job_id'] = self.id
elif 'production_id' in QC._fields:
# bridge_mrp's QC binds to production. We can't fill that
# from here — leave it null and let a manual link happen.
pass
QC.create(qc_vals)
QC.create_for_job(self)
except Exception as e:
_logger.warning(
"Job %s: failed to create QC check: %s", self.name, e,
"Job %s: create_for_job failed: %s", self.name, e,
)
# ------------------------------------------------------------------
@@ -626,12 +706,22 @@ class FpJob(models.Model):
def button_mark_done(self):
"""Transition the job to 'done' and trigger downstream side effects.
- Blocks if any step is not done/skipped (manager bypass via
context key `fp_skip_step_gate=True`). Compliance: AS9100 /
Nadcap require evidence that every recipe step ran. Without
this guard an operator could close a job with zero work.
- Blocks if customer requires QC and the QC check isn't passed
(manager bypass via context key `fp_skip_qc_gate=True`)
- Sets state='done', date_finished=now
- Auto-creates a draft fusion.plating.delivery
- Triggers certificate auto-generation (best-effort)
"""
# During migration, side-effects are skipped — see action_confirm.
skip_side_effects = self.env.context.get('fp_jobs_migration')
skip_qc_gate = self.env.context.get('fp_skip_qc_gate')
skip_step_gate = self.env.context.get('fp_skip_step_gate')
QC = self.env['fusion.plating.quality.check'] \
if 'fusion.plating.quality.check' in self.env else None
for job in self:
if job.state == 'done':
continue
@@ -639,6 +729,105 @@ class FpJob(models.Model):
raise UserError(
"Job %s is cancelled — cannot mark done." % job.name
)
# Step-completion gate: every step must be done (or explicitly
# skipped, once button_skip is implemented). Without this
# guard operators can close a recipe-driven job with zero
# actual work logged. Manager bypass via context.
if not skip_step_gate and job.step_ids:
# `skipped` and `cancelled` count as terminal — operator
# explicitly opted those out (skipped) or killed them
# (cancelled). Only steps still in pending/ready/in_progress/
# paused block job close.
undone = job.step_ids.filtered(
lambda s: s.state not in ('done', 'skipped', 'cancelled')
)
if undone:
raise UserError(_(
"Job %s cannot be marked Done — %d/%d step(s) "
"are not finished:\n %s\n\nWalk each step on "
"the tablet (or skip / cancel opt-in steps)."
) % (
job.name, len(undone), len(job.step_ids),
'\n '.join(
f'#{s.sequence} {s.name} ({s.state})'
for s in undone[:5]
),
))
# Bake-window gate (compliance — AS9100 / Nadcap): if any
# auto-spawned bake.window is still awaiting_bake OR
# bake_in_progress, the bake hasn't been documented and
# parts cannot ship. Without this guard a careless
# operator closes the job, parts ship, three weeks later
# a field failure surfaces and the auditor asks for the
# bake record that doesn't exist. Manager bypass via
# fp_skip_bake_gate=True for documented customer deviation.
skip_bake_gate = self.env.context.get('fp_skip_bake_gate')
BW = (self.env['fusion.plating.bake.window']
if 'fusion.plating.bake.window' in self.env else None)
if not skip_bake_gate and BW is not None:
pending_bw = BW.sudo().search([
('part_ref', '=', job.name),
('state', 'in', ('awaiting_bake', 'bake_in_progress')),
])
if pending_bw:
raise UserError(_(
"Job %s cannot be marked Done — bake window "
"still pending:\n %s\n\nBake hydrogen "
"embrittlement relief on the parts (start + "
"end the bake on the bake.window record), then "
"close the job. Manager override available for "
"documented customer deviation."
) % (
job.name,
'\n '.join(
f'{bw.name} (state={bw.state}, '
f'required_by={bw.bake_required_by})'
for bw in pending_bw[:5]
),
))
# Qty reconciliation gate: qty_done + qty_scrapped must
# equal qty when the job closes. Without this an operator
# can ship "5 of 5" while only 4 are actually plated +
# 1 contaminated, with no record of the missing piece.
# Manager bypass via fp_skip_qty_reconcile=True (e.g. when
# qty tracking truly doesn't apply).
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
if not skip_qty_gate and job.qty:
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
if abs(accounted - job.qty) > 0.0001:
raise UserError(_(
"Job %s qty mismatch — ordered %g, but qty_done "
"(%g) + qty_scrapped (%g) = %g. Update Quantity "
"Completed and Quantity Scrapped on the job "
"header so they sum to %g before closing."
) % (
job.name, job.qty, job.qty_done or 0,
job.qty_scrapped or 0, accounted, job.qty,
))
# QC gate: customers flagged x_fc_requires_qc must have a
# passed QC before the job closes. AS9100 / Nadcap compliance.
if QC and not skip_qc_gate \
and 'x_fc_requires_qc' in job.partner_id._fields \
and job.partner_id.x_fc_requires_qc:
blocking_qc = QC.search([
('job_id', '=', job.id),
('state', 'not in', ('passed',)),
], order='create_date desc', limit=1)
if blocking_qc:
raise UserError(_(
"Job %s cannot be marked Done — QC check %s is in "
"state '%s'. Pass the QC checklist first, or have "
"a manager override via the bypass button."
) % (job.name, blocking_qc.name, blocking_qc.state))
# No QC at all? Spawn one now (idempotent) and require
# the operator to walk it before retrying.
no_qc = not QC.search_count([('job_id', '=', job.id)])
if no_qc:
QC.create_for_job(job)
raise UserError(_(
"Job %s requires QC. A new check has been created — "
"complete it before marking the job Done."
) % job.name)
job.state = 'done'
job.date_finished = fields.Datetime.now()
if not skip_side_effects:
@@ -682,33 +871,31 @@ class FpJob(models.Model):
)
def _fp_create_delivery(self):
"""Create a draft fusion.plating.delivery linked to this job."""
"""Create a draft fusion.plating.delivery linked to this job.
Sets BOTH x_fc_job_id (Many2one — strong link) AND job_ref
(Char — soft reference). Downstream code is split: smart-button
navigation reads x_fc_job_id, but the box-parity check, RMA
refund auto-link, and the legacy notification dispatch all
look up by job_ref. Setting both ends keeps every consumer
happy.
"""
self.ensure_one()
if self.delivery_id:
return
Delivery = self.env['fusion.plating.delivery'].sudo()
# Verify the model has a job link field. The current delivery
# model uses `job_ref` (Char) as a soft reference; some forks
# may add `x_fc_job_id` (Many2one).
vals = {'partner_id': self.partner_id.id}
if 'x_fc_job_id' in Delivery._fields:
ref_field = 'x_fc_job_id'
ref_value = self.id
elif 'job_ref' in Delivery._fields:
ref_field = 'job_ref'
ref_value = self.name
else:
vals['x_fc_job_id'] = self.id
if 'job_ref' in Delivery._fields:
vals['job_ref'] = self.name
if 'x_fc_job_id' not in Delivery._fields \
and 'job_ref' not in Delivery._fields:
_logger.warning(
"Job %s: fusion.plating.delivery has no job link field; "
"delivery created without job back-reference.", self.name,
)
ref_field = None
ref_value = None
try:
vals = {
'partner_id': self.partner_id.id,
}
if ref_field:
vals[ref_field] = ref_value
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
except Exception as e:
@@ -719,29 +906,87 @@ class FpJob(models.Model):
def _fp_create_certificates(self):
"""Trigger cert auto-create on job done.
Best-effort: if fp.certificate has the right fields, create a
draft CoC. Otherwise log + skip.
Pre-populates ALL the fields a CoC issuer needs so Tom can hit
Issue without filling 6 fields first:
- partner_id from job
- spec_reference from coating (required by action_issue)
- part_number from part_catalog
- quantity_shipped from job qty (minus scrap)
- po_number from sale_order
- sale_order_id link
- x_fc_job_id link if the field exists
Idempotent — if a cert already exists for this job, skip
(prevents dupes when button_mark_done is re-run after a
manager bypass).
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
return
Cert = self.env['fp.certificate'].sudo()
# Idempotency: don't double-create on retry.
existing_dom = []
if 'x_fc_job_id' in Cert._fields:
existing_dom.append(('x_fc_job_id', '=', self.id))
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
existing_dom.append(('sale_order_id', '=', self.sale_order_id.id))
if existing_dom:
existing = Cert.search(existing_dom, limit=1)
if existing:
_logger.info(
'Job %s: cert %s already exists, skipping auto-create',
self.name, existing.name,
)
return
try:
vals = {
'partner_id': self.partner_id.id,
}
vals = {'partner_id': self.partner_id.id}
if 'certificate_type' in Cert._fields:
vals['certificate_type'] = 'coc'
if 'state' in Cert._fields:
vals['state'] = 'draft'
# Add job link if Cert has the field
# Job + SO links.
if 'x_fc_job_id' in Cert._fields:
vals['x_fc_job_id'] = self.id
elif 'job_id' in Cert._fields:
vals['job_id'] = self.id
elif 'sale_order_id' in Cert._fields and self.sale_order_id:
if 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id
Cert.create(vals)
# Pre-fill from coating: the spec_reference is what action_issue
# blocks on — without this every cert needs a manual edit.
coating = self.coating_config_id
if coating and 'spec_reference' in Cert._fields \
and getattr(coating, 'spec_reference', False):
vals['spec_reference'] = coating.spec_reference
# Pre-fill part_number from the part catalog if we have one.
if 'part_number' in Cert._fields and self.part_catalog_id:
vals['part_number'] = self.part_catalog_id.part_number or ''
# Quantity shipped = job qty minus scrap. AS9100 wants the
# actual count that left the shop, not the order count.
if 'quantity_shipped' in Cert._fields:
vals['quantity_shipped'] = int(
(self.qty_done or self.qty or 0) - (self.qty_scrapped or 0)
)
# PO number from the source SO.
if 'po_number' in Cert._fields and self.sale_order_id \
and 'x_fc_po_number' in self.sale_order_id._fields:
vals['po_number'] = self.sale_order_id.x_fc_po_number or ''
# Customer job# → cert label (helps customer search).
if 'customer_job_no' in Cert._fields and self.sale_order_id \
and 'x_fc_customer_job_number' in self.sale_order_id._fields:
vals['customer_job_no'] = (
self.sale_order_id.x_fc_customer_job_number or ''
)
# Process description from coating name.
if 'process_description' in Cert._fields and coating:
vals['process_description'] = coating.name or ''
# Job # for shop-side reference.
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals)
self.message_post(body=Markup(_(
'CoC <b>%s</b> auto-created (draft). Issuer should hit '
'the Issue button on the certificate when ready to ship.'
)) % cert.name)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create cert: %s", self.name, e,

View File

@@ -6,20 +6,55 @@
# fusion_plating core's fp.job.step shipped as NotImplementedError
# placeholders. Per spec §5.2 state machine.
from odoo import _, fields, models
import logging
import re
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
def button_start(self):
"""Override — soft gate when parts haven't been received yet.
"""Override — soft gate when parts haven't been received yet,
plus hard predecessor gate for steps flagged
requires_predecessor_done by the recipe author.
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.
Receiving check is soft (logs to chatter) — manager wants the
shop to start prep regardless when parts are in-transit late.
Predecessor check IS hard-blocking — if the recipe author
marked this step as serial-required, every earlier-sequence
step must be terminal (done / skipped / cancelled) before
Start fires. Manager bypass via fp_skip_predecessor_check=True.
"""
skip_pred = self.env.context.get('fp_skip_predecessor_check')
for step in self:
if not step.requires_predecessor_done or skip_pred:
continue
blocking = step.job_id.step_ids.filtered(
lambda s: s.sequence < step.sequence and s.state not in (
'done', 'skipped', 'cancelled',
)
)
if blocking:
raise UserError(_(
"Step '%s' requires predecessors done first. "
"Blocking earlier step(s):\n %s\n\nFinish or skip "
"those before starting this one (manager can "
"override via context fp_skip_predecessor_check=True)."
) % (
step.name,
'\n '.join(
f'#{s.sequence} {s.name} ({s.state})'
for s in blocking[:5]
),
))
result = super().button_start()
for step in self:
so = step.job_id.sale_order_id
@@ -88,3 +123,293 @@ class FpJobStep(models.Model):
) % step.name)
step.state = 'cancelled'
return True
def write(self, vals):
"""Post a chatter trail on the parent JOB whenever an active
step gets reassigned. The step itself already tracks
assigned_user_id (tracking=True) but supervisors don't open
each step's chatter — they read the job. Without a job-level
post the takeover is invisible.
Only fires for steps in active states (in_progress / paused)
so creating a draft job + assigning a step to someone doesn't
spam the job chatter. Comparing to the OLD assignment so we
don't post on the initial set-from-False either.
"""
post_for = []
if 'assigned_user_id' in vals:
new_uid = vals['assigned_user_id']
for step in self:
if step.state not in ('in_progress', 'paused'):
continue
old_uid = step.assigned_user_id.id
if not old_uid:
continue
if new_uid == old_uid:
continue
post_for.append((step, old_uid, new_uid))
result = super().write(vals)
Users = self.env['res.users']
for step, old_uid, new_uid in post_for:
old_name = Users.browse(old_uid).name if old_uid else '(unassigned)'
new_name = Users.browse(new_uid).name if new_uid else '(unassigned)'
step.job_id.message_post(body=Markup(_(
'Step <b>%s</b> reassigned from <b>%s</b> to <b>%s</b> '
'(state=%s) by %s.'
)) % (step.name, old_name, new_name, step.state,
self.env.user.name))
return result
@api.model
def _cron_nudge_stale_paused(self, threshold_hours=24):
"""Daily nudge for steps stuck in `paused` longer than threshold."""
return self._cron_nudge_stale_steps(
states=('paused',),
threshold_hours=threshold_hours,
label='paused',
)
@api.model
def _cron_nudge_stale_in_progress(self, threshold_hours=8):
"""Cron nudge for steps stuck in `in_progress` longer than
threshold. Default 8 hours — operator started, walked away,
timelog accumulating phantom hours.
"""
return self._cron_nudge_stale_steps(
states=('in_progress',),
threshold_hours=threshold_hours,
label='in-progress',
)
@api.model
def _cron_nudge_stale_steps(self, states=('paused',),
threshold_hours=24, label='stale'):
"""Generic stale-step nudger.
Finds every fp.job.step in any of `states` with date_started
older than N hours. Schedules a 'todo' mail.activity on the
parent job for the job's manager_id (falls back to the user
who started the step). Idempotent — won't double-schedule if
an open activity with the same summary already exists.
"""
from datetime import timedelta as _td
cutoff = fields.Datetime.now() - _td(hours=threshold_hours)
stale = self.search([
('state', 'in', list(states)),
('date_started', '<', cutoff),
('date_started', '!=', False),
])
Activity = self.env['mail.activity']
ActivityType = self.env.ref(
'mail.mail_activity_data_todo', raise_if_not_found=False,
)
nudged_count = 0
for step in stale:
job = step.job_id
assignee = (job.manager_id or step.assigned_user_id
or step.started_by_user_id or self.env.user)
summary = _('Stale %s step: %s') % (label, step.name)
existing = Activity.search([
('res_model', '=', job._name),
('res_id', '=', job.id),
('summary', '=', summary),
], limit=1)
if existing:
continue
age_h = (fields.Datetime.now() - step.date_started).total_seconds() / 3600.0
note = _(
'Step "%(step)s" on job %(job)s has been in %(label)s state for '
'%(hours).1f hours (since %(start)s). Investigate: operator '
'reassignment, equipment failure, or finish + close out.'
) % {
'step': step.name, 'job': job.name, 'label': label,
'hours': age_h, 'start': step.date_started,
}
vals = {
'res_model_id': self.env['ir.model']._get(job._name).id,
'res_id': job.id,
'summary': summary,
'note': note,
'user_id': assignee.id,
'date_deadline': fields.Date.context_today(self),
}
if ActivityType:
vals['activity_type_id'] = ActivityType.id
Activity.create(vals)
nudged_count += 1
job.message_post(body=Markup(_(
'Stale %s step: <b>%s</b> has been idle %.1f hours. '
'Activity created for %s.'
)) % (label, step.name, age_h, assignee.name))
if nudged_count:
_logger.info(
'fp.job.step stale-%s cron: nudged %d step(s)',
label, nudged_count,
)
return nudged_count
def action_abort_for_retry(self, reason=None, new_tank_id=None,
new_bath_id=None):
"""Abort an in_progress / paused step so the operator can restart
it (typically after an equipment failure mid-step).
Closes the open timelog (preserves the partial-work record on
the audit trail), posts a clear chatter event on the JOB
explaining why + which tank, optionally moves the step to a
different tank/bath, and resets the step to `ready` so the
operator can hit Start again.
Without this method the operator's only options are
button_cancel (kills the step entirely) or
pause → write tank → start (no failure audit).
"""
if not reason:
reason = _('Equipment failure / abort for retry')
for step in self:
if step.state not in ('in_progress', 'paused'):
raise UserError(_(
"Step '%s' is in state '%s' — only in_progress / "
"paused steps can be aborted for retry."
) % (step.name, step.state))
old_tank = step.tank_id.display_name or '(no tank set)'
old_bath = step.bath_id.display_name or '(no bath set)'
now = fields.Datetime.now()
open_logs = step.time_log_ids.filtered(
lambda l: not l.date_finished
)
if open_logs:
open_logs.write({'date_finished': now})
partial_min = sum(step.time_log_ids.mapped('duration_minutes'))
change_msg = ''
if new_tank_id:
step.tank_id = new_tank_id
change_msg += ' -> tank %s' % step.tank_id.display_name
if new_bath_id:
step.bath_id = new_bath_id
change_msg += ' -> bath %s' % step.bath_id.display_name
step.state = 'ready'
step.duration_actual = partial_min
step.job_id.message_post(body=Markup(_(
'⚠️ Step <b>%s</b> aborted for retry by %s.<br/>'
'Reason: <em>%s</em><br/>'
'Equipment: tank=%s, bath=%s%s<br/>'
'Partial work captured: %.2f min in %d timelog(s). '
'Step is back in <b>ready</b> state — operator can '
'restart when the issue is resolved.'
)) % (
step.name, self.env.user.name, reason,
old_tank, old_bath, change_msg, partial_min,
len(step.time_log_ids),
))
return True
def action_recompute_duration_from_timelogs(self):
"""Re-sum duration_actual from the step's timelog rows.
Use case: supervisor adjusts a timelog row (back-date a forgotten
click, fix wrong operator, delete a stale entry that was left
open over a shift change) and needs the step's duration_actual
to reflect the corrected reality. Without this, edits to time_log_ids
rows don't propagate into duration_actual (which is set once
by button_finish).
Posts the before/after to chatter for audit.
"""
for step in self:
old = step.duration_actual or 0.0
new = sum(step.time_log_ids.mapped('duration_minutes'))
step.duration_actual = new
if abs(old - new) > 0.001:
step.job_id.message_post(body=Markup(_(
'Step <b>%s</b> duration recomputed from timelog rows: '
'%.2f min → %.2f min (Δ %+.2f). Recomputed by %s.'
)) % (step.name, old, new, new - old, self.env.user.name))
return True
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes
on a coating that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance);
2) Post a chatter warning when duration_actual exceeds 1.5×
duration_expected — silent overruns are a red flag for
scheduling and costing.
Both actions are idempotent and never block the finish itself.
"""
result = super().button_finish()
BW = self.env['fusion.plating.bake.window']
Bath = self.env['fusion.plating.bath']
for step in self:
if step.state != 'done':
continue
# Duration-overrun chatter alert.
if step.duration_expected and step.duration_actual:
ratio = step.duration_actual / step.duration_expected
if ratio >= 1.5:
step.job_id.message_post(body=Markup(_(
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
'expected %.0f min, actual %.0f min. Investigate: '
'equipment issue, training gap, or recipe time '
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
coating = step.job_id.coating_config_id \
if 'coating_config_id' in step.job_id._fields else False
if not coating:
continue
requires = getattr(coating, 'requires_bake_relief', False)
window_hrs = getattr(coating, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
# Trigger only on the actual plating-out step. We want
# exactly ONE bake.window per job (not one per step that
# happens to have "plate" in the name). Heuristic:
# - step.kind == 'wet' (clean, recipe-authored signal); OR
# - the step name contains "plating" as a word
# Explicit excludes: inspection / bake / mask / rack steps
# whose names might happen to mention plating in passing
# (e.g. "Post-plate Inspection").
name_l = (step.name or '').lower()
kind_match = step.kind == 'wet'
name_match = bool(re.search(r'\bplating\b', name_l))
excluded = any(kw in name_l for kw in (
'inspect', 'inspection', 'bake', 'mask', 'rack',
))
if (not kind_match and not name_match) or excluded:
continue
# Idempotency — only one bake.window per (job, step).
existing = BW.sudo().search([
('part_ref', '=', step.job_id.name),
('lot_ref', '=', f'step-{step.id}'),
], limit=1)
if existing:
continue
# Pick a bath: step.bath_id wins; fall back to the first
# active bath in the facility (best-effort — operator can
# correct on the bake.window record).
bath = step.bath_id or Bath.sudo().search(
[('facility_id', '=', step.facility_id.id)], limit=1,
) if step.facility_id else False
if not bath:
bath = Bath.sudo().search([], limit=1)
if not bath:
_logger.warning(
'Step %s: bake-window auto-spawn skipped — no bath '
'configured.', step.name,
)
continue
bw = BW.sudo().create({
'bath_id': bath.id,
'plate_exit_time': step.date_finished or fields.Datetime.now(),
'window_hours': window_hrs,
'part_ref': step.job_id.name,
'lot_ref': f'step-{step.id}',
'customer_ref': step.job_id.partner_id.display_name or '',
'quantity': int(step.job_id.qty or 0),
})
step.job_id.message_post(body=Markup(_(
'Bake window <b>%s</b> auto-created — %.1fh window from '
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result

View File

@@ -25,6 +25,14 @@ class SaleOrder(models.Model):
string='Plating Jobs',
compute='_compute_fp_job_count',
)
x_fc_fp_certificate_count = fields.Integer(
string='Certificates',
compute='_compute_fp_certificate_count',
help='Number of fp.certificate records issued (or draft) against '
'this sale order. Surfaced as a smart button so Sarah/Tom '
'can jump straight from the SO to the cert without having '
'to drill through the linked Plating Job first.',
)
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
@@ -66,6 +74,13 @@ class SaleOrder(models.Model):
[('sale_order_id', '=', so.id)]
)
def _compute_fp_certificate_count(self):
Cert = self.env['fp.certificate'].sudo()
for so in self:
so.x_fc_fp_certificate_count = Cert.search_count(
[('sale_order_id', '=', so.id)]
)
def _compute_workflow_stage(self):
"""Native-jobs override — walks fp.job state instead of mrp.production.
@@ -162,6 +177,28 @@ class SaleOrder(models.Model):
})
return action
def action_view_fp_certificates(self):
"""Smart-button target — open the certificate(s) linked to this
SO. One cert → form view; many → list view filtered to this SO."""
self.ensure_one()
certs = self.env['fp.certificate'].search([
('sale_order_id', '=', self.id),
])
action = {
'type': 'ir.actions.act_window',
'name': _('Certificates'),
'res_model': 'fp.certificate',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {
'default_sale_order_id': self.id,
'default_partner_id': self.partner_id.id,
},
}
if len(certs) == 1:
action.update({'view_mode': 'form', 'res_id': certs.id})
return action
def action_confirm(self):
result = super().action_confirm()
# Only run when the native flag is on
@@ -209,6 +246,18 @@ class SaleOrder(models.Model):
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id)
)
)
# Fallback: legacy/configurator SOs that carry part+coating on the
# header but not on the line. Treat the entire order as one
# plating line so the planner gets an fp.job to work against.
if not plating_lines and self.order_line and (
('x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id)
or ('x_fc_coating_config_id' in self._fields and self.x_fc_coating_config_id)
):
_logger.info(
'SO %s: no line-level part/coating but header carries one — '
'treating all lines as a single plating job.', self.name,
)
plating_lines = self.order_line
if not plating_lines:
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
@@ -239,13 +288,38 @@ class SaleOrder(models.Model):
and first_line.x_fc_coating_config_id
or False
)
# Recipe lookup: from coating, fallback to part
# Header fallback for legacy/configurator SOs that put part +
# coating on the SO header instead of the line.
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
# Recipe lookup priority:
# 1. line.x_fc_process_variant_id — Sarah explicitly picked
# a part-scoped variant on this order line. Always wins.
# 2. coating.recipe_id — coating-config recipe.
# 3. part.default_process_id — part's flagged default.
# 4. part.recipe_id — legacy fallback.
#
# If multiple lines in the same WO group have different
# variants we use the FIRST line's variant (consistent with
# everything else in this loop using `first_line`).
recipe = False
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
picked_variant = (
'x_fc_process_variant_id' in first_line._fields
and first_line.x_fc_process_variant_id
or False
)
if picked_variant:
recipe = picked_variant
if not recipe and coating and 'recipe_id' in coating._fields \
and coating.recipe_id:
recipe = coating.recipe_id
if not recipe and part and 'default_process_id' in part._fields and part.default_process_id:
if not recipe and part and 'default_process_id' in part._fields \
and part.default_process_id:
recipe = part.default_process_id
if not recipe and part and 'recipe_id' in part._fields and part.recipe_id:
if not recipe and part and 'recipe_id' in part._fields \
and part.recipe_id:
recipe = part.recipe_id
vals = {

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Mid-job qty drift guard. When Sarah edits an SO line's qty after a
# fp.job has been spawned and started, the job's qty does NOT auto-
# update (intentionally — Carlos may already be plating). But without
# a warning the qty drift is silent and bills go out wrong. This
# write-override posts chatter on every active linked job so operators
# see the change immediately, AND offers a "Sync qty from SO" action
# on the job for the supervisor to apply.
from markupsafe import Markup
from odoo import _, models
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def write(self, vals):
# Detect qty changes BEFORE the write so we can compare.
old_qty_by_id = {}
if 'product_uom_qty' in vals:
for line in self:
old_qty_by_id[line.id] = line.product_uom_qty
result = super().write(vals)
if 'product_uom_qty' not in vals:
return result
Job = self.env['fp.job']
for line in self:
new_qty = line.product_uom_qty
old_qty = old_qty_by_id.get(line.id, new_qty)
if old_qty == new_qty:
continue
jobs = Job.search([
('sale_order_id', '=', line.order_id.id),
('state', 'not in', ('draft', 'cancelled', 'done')),
])
for job in jobs:
job.message_post(body=Markup(_(
'⚠️ <b>SO qty changed mid-job</b> by %(user)s. '
'SO line %(name)s went from %(old)g to %(new)g. '
'Job qty is still <b>%(jobqty)g</b> — operator '
'must manually adjust scope (start more racks or '
'stop early) and the supervisor should hit '
'<b>Sync qty from SO</b> on the job header to '
'reconcile.'
)) % {
'user': self.env.user.name,
'name': line.name[:60] if line.name else '(unnamed)',
'old': old_qty,
'new': new_qty,
'jobqty': job.qty,
})
return result

View File

@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- S19 — Surface Fischerscope thickness PDF on the cert form -->
<!-- ============================================================ -->
<!-- Without this extension the operator has no way to know, -->
<!-- before clicking Issue, whether the QC's Fischerscope PDF -->
<!-- will be appended to the CoC. After Issue, no indicator that -->
<!-- the merged PDF actually contains it. This extension fixes -->
<!-- both gaps with a banner + smart button + clickable file. -->
<record id="fp_certificate_view_form_jobs"
model="ir.ui.view">
<field name="name">fp.certificate.form.inherit.jobs</field>
<field name="model">fp.certificate</field>
<field name="inherit_id"
ref="fusion_plating_certificates.fp_certificate_view_form"/>
<field name="arch" type="xml">
<!-- 1. Smart button: linked Plating Job, and a separate -->
<!-- smart button for the Fischerscope-source QC. -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_job"
type="object"
class="oe_stat_button"
icon="fa-cogs"
invisible="not x_fc_job_id">
<field name="x_fc_job_id" widget="statinfo"
string="Plating Job"/>
</button>
<button name="action_view_thickness_qc"
type="object"
class="oe_stat_button"
icon="fa-microscope"
invisible="not x_fc_thickness_qc_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">
<field name="x_fc_thickness_status" widget="badge"
decoration-info="x_fc_thickness_status == 'pending'"
decoration-success="x_fc_thickness_status == 'merged'"/>
</span>
<span class="o_stat_text">Fischerscope</span>
</div>
</button>
</xpath>
<!-- 2. Banner row above the title — explicit, can't miss. -->
<!-- Three states with distinct alert classes. -->
<xpath expr="//sheet/div[@class='oe_title']" position="before">
<div class="alert alert-info" role="alert"
invisible="x_fc_thickness_status != 'pending'">
<i class="fa fa-info-circle" title="Info"
aria-label="Info"/>
<strong> Fischerscope thickness PDF is on file.</strong>
It will be automatically appended as page&#160;2 of
the CoC when you click <strong>Issue</strong>.
</div>
<div class="alert alert-success" role="alert"
invisible="x_fc_thickness_status != 'merged'">
<i class="fa fa-check-circle" title="Merged"
aria-label="Merged"/>
<strong> Fischerscope thickness report merged.</strong>
The issued CoC PDF includes the Fischerscope report
as page&#160;2 — open the Certificate&#160;PDF tab to verify.
</div>
<div class="alert alert-warning" role="alert"
invisible="not x_fc_job_id or state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
style="margin-top:0;">
<i class="fa fa-exclamation-triangle" title="Warning"
aria-label="Warning"/>
<strong> No Fischerscope PDF on the linked QC.</strong>
If this customer expects an XRF report with the CoC,
have the operator upload the Fischerscope PDF on the
QC check before issuing.
</div>
</xpath>
<!-- 3. Add a Thickness Report tab right next to the -->
<!-- Certificate PDF tab so operator can preview the -->
<!-- Fischerscope file before merging into the cert. -->
<xpath expr="//notebook/page[@name='pdf']" position="after">
<page string="Thickness Report (Fischerscope)"
name="thickness_pdf"
invisible="not x_fc_job_id">
<group>
<field name="x_fc_thickness_status" widget="badge"
readonly="1"
decoration-muted="x_fc_thickness_status == 'none'"
decoration-info="x_fc_thickness_status == 'pending'"
decoration-success="x_fc_thickness_status == 'merged'"/>
<field name="x_fc_thickness_qc_id" readonly="1"
invisible="not x_fc_thickness_qc_id"/>
<field name="x_fc_thickness_pdf_id" readonly="1"
widget="many2one_binary"
invisible="not x_fc_thickness_pdf_id"/>
</group>
<div class="text-muted"
invisible="x_fc_thickness_status != 'none'">
<p>
No Fischerscope thickness PDF has been
uploaded on the linked QC yet. The CoC will
be issued without an appended thickness
report. To attach one:
</p>
<ol>
<li>Open the linked Plating Job (smart
button above)</li>
<li>Click into the auto-spawned Quality
Check</li>
<li>Go to the <em>Thickness Report</em> tab
and upload the PDF from the Fischerscope
/ XDAL 600 export</li>
<li>Pass the QC, then come back here and
click Issue</li>
</ol>
</div>
<div class="text-muted"
invisible="x_fc_thickness_status != 'pending'">
<p>
<i class="fa fa-arrow-up" title="Action"
aria-label="Action"/>
Click <strong>Issue</strong> in the header
and the Fischerscope PDF above will be
merged into page&#160;2 of the CoC.
</p>
</div>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Sub 12 Phase D — quality smart-button row on fp.job. The quality
fields (fp_qc_hold_count, fp_qc_check_count, fp_qc_ncr_count,
fp_qc_capa_count, fp_qc_rma_count) are defined in fusion_plating_quality
via _inherit on fp.job. The view lives here because the button_box
container is added by fusion_plating_jobs (this module loads after
quality so we can safely reference quality fields).
-->
<odoo>
<record id="view_fp_job_form_quality_buttons" model="ir.ui.view">
<field name="name">fp.job.form.quality.buttons</field>
<field name="model">fp.job</field>
<field name="inherit_id" ref="view_fp_job_form_jobs_inherit"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fp_holds" type="object"
class="oe_stat_button" icon="fa-hand-paper-o">
<field name="fp_qc_hold_count" widget="statinfo" string="Holds"/>
</button>
<button name="action_view_fp_checks" type="object"
class="oe_stat_button" icon="fa-check-square-o">
<field name="fp_qc_check_count" widget="statinfo" string="Checks"/>
</button>
<button name="action_view_fp_ncrs" type="object"
class="oe_stat_button" icon="fa-exclamation-triangle">
<field name="fp_qc_ncr_count" widget="statinfo" string="NCRs"/>
</button>
<button name="action_view_fp_capas" type="object"
class="oe_stat_button" icon="fa-wrench">
<field name="fp_qc_capa_count" widget="statinfo" string="CAPAs"/>
</button>
<button name="action_view_fp_rmas" type="object"
class="oe_stat_button" icon="fa-undo">
<field name="fp_qc_rma_count" widget="statinfo" string="RMAs"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -23,10 +23,7 @@
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<!-- bridge_mrp: Production Priorities is mrp.workorder ordering UI;
fp.job has its own priority field on the header. Hidden from
operators / supervisors / managers; only the legacy group sees it. -->
<record id="fusion_plating_bridge_mrp.menu_fp_workorder_priority" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
</record>
<!-- bridge_mrp Production Priorities reference removed post-Sub 11
(the bridge module is uninstalled and its menu xmlid no longer
resolves). fp.job has its own priority field on the header. -->
</odoo>

View File

@@ -23,6 +23,14 @@
<field name="x_fc_fp_job_count" widget="statinfo"
string="Plating Jobs"/>
</button>
<!-- Sarah/Tom path: SO → Certificates (one click instead -->
<!-- of two via the job). Hidden until a cert exists. -->
<button name="action_view_fp_certificates" type="object"
class="oe_stat_button" icon="fa-certificate"
invisible="x_fc_fp_certificate_count == 0">
<field name="x_fc_fp_certificate_count" widget="statinfo"
string="Certificates"/>
</button>
</xpath>
</field>
</record>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.0.0',
'version': '19.0.3.1.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '

View File

@@ -251,17 +251,16 @@ class FpDelivery(models.Model):
self.ensure_one()
if not self.x_fc_box_count_out:
return
Receiving = self.env.get('fp.receiving')
if Receiving is None:
if 'fp.receiving' not in self.env:
return
# Resolve SO via job_ref → MO.origin → SO.name
# Sub 11 — resolve SO via job_ref → fp.job.origin → SO.name.
so_name = False
if self.job_ref:
mo = self.env['mrp.production'].search(
if self.job_ref and 'fp.job' in self.env:
job = self.env['fp.job'].sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
if mo and mo.origin:
so_name = mo.origin
if job and job.origin:
so_name = job.origin
if not so_name:
return
so = self.env['sale.order'].search(

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
'version': '19.0.6.0.0',
'version': '19.0.6.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',

View File

@@ -13,11 +13,16 @@ TRIGGER_EVENTS = [
('quote_sent', 'Quotation Sent'),
('so_confirmed', 'Order Confirmed'),
('parts_received', 'Parts Received'),
('mo_complete', 'Manufacturing Complete'),
('mo_complete', 'Manufacturing Complete'), # legacy, fired by mrp; kept for back-compat
('job_confirmed', 'Plating Job Confirmed'), # Sub 11 — fp.job lifecycle
('job_complete', 'Plating Job Complete'), # Sub 11 — fp.job.button_mark_done
('shipped', 'Shipped / Delivered'),
('invoice_posted', 'Invoice Posted'),
('payment_received', 'Payment Received'),
('deposit_created', 'Deposit Required'),
('rma_authorised', 'RMA Authorised'), # Sub 12 — RMA lifecycle
('rma_received', 'RMA Parts Received'),
('rma_resolved', 'RMA Resolved'),
]
# Sub 6 — map each trigger event to a communication stream. Contacts on
@@ -29,10 +34,15 @@ FP_TRIGGER_STREAM = {
'so_confirmed': 'quotes_so',
'parts_received': 'quotes_so',
'mo_complete': 'qc',
'job_confirmed': 'qc',
'job_complete': 'qc',
'shipped': 'certs',
'invoice_posted': 'invoices',
'payment_received': 'invoices',
'deposit_created': 'invoices',
'rma_authorised': 'qc',
'rma_received': 'qc',
'rma_resolved': 'qc',
}
@@ -117,6 +127,9 @@ class FpNotificationTemplate(models.Model):
)
elif partner.email:
recipient_emails = [partner.email]
# Filter out falsy entries — sub-contacts may have no email and the
# resolver returns False/None for them. Joining with bool blows up.
recipient_emails = [e for e in (recipient_emails or []) if e]
recipient_str = ', '.join(recipient_emails)
email_values = {}

View File

@@ -1,31 +0,0 @@
# -*- 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 models
class MrpProduction(models.Model):
_inherit = 'mrp.production'
def button_mark_done(self):
res = super().button_mark_done()
Dispatch = self.env['fp.notification.template']
for mo in self:
partner = False
so = False
if mo.x_fc_portal_job_id:
partner = mo.x_fc_portal_job_id.partner_id
if mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if so and not partner:
partner = so.partner_id
if not partner:
continue
Dispatch._dispatch(
'mo_complete', mo, partner, sale_order=so,
)
return res

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.3.0.0',
'version': '19.0.4.7.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
@@ -69,6 +69,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_configurator',
'fusion_plating_certificates', # fp.thickness.reading link from QC
'fusion_plating_shopfloor', # _fp_shopfloor_tokens.scss for QC tablet
'fusion_plating_receiving', # rma_id on fp.receiving (Sub 12 Phase A)
# NB: deliberately NOT depending on fusion_plating_jobs — it depends
# on us already (extends fusion.plating.quality.hold). Many2one('fp.job')
# on fp.rma is resolved by the registry once jobs loads after us.
'mail',
],
'data': [
@@ -76,6 +80,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_rma_sequence.xml',
'data/fp_quality_categorisation_data.xml',
'data/fp_qc_data.xml',
'views/fp_qc_template_views.xml',
'views/fp_quality_hold_views.xml',
@@ -93,6 +99,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_contract_review_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_quality_check_views.xml',
'views/fp_rma_views.xml',
'views/fp_quality_categorisation_views.xml',
'views/fp_quality_point_views.xml',
'views/fp_quality_smart_button_views.xml',
'views/fp_quality_dashboard_views.xml',
'reports/fp_contract_review_report.xml',
'reports/fp_contract_review_template.xml',
'views/fp_menu.xml',
@@ -107,6 +118,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'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',
# Sub 12 Phase D — Unified Quality Dashboard.
'fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss',
'fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml',
'fusion_plating_quality/static/src/js/fp_quality_dashboard.js',
],
},
'installable': True,

View File

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

View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase D — counts endpoint for the Unified Quality Dashboard.
from odoo import fields, http
from odoo.http import request
class FpQualityDashboardController(http.Controller):
@http.route('/fp/quality/dashboard/counts',
type='jsonrpc', auth='user', methods=['POST'])
def counts(self):
"""Return per-tab open + overdue counts for the dashboard.
"Overdue" definition:
- Hold: state='on_hold' for > 3 days
- Check: state='pending' for > 1 day
- NCR: state in (open, containment, disposition) AND reported >7d
- CAPA: due_date < today AND state not in (effective, closed)
- RMA: state='received' for > 5 days (triage past due) OR
state in (authorised, shipped_to_us) for > 14 days
"""
env = request.env
today = fields.Date.context_today(env.user)
now = fields.Datetime.now()
Hold = env['fusion.plating.quality.hold']
Check = env['fusion.plating.quality.check']
Ncr = env['fusion.plating.ncr']
Capa = env['fusion.plating.capa']
Rma = env['fusion.plating.rma']
d3 = fields.Datetime.subtract(now, days=3)
d1 = fields.Datetime.subtract(now, days=1)
d7 = fields.Datetime.subtract(now, days=7)
d5 = fields.Datetime.subtract(now, days=5)
d14 = fields.Datetime.subtract(now, days=14)
return {
'holds': {
'open': Hold.search_count(
[('state', 'in', ('on_hold', 'under_review'))]),
'overdue': Hold.search_count([
('state', 'in', ('on_hold', 'under_review')),
('create_date', '<', d3),
]),
},
'checks': {
'open': Check.search_count([('state', '=', 'pending')]),
'overdue': Check.search_count([
('state', '=', 'pending'),
('create_date', '<', d1),
]),
},
'ncrs': {
'open': Ncr.search_count([
('state', 'in', ('open', 'containment', 'disposition')),
]),
'overdue': Ncr.search_count([
('state', 'in', ('open', 'containment', 'disposition')),
('reported_date', '<', d7),
]),
},
'capas': {
'open': Capa.search_count([
('state', 'not in', ('effective', 'closed')),
]),
'overdue': Capa.search_count([
('state', 'not in', ('effective', 'closed')),
('due_date', '<', today),
('due_date', '!=', False),
]),
},
'rmas': {
'open': Rma.search_count([
('state', 'not in', ('closed', 'cancelled')),
]),
'overdue': Rma.search_count([
'|',
'&', ('state', '=', 'received'),
('create_date', '<', d5),
'&', ('state', 'in', ('authorised', 'shipped_to_us')),
('create_date', '<', d14),
]),
},
}

View File

@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase B — seed data for the kanban stage namespace + a small
starter set of tags, reasons, and one default quality team. All are
`noupdate=1` so a customer's edits survive module upgrades.
-->
<odoo noupdate="1">
<!-- ============================================ STAGES ===== -->
<record id="stage_new" model="fp.quality.alert.stage">
<field name="name">New</field>
<field name="code">new</field>
<field name="sequence">10</field>
</record>
<record id="stage_investigating" model="fp.quality.alert.stage">
<field name="name">Investigating</field>
<field name="code">investigating</field>
<field name="sequence">20</field>
</record>
<record id="stage_containment" model="fp.quality.alert.stage">
<field name="name">Containment</field>
<field name="code">containment</field>
<field name="sequence">30</field>
</record>
<record id="stage_disposition" model="fp.quality.alert.stage">
<field name="name">Disposition</field>
<field name="code">disposition</field>
<field name="sequence">40</field>
</record>
<record id="stage_awaiting_signoff" model="fp.quality.alert.stage">
<field name="name">Awaiting Sign-off</field>
<field name="code">awaiting_signoff</field>
<field name="sequence">50</field>
</record>
<record id="stage_closed" model="fp.quality.alert.stage">
<field name="name">Closed</field>
<field name="code">closed</field>
<field name="sequence">60</field>
<field name="fold" eval="True"/>
</record>
<record id="stage_cancelled" model="fp.quality.alert.stage">
<field name="name">Cancelled</field>
<field name="code">cancelled</field>
<field name="sequence">70</field>
<field name="fold" eval="True"/>
</record>
<!-- ============================================== TAGS ===== -->
<record id="tag_customer_complaint" model="fp.quality.tag">
<field name="name">Customer Complaint</field>
<field name="color">2</field>
</record>
<record id="tag_thickness" model="fp.quality.tag">
<field name="name">Thickness</field>
<field name="color">3</field>
</record>
<record id="tag_appearance" model="fp.quality.tag">
<field name="name">Appearance</field>
<field name="color">4</field>
</record>
<record id="tag_adhesion" model="fp.quality.tag">
<field name="name">Adhesion</field>
<field name="color">5</field>
</record>
<record id="tag_corrosion" model="fp.quality.tag">
<field name="name">Corrosion</field>
<field name="color">1</field>
</record>
<record id="tag_repeat_offender" model="fp.quality.tag">
<field name="name">Repeat Offender</field>
<field name="color">1</field>
<field name="description">Same customer + part has had &gt; 2 issues in 90 days.</field>
</record>
<record id="tag_audit_finding" model="fp.quality.tag">
<field name="name">Audit Finding</field>
<field name="color">6</field>
</record>
<record id="tag_first_off" model="fp.quality.tag">
<field name="name">First-Off Inspection</field>
<field name="color">7</field>
</record>
<!-- ========================================== REASONS ===== -->
<record id="reason_chemistry_drift" model="fp.quality.reason">
<field name="name">Bath Chemistry Drift</field>
<field name="category">process</field>
<field name="description">Concentration, pH, or temperature outside spec window.</field>
</record>
<record id="reason_contamination" model="fp.quality.reason">
<field name="name">Bath Contamination</field>
<field name="category">process</field>
</record>
<record id="reason_temperature" model="fp.quality.reason">
<field name="name">Temperature Excursion</field>
<field name="category">process</field>
</record>
<record id="reason_supplier_inbound" model="fp.quality.reason">
<field name="name">Inbound Material Defect</field>
<field name="category">supplier</field>
</record>
<record id="reason_calibration" model="fp.quality.reason">
<field name="name">Out-of-Calibration Equipment</field>
<field name="category">equipment</field>
</record>
<record id="reason_rectifier" model="fp.quality.reason">
<field name="name">Rectifier / Power Supply Issue</field>
<field name="category">equipment</field>
</record>
<record id="reason_misload" model="fp.quality.reason">
<field name="name">Mis-load / Mis-rack</field>
<field name="category">human</field>
</record>
<record id="reason_training_gap" model="fp.quality.reason">
<field name="name">Training Gap</field>
<field name="category">human</field>
</record>
<record id="reason_recipe_violation" model="fp.quality.reason">
<field name="name">Recipe Step Skipped</field>
<field name="category">human</field>
</record>
<record id="reason_part_defect" model="fp.quality.reason">
<field name="name">Customer Part Defect</field>
<field name="category">material</field>
</record>
<!-- ============================================ TEAMS ===== -->
<record id="team_default_qa" model="fp.quality.team">
<field name="name">Quality Assurance</field>
<field name="sequence">10</field>
<field name="description">Default quality team. Assign every new NCR/RMA here unless the issue clearly belongs to a process-specific team.</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?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 noupdate="1">
<record id="seq_fp_rma" model="ir.sequence">
<field name="name">Fusion Plating: RMA</field>
<field name="code">fusion.plating.rma</field>
<field name="prefix">RMA/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -23,3 +23,23 @@ from . import fp_part_catalog
from . import fp_qc_template
from . import fp_thickness_reading
from . import fp_quality_check
# Sub 12 Phase A — native RMA + the inverse fields it hangs off existing
# quality and receiving models.
from . import fp_rma
from . import fp_rma_links
# Sub 12 Phase B — categorisation primitives + cross-model link fields.
from . import fp_quality_tag
from . import fp_quality_reason
from . import fp_quality_team
from . import fp_quality_alert_stage
from . import fp_quality_categorisation_links
# Sub 12 Phase C — trigger-based quality points.
from . import fp_quality_point
from . import fp_quality_point_hooks
# Sub 12 Phase D — smart-button counts + cross-creation actions.
from . import fp_quality_smart_buttons
from . import fp_quality_cross_creation

View File

@@ -44,28 +44,29 @@ class FpPartCatalog(models.Model):
# ---- Computes ------------------------------------------------------------
def _compute_has_confirmed_mo(self):
"""True if this part is referenced by at least one non-draft MO.
"""True if this part is referenced by at least one live fp.job.
Trace: fp.part.catalog → sale.order.line (x_fc_part_catalog_id)
→ sale.order → mrp.production (via origin name match).
Cheap: two bounded search_counts. Kept store=False so MO state
changes don't write-amplify through every part record.
Sub 11 — replaced mrp.production lookup with fp.job. Trace:
fp.part.catalog → sale.order.line (x_fc_part_catalog_id) →
sale.order → fp.job (via origin name match).
"""
SO = self.env['sale.order']
MO = self.env['mrp.production']
live_states = ('confirmed', 'progress', 'to_close', 'done')
live_states = ('confirmed', 'in_progress', 'on_hold', 'done')
for part in self:
part.x_fc_has_confirmed_mo = False
if 'fp.job' not in self.env:
return
Job = self.env['fp.job']
for part in self:
if not part.id:
part.x_fc_has_confirmed_mo = False
continue
so_names = SO.search([
('order_line.x_fc_part_catalog_id', '=', part.id),
('state', 'in', ('sale', 'done')),
]).mapped('name')
if not so_names:
part.x_fc_has_confirmed_mo = False
continue
part.x_fc_has_confirmed_mo = bool(MO.search_count([
part.x_fc_has_confirmed_mo = bool(Job.search_count([
('origin', 'in', so_names),
('state', 'in', live_states),
]))

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality alert stage.
#
# Shared kanban-stage namespace used by both NCR and RMA. Each model has
# its own state Selection (state machine guards) AND a stage_id Many2one
# (kanban-draggable). The two stay in sync — see fp_quality_categorisation_links.
from odoo import fields, models
class FpQualityAlertStage(models.Model):
_name = 'fp.quality.alert.stage'
_description = 'Fusion Plating — Quality Alert Stage'
_order = 'sequence, id'
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=10, index=True)
fold = fields.Boolean(
string='Fold by Default',
help='If checked the stage is collapsed by default in kanban views.',
)
code = fields.Char(
string='Code',
index=True,
help='Stable machine identifier used by the state ↔ stage_id sync. '
'Examples: new / investigating / containment / disposition / '
'awaiting_signoff / closed / cancelled.',
)
description = fields.Text(translate=True)
active = fields.Boolean(default=True)
_sql_constraints = [
('code_uniq', 'unique(code)', 'A stage with that code already exists.'),
]

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — categorisation field extensions.
#
# Adds the cross-cutting tag_ids / reason_id / team_id fields to all five
# quality records (NCR, CAPA, Hold, Check, RMA). Adds stage_id (kanban
# stage) to NCR + RMA with state ↔ stage_id sync.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# ----- helper mapping (keeps stage codes consistent across models) -----
NCR_STATE_TO_STAGE_CODE = {
'draft': 'new',
'open': 'investigating',
'containment': 'containment',
'disposition': 'disposition',
'closed': 'closed',
}
NCR_STAGE_CODE_TO_STATE = {v: k for k, v in NCR_STATE_TO_STAGE_CODE.items()}
RMA_STATE_TO_STAGE_CODE = {
'draft': 'new',
'authorised': 'investigating',
'shipped_to_us': 'investigating',
'received': 'containment',
'triaged': 'disposition',
'resolving': 'disposition',
'resolved': 'awaiting_signoff',
'closed': 'closed',
'cancelled': 'cancelled',
}
def _stage_for_code(env, code):
if not code:
return env['fp.quality.alert.stage']
return env['fp.quality.alert.stage'].sudo().search(
[('code', '=', code)], limit=1,
)
# ============================================================ NCR ===
class FpNcrCategorisation(models.Model):
_inherit = 'fusion.plating.ncr'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_ncr_tag_rel', 'ncr_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team',
tracking=True)
stage_id = fields.Many2one(
'fp.quality.alert.stage', string='Stage',
compute='_compute_stage_id', inverse='_inverse_stage_id',
store=True, tracking=True, group_expand='_read_group_stage_ids',
)
@api.depends('state')
def _compute_stage_id(self):
for rec in self:
code = NCR_STATE_TO_STAGE_CODE.get(rec.state)
rec.stage_id = _stage_for_code(self.env, code) if code else False
def _inverse_stage_id(self):
for rec in self:
if not rec.stage_id or not rec.stage_id.code:
continue
new_state = NCR_STAGE_CODE_TO_STATE.get(rec.stage_id.code)
if new_state and new_state != rec.state:
# Use direct write to avoid the action_close UserError
# guards — kanban drag is an explicit user intent.
super(FpNcrCategorisation, rec).write({'state': new_state})
@api.model
def _read_group_stage_ids(self, stages, domain):
return self.env['fp.quality.alert.stage'].sudo().search([])
# ============================================================ CAPA ===
class FpCapaCategorisation(models.Model):
_inherit = 'fusion.plating.capa'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_capa_tag_rel', 'capa_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
# ============================================================ HOLD ===
class FpQualityHoldCategorisation(models.Model):
_inherit = 'fusion.plating.quality.hold'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_hold_tag_rel', 'hold_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
# =========================================================== CHECK ===
class FpQualityCheckCategorisation(models.Model):
_inherit = 'fusion.plating.quality.check'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_check_tag_rel', 'check_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Failure Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
# ============================================================ RMA ===
class FpRmaCategorisation(models.Model):
_inherit = 'fusion.plating.rma'
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_rma_tag_rel', 'rma_id', 'tag_id',
string='Tags',
)
reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
team_id = fields.Many2one('fp.quality.team', string='Quality Team',
tracking=True)
stage_id = fields.Many2one(
'fp.quality.alert.stage', string='Stage',
compute='_compute_stage_id', store=True, tracking=True,
group_expand='_read_group_stage_ids',
help='Computed from state. RMA state machine has guards (use the '
'lifecycle buttons for valid transitions); the stage field is '
'read-mostly here so the unified Quality Dashboard can group '
'NCR + RMA cards in one kanban.',
)
@api.depends('state')
def _compute_stage_id(self):
for rec in self:
code = RMA_STATE_TO_STAGE_CODE.get(rec.state)
rec.stage_id = _stage_for_code(self.env, code) if code else False
@api.model
def _read_group_stage_ids(self, stages, domain):
return self.env['fp.quality.alert.stage'].sudo().search([])

View File

@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase D — cross-creation actions and CAPA closure-loop linkage.
#
# - NCR.action_spawn_capa: creates a draft CAPA pre-filled from the NCR.
# - CAPA.action_mark_not_effective override: auto-creates a follow-up NCR
# linked back to the original NCR. Closes the loop "we said we fixed it
# but it happened again."
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpNcrCrossCreation(models.Model):
_inherit = 'fusion.plating.ncr'
def action_spawn_capa(self):
"""Create a draft CAPA pre-filled from this NCR. Visible from form
when state ∈ {disposition, closed} and severity ≥ medium (gating
lives in the view; this method is a helper)."""
self.ensure_one()
Capa = self.env['fusion.plating.capa']
existing = Capa.search([('ncr_id', '=', self.id)], limit=1)
if existing:
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'form',
'res_id': existing.id,
}
capa = Capa.create({
'ncr_id': self.id,
'description': self.description,
'type': 'corrective',
'state': 'draft',
'team_id': self.team_id.id if self.team_id else False,
'reason_id': self.reason_id.id if self.reason_id else False,
})
self.message_post(
body=Markup('Spawned CAPA <b>%s</b> from this NCR.') % capa.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'form',
'res_id': capa.id,
}
class FpCapaCrossCreation(models.Model):
_inherit = 'fusion.plating.capa'
follow_up_ncr_id = fields.Many2one(
'fusion.plating.ncr', string='Follow-up NCR',
ondelete='set null',
help='When effectiveness verification fails, a new NCR is auto-spawned '
'linked back to the original. This field tracks that follow-up.',
)
def action_mark_not_effective(self):
"""Override to auto-spawn a follow-up NCR linked to the original.
Closes the closed-loop CAPA discipline: if a fix didn't work, the
next NCR gets a clear lineage back to the failed CAPA, so root-
cause analysis can dig deeper next time.
"""
super().action_mark_not_effective()
Ncr = self.env['fusion.plating.ncr']
for rec in self:
if rec.follow_up_ncr_id:
continue
if not rec.ncr_id:
_logger.info(
'CAPA %s marked not_effective but has no source NCR; '
'no follow-up NCR created.', rec.name,
)
continue
src = rec.ncr_id
ncr = Ncr.create({
'facility_id': src.facility_id.id,
'source': src.source,
'severity': src.severity,
'part_ref': src.part_ref,
'quantity_affected': src.quantity_affected,
'customer_partner_id': src.customer_partner_id.id,
'bath_id': src.bath_id.id if src.bath_id else False,
'description': Markup(
'<p><strong>Follow-up NCR auto-created from CAPA %s '
'(verification failed).</strong></p>'
) % rec.name,
'team_id': rec.team_id.id if rec.team_id else False,
'reason_id': rec.reason_id.id if rec.reason_id else False,
'tag_ids': [(6, 0, src.tag_ids.ids)],
})
rec.follow_up_ncr_id = ncr.id
rec.message_post(
body=Markup(
'Effectiveness verification failed. Spawned follow-up '
'<b>NCR %s</b> for re-investigation.'
) % ncr.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
ncr.message_post(
body=Markup(
'Auto-created from <b>CAPA %s</b> after effectiveness '
'verification failed. Original NCR was %s.'
) % (rec.name, src.name),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
def action_verify_effectiveness(self):
"""Schedule a follow-up activity on the originating NCR.
Used from the CAPA form to remind the QA team to come back and
confirm the corrective action actually held.
"""
from datetime import timedelta
self.ensure_one()
if not self.ncr_id:
raise UserError(_(
'CAPA %s has no source NCR — verification activity '
'cannot be scheduled.'
) % self.display_name)
deadline = fields.Date.context_today(self) + timedelta(days=30)
self.ncr_id.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=deadline,
summary=_('Verify CAPA %s effectiveness') % self.name,
note=_(
'Confirm that the corrective action from CAPA %s is still '
'holding. If issue recurs, mark CAPA as Not Effective '
'(auto-spawns a follow-up NCR).'
) % self.name,
user_id=(self.owner_id.id if self.owner_id else self.env.user.id),
)
self.message_post(
body=Markup(
'Verification activity scheduled on source <b>NCR %s</b> '
'(due %s).'
) % (self.ncr_id.name, deadline),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
return True

View File

@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase C — trigger-based quality points.
#
# Replaces the old "customer.x_fc_requires_qc + customer.x_fc_qc_template_id"
# direct binding. Now an admin defines fp.quality.point rules with filters
# (partner / part / coating / step kind) and a trigger event; matching
# records spawn fusion.plating.quality.check rows from the chosen template.
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
TRIGGER_TYPES = [
('manual', 'Manual'),
('so_confirmed', 'Sale Order Confirmed'),
('receiving_done', 'Receiving Closed'),
('job_confirmed', 'Job Confirmed'),
('job_step_done', 'Job Step Finished'),
('job_done', 'Job Completed'),
]
STEP_KINDS = [
('wet', 'Wet Process'),
('bake', 'Bake / Cure'),
('inspect', 'Inspection'),
('mask', 'Masking'),
('post', 'Post-Treatment'),
('other', 'Other'),
]
class FpQualityPoint(models.Model):
_name = 'fp.quality.point'
_description = 'Fusion Plating — Quality Point'
_inherit = ['mail.thread']
_order = 'sequence, name'
name = fields.Char(required=True, translate=True, tracking=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True, tracking=True)
description = fields.Text(translate=True)
trigger_type = fields.Selection(
TRIGGER_TYPES, string='Trigger', required=True,
default='job_confirmed', tracking=True,
help='When this point fires. "manual" never auto-fires.',
)
# ----- Filters (all optional; empty == match all) -----
partner_ids = fields.Many2many(
'res.partner', 'fp_quality_point_partner_rel',
'point_id', 'partner_id', string='Customers',
)
part_catalog_ids = fields.Many2many(
'fp.part.catalog', 'fp_quality_point_part_rel',
'point_id', 'part_id', string='Parts',
)
coating_config_ids = fields.Many2many(
'fp.coating.config', 'fp_quality_point_coating_rel',
'point_id', 'coating_id', string='Coatings',
)
step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
template_id = fields.Many2one(
'fp.qc.checklist.template', string='Checklist Template',
required=True, ondelete='restrict',
)
assignee_user_id = fields.Many2one(
'res.users', string='Default Inspector',
help='If set, the auto-spawned QC check is pre-assigned here.',
)
team_id = fields.Many2one('fp.quality.team', string='Quality Team')
tag_ids = fields.Many2many(
'fp.quality.tag', 'fp_quality_point_tag_rel',
'point_id', 'tag_id', string='Tags',
)
# Stats
spawn_count = fields.Integer(
string='Checks Spawned', compute='_compute_spawn_count',
)
@api.depends('template_id')
def _compute_spawn_count(self):
Check = self.env['fusion.plating.quality.check']
for rec in self:
if not rec.template_id:
rec.spawn_count = 0
continue
rec.spawn_count = Check.search_count([
('template_id', '=', rec.template_id.id),
])
# ------------------------------------------------------------------
# Matching + spawning
# ------------------------------------------------------------------
def _matches(self, partner=None, part=None, coating=None, step=None):
"""Return True if this point's filters all pass against the supplied
context. Empty filter == match anything.
"""
self.ensure_one()
if self.partner_ids and (not partner or partner not in self.partner_ids):
return False
if self.part_catalog_ids and (
not part or part not in self.part_catalog_ids):
return False
if self.coating_config_ids and (
not coating or coating not in self.coating_config_ids):
return False
if self.step_kind and step and getattr(step, 'kind', None) \
and step.kind != self.step_kind:
return False
return True
@api.model
def _find_matching(self, trigger, partner=None, part=None, coating=None,
step=None):
"""Return active points whose trigger + filters match the context."""
candidates = self.search([
('active', '=', True),
('trigger_type', '=', trigger),
])
return candidates.filtered(lambda p: p._matches(
partner=partner, part=part, coating=coating, step=step,
))
def _spawn_check_for(self, source, partner=None, job=None, step=None):
"""Create a fusion.plating.quality.check from this point's template.
Idempotent per (point, source): if a check already exists with the
same template_id and the same job/step binding, no new one is
created (returns the existing one).
"""
self.ensure_one()
Check = self.env['fusion.plating.quality.check']
if not self.template_id:
_logger.warning(
'fp.quality.point %s: no template_id set, skipping spawn.',
self.name,
)
return False
domain = [('template_id', '=', self.template_id.id)]
if job:
domain.append(('job_id', '=', job.id))
if step and 'step_id' in Check._fields:
domain.append(('step_id', '=', step.id))
existing = Check.search(domain, limit=1)
if existing:
return existing
vals = {
'template_id': self.template_id.id,
}
# Best-effort field bindings — survives schema variations.
if 'partner_id' in Check._fields and partner:
vals['partner_id'] = partner.id
if 'job_id' in Check._fields and job:
vals['job_id'] = job.id
if 'step_id' in Check._fields and step:
vals['step_id'] = step.id
if 'state' in Check._fields:
vals['state'] = 'pending'
if 'inspector_id' in Check._fields and self.assignee_user_id:
vals['inspector_id'] = self.assignee_user_id.id
if 'team_id' in Check._fields and self.team_id:
vals['team_id'] = self.team_id.id
if 'tag_ids' in Check._fields and self.tag_ids:
vals['tag_ids'] = [(6, 0, self.tag_ids.ids)]
try:
return Check.create(vals)
except Exception as e:
_logger.warning(
'fp.quality.point %s: spawn failed for %s%s',
self.name, source.display_name if source else '?', e,
)
return False
def action_spawn_manual(self):
"""Manual fire — present from the form view button. No source ctx."""
for rec in self:
rec._spawn_check_for(source=rec)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Quality Point fired'),
'message': _('Spawned %s check(s).') % len(self),
'type': 'success',
},
}

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase C — trigger-hook overrides on receiving / job / step / SO.
# Each hook walks fp.quality.point with the matching trigger_type and
# spawns a quality check for every match. Best-effort: failures are
# logged but never block the underlying state transition.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# ============================================================ RECEIVING ===
class FpReceivingPointHook(models.Model):
_inherit = 'fp.receiving'
def write(self, vals):
"""When state flips to closed, fire receiving_done points."""
prev_states = {rec.id: rec.state for rec in self}
result = super().write(vals)
if 'state' not in vals or vals.get('state') != 'closed':
return result
Point = self.env['fp.quality.point']
for rec in self:
if prev_states.get(rec.id) == 'closed':
continue
partner = rec.partner_id
points = Point._find_matching(
trigger='receiving_done', partner=partner,
)
for point in points:
point._spawn_check_for(
source=rec, partner=partner,
)
return result
# ================================================================== SO ===
class SaleOrderPointHook(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
result = super().action_confirm()
Point = self.env['fp.quality.point']
for so in self:
partner = so.partner_id
# Walk lines for part / coating context.
parts = so.order_line.mapped('x_fc_part_catalog_id') \
if 'x_fc_part_catalog_id' in so.order_line._fields else False
coatings = so.order_line.mapped('x_fc_coating_config_id') \
if 'x_fc_coating_config_id' in so.order_line._fields else False
points = Point._find_matching(
trigger='so_confirmed', partner=partner,
)
for point in points:
# Filter by part / coating intersection if the point cares.
if point.part_catalog_ids and parts and \
not (point.part_catalog_ids & parts):
continue
if point.coating_config_ids and coatings and \
not (point.coating_config_ids & coatings):
continue
point._spawn_check_for(source=so, partner=partner)
return result
# ================================================================ JOB ===
class FpJobPointHook(models.Model):
_inherit = 'fp.job'
def action_confirm(self):
result = super().action_confirm()
Point = self.env['fp.quality.point']
for job in self:
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
points = Point._find_matching(
trigger='job_confirmed', partner=partner,
part=part or None, coating=coating or None,
)
for point in points:
point._spawn_check_for(
source=job, partner=partner, job=job,
)
return result
def button_mark_done(self):
result = super().button_mark_done()
Point = self.env['fp.quality.point']
for job in self:
if job.state != 'done':
continue
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
points = Point._find_matching(
trigger='job_done', partner=partner,
part=part or None, coating=coating or None,
)
for point in points:
point._spawn_check_for(
source=job, partner=partner, job=job,
)
return result
# =========================================================== JOB STEP ===
class FpJobStepPointHook(models.Model):
_inherit = 'fp.job.step'
def button_finish(self):
result = super().button_finish()
Point = self.env['fp.quality.point']
for step in self:
if step.state != 'done':
continue
job = step.job_id
partner = job.partner_id if job else False
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
points = Point._find_matching(
trigger='job_step_done', partner=partner,
part=part or None, coating=coating or None, step=step,
)
for point in points:
point._spawn_check_for(
source=step, partner=partner, job=job, step=step,
)
return result

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality reason (root-cause classification library).
from odoo import fields, models
class FpQualityReason(models.Model):
_name = 'fp.quality.reason'
_description = 'Fusion Plating — Quality Reason'
_order = 'category, name'
name = fields.Char(required=True, translate=True)
description = fields.Text(translate=True)
category = fields.Selection(
[
('process', 'Process'),
('supplier', 'Supplier / Material Inbound'),
('equipment', 'Equipment / Calibration'),
('human', 'Human Error / Training'),
('material', 'Material Defect'),
('other', 'Other'),
],
string='Category',
default='process',
required=True,
)
active = fields.Boolean(default=True)
_sql_constraints = [
('name_category_uniq', 'unique(name, category)',
'A reason with that name + category combination already exists.'),
]

View File

@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase D — smart-button counts on fp.job, sale.order, res.partner.
#
# Each parent record gets badge counts for: Holds, Checks, NCRs, CAPAs,
# RMAs. Counts always render (zero is acceptable). Action methods open
# the relevant kanban filtered to that record.
from odoo import _, api, fields, models
# ============================================================ FP.JOB ===
class FpJobQualitySmart(models.Model):
_inherit = 'fp.job'
fp_qc_hold_count = fields.Integer(
compute='_compute_fp_quality_counts', string='Holds',
)
fp_qc_check_count = fields.Integer(
compute='_compute_fp_quality_counts', string='Checks',
)
fp_qc_ncr_count = fields.Integer(
compute='_compute_fp_quality_counts', string='NCRs',
)
fp_qc_capa_count = fields.Integer(
compute='_compute_fp_quality_counts', string='CAPAs',
)
fp_qc_rma_count = fields.Integer(
compute='_compute_fp_quality_counts', string='RMAs',
)
def _compute_fp_quality_counts(self):
Hold = self.env['fusion.plating.quality.hold']
Check = self.env['fusion.plating.quality.check']
Ncr = self.env['fusion.plating.ncr']
Capa = self.env['fusion.plating.capa']
Rma = self.env['fusion.plating.rma']
for job in self:
job.fp_qc_hold_count = Hold.search_count(
[('job_id', '=', job.id)])
job.fp_qc_check_count = Check.search_count(
[('job_id', '=', job.id)])
ncr_ids = []
capa_ids = []
rma_ids = []
if job.sale_order_id:
rma_ids = Rma.search(
[('sale_order_id', '=', job.sale_order_id.id)]).ids
if rma_ids:
ncr_ids = Ncr.search([('rma_id', 'in', rma_ids)]).ids
if job.partner_id:
ncr_ids = list(set(ncr_ids + Ncr.search([
('customer_partner_id', '=', job.partner_id.id),
]).ids))
if ncr_ids:
capa_ids = Capa.search([('ncr_id', 'in', ncr_ids)]).ids
job.fp_qc_ncr_count = len(ncr_ids)
job.fp_qc_capa_count = len(capa_ids)
job.fp_qc_rma_count = len(rma_ids)
def action_view_fp_holds(self):
self.ensure_one()
return {
'name': _('Holds'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('job_id', '=', self.id)],
'context': {'default_job_id': self.id},
}
def action_view_fp_checks(self):
self.ensure_one()
return {
'name': _('Quality Checks'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.check',
'view_mode': 'list,form',
'domain': [('job_id', '=', self.id)],
'context': {'default_job_id': self.id},
}
def action_view_fp_ncrs(self):
self.ensure_one()
domain = [('customer_partner_id', '=', self.partner_id.id)]
return {
'name': _('NCRs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.ncr',
'view_mode': 'kanban,list,form',
'domain': domain,
'context': {'default_customer_partner_id': self.partner_id.id},
}
def action_view_fp_capas(self):
self.ensure_one()
Ncr = self.env['fusion.plating.ncr']
ncr_ids = Ncr.search([
('customer_partner_id', '=', self.partner_id.id),
]).ids
return {
'name': _('CAPAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'list,form',
'domain': [('ncr_id', 'in', ncr_ids)],
}
def action_view_fp_rmas(self):
self.ensure_one()
domain = [('partner_id', '=', self.partner_id.id)]
if self.sale_order_id:
domain = ['|', ('sale_order_id', '=', self.sale_order_id.id),
('partner_id', '=', self.partner_id.id)]
return {
'name': _('RMAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.rma',
'view_mode': 'kanban,list,form',
'domain': domain,
'context': {'default_partner_id': self.partner_id.id},
}
# ============================================================== SO ===
class SaleOrderQualitySmart(models.Model):
_inherit = 'sale.order'
fp_qc_hold_count = fields.Integer(
compute='_compute_fp_qc_counts', string='Holds',
)
fp_qc_check_count = fields.Integer(
compute='_compute_fp_qc_counts', string='Checks',
)
fp_qc_ncr_count_so = fields.Integer(
compute='_compute_fp_qc_counts', string='NCRs',
)
fp_qc_capa_count = fields.Integer(
compute='_compute_fp_qc_counts', string='CAPAs',
)
fp_qc_rma_count = fields.Integer(
compute='_compute_fp_qc_counts', string='RMAs',
)
def _compute_fp_qc_counts(self):
Hold = self.env['fusion.plating.quality.hold']
Check = self.env['fusion.plating.quality.check']
Ncr = self.env['fusion.plating.ncr']
Capa = self.env['fusion.plating.capa']
Rma = self.env['fusion.plating.rma']
Job = self.env['fp.job']
for so in self:
job_ids = Job.search([('sale_order_id', '=', so.id)]).ids
so.fp_qc_hold_count = Hold.search_count(
[('job_id', 'in', job_ids)]) if job_ids else 0
so.fp_qc_check_count = Check.search_count(
[('job_id', 'in', job_ids)]) if job_ids else 0
rma_ids = Rma.search([('sale_order_id', '=', so.id)]).ids
so.fp_qc_rma_count = len(rma_ids)
ncr_ids = []
if rma_ids:
ncr_ids = Ncr.search([('rma_id', 'in', rma_ids)]).ids
if so.partner_id:
ncr_ids = list(set(ncr_ids + Ncr.search([
('customer_partner_id', '=', so.partner_id.id),
]).ids))
so.fp_qc_ncr_count_so = len(ncr_ids)
so.fp_qc_capa_count = Capa.search_count(
[('ncr_id', 'in', ncr_ids)]) if ncr_ids else 0
def action_view_fp_holds(self):
self.ensure_one()
Job = self.env['fp.job']
job_ids = Job.search([('sale_order_id', '=', self.id)]).ids
return {
'name': _('Holds'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('job_id', 'in', job_ids)],
}
def action_view_fp_checks(self):
self.ensure_one()
Job = self.env['fp.job']
job_ids = Job.search([('sale_order_id', '=', self.id)]).ids
return {
'name': _('Quality Checks'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.check',
'view_mode': 'list,form',
'domain': [('job_id', 'in', job_ids)],
}
def action_view_fp_ncrs_so(self):
self.ensure_one()
return {
'name': _('NCRs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.ncr',
'view_mode': 'kanban,list,form',
'domain': [('customer_partner_id', '=', self.partner_id.id)],
}
def action_view_fp_capas(self):
self.ensure_one()
Ncr = self.env['fusion.plating.ncr']
ncr_ids = Ncr.search([
('customer_partner_id', '=', self.partner_id.id),
]).ids
return {
'name': _('CAPAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'list,form',
'domain': [('ncr_id', 'in', ncr_ids)],
}
def action_view_fp_rmas(self):
self.ensure_one()
return {
'name': _('RMAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.rma',
'view_mode': 'kanban,list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {
'default_partner_id': self.partner_id.id,
'default_sale_order_id': self.id,
},
}
# ====================================================== RES.PARTNER ===
class ResPartnerQualitySmart(models.Model):
_inherit = 'res.partner'
fp_qc_quality_history_count = fields.Integer(
compute='_compute_fp_qc_history_count', string='Quality History',
)
def _compute_fp_qc_history_count(self):
Ncr = self.env['fusion.plating.ncr']
Rma = self.env['fusion.plating.rma']
for partner in self:
partner.fp_qc_quality_history_count = (
Ncr.search_count([('customer_partner_id', '=', partner.id)])
+ Rma.search_count([('partner_id', '=', partner.id)])
)
def action_view_fp_quality_history(self):
self.ensure_one()
return {
'name': _('Quality History — %s') % self.display_name,
'type': 'ir.actions.client',
'tag': 'fp_quality_dashboard',
'context': {'default_partner_id': self.id},
}

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality tag.
#
# Cross-cutting tag library reused by NCR, CAPA, Hold, Check, RMA.
from odoo import fields, models
class FpQualityTag(models.Model):
_name = 'fp.quality.tag'
_description = 'Fusion Plating — Quality Tag'
_order = 'name'
name = fields.Char(required=True, translate=True)
color = fields.Integer(string='Colour Index', default=0)
description = fields.Char(string='Description', translate=True)
active = fields.Boolean(default=True)
_sql_constraints = [
('name_uniq', 'unique(name)', 'A tag with that name already exists.'),
]

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase B — quality team.
#
# Dedicated team model rather than reusing res.groups, per Sub 12 locked
# decision: teams need their own kanban grouping + per-team escalation
# chains.
from odoo import fields, models
class FpQualityTeam(models.Model):
_name = 'fp.quality.team'
_description = 'Fusion Plating — Quality Team'
_order = 'sequence, name'
_inherit = ['mail.thread']
name = fields.Char(required=True, tracking=True, translate=True)
sequence = fields.Integer(default=10)
color = fields.Integer(string='Colour Index', default=0)
description = fields.Text(translate=True)
lead_user_id = fields.Many2one(
'res.users', string='Team Lead',
tracking=True,
help='Owns escalations and weekly review of open NCRs/RMAs.',
)
member_ids = fields.Many2many(
'res.users', 'fp_quality_team_user_rel', 'team_id', 'user_id',
string='Members',
)
escalation_user_id = fields.Many2one(
'res.users', string='Escalation Manager',
tracking=True,
help='Notified when team owns a record that misses its deadline.',
)
active = fields.Boolean(default=True)
_sql_constraints = [
('name_uniq', 'unique(name)', 'A team with that name already exists.'),
]

View File

@@ -0,0 +1,775 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# fp.rma — Return Material Authorisation.
#
# Sub 12 Phase A. Internal-only RMA workflow that ties customer returns to
# the existing NCR / CAPA / Hold stack. Portal submission is deferred to a
# future sub-project; for now an internal user opens the RMA on behalf of
# the customer.
#
# Lifecycle:
# draft -> authorised -> shipped_to_us -> received -> triaged ->
# resolving -> resolved -> closed
# \
# -> cancelled (manager only, any state)
#
# Auto-spawn rules at the `received` transition (driven by fp.receiving):
# - if auto_spawn_ncr (default True) -> create fusion.plating.ncr
# - if auto_spawn_hold (default True) -> create fusion.plating.quality.hold
# A manager can flip either toggle off before saving the RMA.
import base64
import io
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpRma(models.Model):
_name = 'fusion.plating.rma'
_description = 'Fusion Plating — Return Material Authorisation'
_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: self._default_name(),
tracking=True,
)
state = fields.Selection(
[
('draft', 'Draft'),
('authorised', 'Authorised'),
('shipped_to_us', 'Customer Shipped'),
('received', 'Received at Shop'),
('triaged', 'Triaged'),
('resolving', 'Resolving'),
('resolved', 'Resolved'),
('closed', 'Closed'),
('cancelled', 'Cancelled'),
],
string='Status',
default='draft',
required=True,
tracking=True,
index=True,
)
# ------------------------------------------------------------------
# Customer + originating order
# ------------------------------------------------------------------
partner_id = fields.Many2one(
'res.partner', string='Customer',
required=True, tracking=True,
domain=[('customer_rank', '>', 0)],
)
sale_order_id = fields.Many2one(
'sale.order', string='Original Sale Order',
required=True, tracking=True,
domain="[('partner_id', '=', partner_id)]",
help='The order being returned. Required so cert/part/coating '
'context follows the return through triage and resolution.',
)
sale_order_line_ids = fields.Many2many(
'sale.order.line', 'fp_rma_sol_rel', 'rma_id', 'sol_id',
string='Returned Lines',
domain="[('order_id', '=', sale_order_id)]",
help='Subset of the original SO lines that the customer is '
'returning. Used to pull part/cert context.',
)
original_job_ids = fields.Many2many(
'fp.job', string='Original Jobs',
compute='_compute_original_job_ids', store=False,
help='Jobs derived from the SO. Navigation-only.',
)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
readonly=True,
)
# ------------------------------------------------------------------
# Why and how bad
# ------------------------------------------------------------------
trigger_source = fields.Selection(
[
('customer_complaint', 'Customer Complaint'),
('qc_fail_post_ship', 'Post-Shipment QC Failure'),
('inspection_post_delivery', 'Customer Inspection Post-Delivery'),
('other', 'Other'),
],
string='Trigger',
default='customer_complaint',
required=True,
tracking=True,
)
severity = fields.Selection(
[
('low', 'Low'),
('medium', 'Medium'),
('high', 'High'),
('critical', 'Critical'),
],
string='Severity',
default='medium',
required=True,
tracking=True,
)
complaint_description = fields.Html(
string='Customer Complaint',
help='What the customer reported.',
)
triage_findings = fields.Html(
string='Triage Findings',
help='What we found on inspection after receiving the parts.',
)
# ------------------------------------------------------------------
# Resolution
# ------------------------------------------------------------------
resolution_type = fields.Selection(
[
('replace', 'Replace'),
('rework', 'Rework'),
('refund', 'Refund'),
('scrap', 'Scrap'),
],
string='Resolution',
tracking=True,
)
resolution_notes = fields.Html(string='Resolution Notes')
replacement_job_id = fields.Many2one(
'fp.job', string='Replacement Job',
ondelete='set null',
help='New plating job created for replace/rework resolutions.',
)
refund_invoice_id = fields.Many2one(
'account.move', string='Refund / Credit Note',
ondelete='set null',
domain="[('move_type', '=', 'out_refund')]",
)
# ------------------------------------------------------------------
# Inbound logistics
# ------------------------------------------------------------------
inbound_receiving_id = fields.Many2one(
'fp.receiving', string='Inbound Receiving',
ondelete='set null',
help='Receiving record auto-created when the carrier delivers '
'the returned parts.',
)
inbound_picking_id = fields.Many2one(
'stock.picking', string='Inbound Picking',
ondelete='set null',
)
qty_returned = fields.Integer(
string='Qty Returned', tracking=True,
help='Total units the customer is returning per the authorisation.',
)
qty_received = fields.Integer(
string='Qty Received', tracking=True,
help='Counted on receipt at our dock.',
)
customer_tracking = fields.Char(
string='Customer Tracking #',
help='Outbound tracking from the customer back to us.',
)
our_tracking = fields.Char(
string='Our Tracking #',
help='Tracking number for the replacement / return shipment '
'from our shop.',
)
carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
# ------------------------------------------------------------------
# QR + auto-spawn toggles
# ------------------------------------------------------------------
qr_code = fields.Binary(
string='QR Code', compute='_compute_qr_code', store=False,
help='Encodes /fp/rma/<id> for the customer authorisation PDF.',
)
auto_spawn_ncr = fields.Boolean(
string='Auto-create NCR on Receipt',
default=True, tracking=True,
help='When the carrier delivers the returned parts and an '
'fp.receiving is created against this RMA, an NCR is '
'spawned automatically. Manager can toggle off — the '
'change is tracked on the chatter for audit.',
)
auto_spawn_hold = fields.Boolean(
string='Auto-place Hold on Receipt',
default=True, tracking=True,
help='Same trigger as auto_spawn_ncr but creates an '
'fusion.plating.quality.hold for the returned qty.',
)
# ------------------------------------------------------------------
# Linked records (cross-domain)
# ------------------------------------------------------------------
linked_ncr_ids = fields.One2many(
'fusion.plating.ncr', 'rma_id', string='NCRs',
)
linked_hold_ids = fields.One2many(
'fusion.plating.quality.hold', 'rma_id', string='Holds',
)
linked_capa_ids = fields.Many2many(
'fusion.plating.capa', string='CAPAs',
compute='_compute_linked_capa_ids', store=False,
)
ncr_count = fields.Integer(compute='_compute_link_counts')
hold_count = fields.Integer(compute='_compute_link_counts')
capa_count = fields.Integer(compute='_compute_link_counts')
active = fields.Boolean(default=True)
# ------------------------------------------------------------------
# Phase B placeholders (categorisation) — added now so views won't
# break when Phase B lands. Kept as M2O/M2M to models added later.
# ------------------------------------------------------------------
# tag_ids, reason_id, team_id, stage_id are added in Phase B.
# ------------------------------------------------------------------
# Defaults / create / name
# ------------------------------------------------------------------
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma')
return seq or '/'
@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)
# ------------------------------------------------------------------
# Computes
# ------------------------------------------------------------------
@api.depends('sale_order_id', 'sale_order_line_ids')
def _compute_original_job_ids(self):
Job = self.env['fp.job']
for rec in self:
if not rec.sale_order_id:
rec.original_job_ids = False
continue
rec.original_job_ids = Job.search([
('sale_order_id', '=', rec.sale_order_id.id),
])
@api.depends('linked_ncr_ids.capa_ids')
def _compute_linked_capa_ids(self):
for rec in self:
rec.linked_capa_ids = rec.linked_ncr_ids.mapped('capa_ids')
@api.depends(
'linked_ncr_ids', 'linked_hold_ids', 'linked_capa_ids',
)
def _compute_link_counts(self):
for rec in self:
rec.ncr_count = len(rec.linked_ncr_ids)
rec.hold_count = len(rec.linked_hold_ids)
rec.capa_count = len(rec.linked_capa_ids)
@api.depends('name')
def _compute_qr_code(self):
try:
import qrcode
except ImportError:
for rec in self:
rec.qr_code = False
return
base = self.env['ir.config_parameter'].sudo().get_param(
'web.base.url', '',
)
for rec in self:
if not rec.id:
rec.qr_code = False
continue
url = f'{base}/fp/rma/{rec.id}'
buf = io.BytesIO()
img = qrcode.make(url)
img.save(buf, format='PNG')
rec.qr_code = base64.b64encode(buf.getvalue())
# ------------------------------------------------------------------
# Lifecycle actions
# ------------------------------------------------------------------
def action_authorise(self):
for rec in self:
if not rec.sale_order_line_ids:
raise UserError(_(
'Select at least one returned line on RMA %s before '
'authorising.'
) % rec.display_name)
if rec.qty_returned <= 0:
raise UserError(_(
'RMA %s needs a returned quantity > 0 before '
'authorising.'
) % rec.display_name)
rec.state = 'authorised'
rec._post_state_message('Authorised')
rec._fire_rma_notification('rma_authorised')
def action_mark_shipped_to_us(self):
for rec in self:
if rec.state != 'authorised':
raise UserError(_(
'RMA %s must be Authorised before marking it as '
'shipped by the customer.'
) % rec.display_name)
rec.state = 'shipped_to_us'
rec._post_state_message('Customer Shipped')
def action_mark_received(self):
"""Manual fallback when an inbound fp.receiving was not auto-linked."""
for rec in self:
if rec.state not in ('authorised', 'shipped_to_us'):
raise UserError(_(
'RMA %s must be Authorised or Shipped before being '
'marked Received.'
) % rec.display_name)
rec._enter_received_state(receiving=False)
def _enter_received_state(self, receiving=None):
"""Common receive-side hook. Called either:
- from action_mark_received (manual)
- from fp.receiving.create override when rma_id was set
Flips state to `received` and (optionally) spawns NCR + Hold per
the auto_spawn_* toggles. Idempotent — re-entry on an already-
received RMA is a no-op (no double-spawn on ORM retry / split
deliveries).
"""
for rec in self:
if rec.state == 'received':
continue
rec.state = 'received'
spawned = []
if rec.auto_spawn_ncr:
ncr = rec._spawn_ncr()
if ncr:
spawned.append(_('NCR %s') % ncr.name)
if rec.auto_spawn_hold:
hold = rec._spawn_hold()
if hold:
spawned.append(_('Hold %s') % hold.name)
label = 'Received'
if spawned:
label += ' — auto-spawned ' + ', '.join(spawned)
rec._post_state_message(label)
# Customer notification: parts arrived at the shop.
rec._fire_rma_notification('rma_received')
def _spawn_ncr(self):
self.ensure_one()
Ncr = self.env['fusion.plating.ncr']
# Idempotency: if an NCR for this RMA already exists, return it.
existing = Ncr.search([('rma_id', '=', self.id)], limit=1)
if existing:
return existing
partner = self.partner_id
# Pull a facility — prefer the partner's company facility, fall
# back to the first active facility.
Facility = self.env['fusion.plating.facility']
facility = (
Facility.search([('company_id', '=', self.company_id.id)], limit=1)
or Facility.search([], limit=1)
)
if not facility:
_logger.warning(
'RMA %s: no fusion.plating.facility found, NCR spawn '
'skipped', self.name,
)
return False
part_ref = ', '.join(
self.sale_order_line_ids.mapped('product_id.default_code') or []
) or self.sale_order_line_ids[:1].product_id.display_name or '/'
complaint = self.complaint_description or ''
body = (
Markup('<p><strong>RMA %s — auto-created from customer return.</strong></p>') % self.name
+ Markup(complaint or '<p>(no description)</p>')
)
ncr = Ncr.create({
'facility_id': facility.id,
'source': 'customer',
'severity': self.severity or 'medium',
'part_ref': part_ref[:64],
'quantity_affected': self.qty_received or self.qty_returned or 0,
'description': body,
'customer_partner_id': partner.id,
'rma_id': self.id,
})
return ncr
def _spawn_hold(self):
self.ensure_one()
Hold = self.env['fusion.plating.quality.hold']
# Idempotency: one auto-Hold per RMA.
existing = Hold.search([('rma_id', '=', self.id)], limit=1)
if existing:
return existing
Facility = self.env['fusion.plating.facility']
facility = (
Facility.search([('company_id', '=', self.company_id.id)], limit=1)
or Facility.search([], limit=1)
)
part_ref = (
self.sale_order_line_ids[:1].product_id.default_code
or self.sale_order_line_ids[:1].product_id.display_name
or self.name
)
hold = Hold.create({
'part_ref': part_ref[:64],
'qty_on_hold': self.qty_received or self.qty_returned or 0,
'qty_original': self.qty_returned or 0,
'hold_reason': 'customer_complaint',
'description': (
f'Auto-created from RMA {self.name}. '
f'Returned parts on hold pending triage.'
),
'facility_id': facility.id if facility else False,
'rma_id': self.id,
})
return hold
def action_triage_complete(self):
for rec in self:
if rec.state != 'received':
raise UserError(_(
'RMA %s must be Received before triage can be '
'completed.'
) % rec.display_name)
if not rec.resolution_type:
raise UserError(_(
'Set a Resolution (replace / rework / refund / scrap) '
'on RMA %s before completing triage.'
) % rec.display_name)
rec.state = 'triaged'
rec._post_state_message('Triaged')
def action_start_resolving(self):
for rec in self:
if rec.state != 'triaged':
raise UserError(_(
'RMA %s must be Triaged before resolution work can '
'start.'
) % rec.display_name)
rec.state = 'resolving'
rec._post_state_message('Resolving')
def action_resolve(self):
"""Trigger resolution-specific side-effects then flip to resolved.
For replace/rework/scrap: spawn the side-effect, flip state.
For refund: open the credit-note wizard. State stays at
`resolving` until the wizard runs and the accountant links the
credit note via action_link_refund (or the AccountMove write
hook auto-links by invoice_origin).
"""
for rec in self:
if rec.state not in ('triaged', 'resolving'):
raise UserError(_(
'RMA %s must be Triaged or Resolving before being '
'marked Resolved.'
) % rec.display_name)
# Refund path needs a wizard return — handle separately.
refund_recs = self.filtered(lambda r: r.resolution_type == 'refund')
if len(refund_recs) > 1:
raise UserError(_(
'Resolve refund RMAs one at a time so the credit-note '
'wizard can be filled in.'
))
if refund_recs:
return refund_recs._resolve_refund()
# Non-refund paths: fire side-effect then flip state.
for rec in self:
handler = {
'replace': rec._resolve_replace,
'rework': rec._resolve_rework,
'scrap': rec._resolve_scrap,
}.get(rec.resolution_type)
if not handler:
raise UserError(_(
'No handler for resolution type "%s" on RMA %s.'
) % (rec.resolution_type, rec.display_name))
handler()
rec.state = 'resolved'
rec._post_state_message(
f'Resolved ({rec.resolution_type})',
)
rec._fire_rma_notification('rma_resolved')
def _resolve_replace(self):
return self._spawn_replacement_job(reason='replace')
def _resolve_rework(self):
return self._spawn_replacement_job(reason='rework')
def _spawn_replacement_job(self, reason='replace'):
self.ensure_one()
Job = self.env['fp.job']
if self.replacement_job_id:
return self.replacement_job_id
first = self.original_job_ids[:1]
if not first:
_logger.info(
'RMA %s: no originating fp.job to clone; creating bare '
'replacement job.', self.name,
)
new_job = Job.create({
'partner_id': self.partner_id.id,
'sale_order_id': self.sale_order_id.id,
'origin': self.sale_order_id.name or self.name,
'qty': self.qty_returned or 1,
})
else:
new_job = first.copy({
'origin': f'{self.name} (RMA {reason})',
'qty': self.qty_returned or first.qty,
'state': 'draft',
})
# Drop cloned-from-source steps and regenerate from the
# recipe so the rework starts fresh (every step pending,
# no inherited timelogs / actuals / completion flags).
if hasattr(new_job, 'step_ids') and new_job.step_ids:
new_job.step_ids.unlink()
if hasattr(new_job, '_generate_steps_from_recipe') \
and new_job.recipe_id:
new_job._generate_steps_from_recipe()
self.replacement_job_id = new_job.id
# Auto-confirm so the portal mirror, racking inspection and
# 'job_confirmed' notification all fire — same as a normal job.
if hasattr(new_job, 'action_confirm') and new_job.state == 'draft':
try:
new_job.action_confirm()
except Exception as e:
_logger.warning(
'RMA %s: replacement job %s auto-confirm failed (%s); '
'leaving in draft.', self.name, new_job.name, e,
)
return new_job
def _resolve_refund(self):
self.ensure_one()
if self.refund_invoice_id:
return self.refund_invoice_id
# Open the standard refund wizard pre-filled to the original SO.
# We don't auto-confirm — accountant verifies amounts first.
invoices = self.env['account.move'].search([
('invoice_origin', '=', self.sale_order_id.name),
('move_type', '=', 'out_invoice'),
], limit=1)
if not invoices:
raise UserError(_(
'RMA %s: no posted invoice found for SO %s — cannot '
'create a credit note automatically. Issue refund '
'manually.'
) % (self.display_name, self.sale_order_id.name))
return {
'name': _('Credit Note'),
'type': 'ir.actions.act_window',
'res_model': 'account.move.reversal',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'account.move',
'active_ids': invoices.ids,
'default_reason': f'RMA {self.name}',
'default_journal_id': invoices.journal_id.id,
},
}
def _resolve_scrap(self):
# NB: spec calls for an fp.job.consumption row with source='rma_scrap'
# but fp.job.consumption requires product_id and there's no curated
# "scrap" product yet. Phase E will surface scrap via the Monthly
# Quality Summary report instead. For now, just narrate.
self.ensure_one()
qty = self.qty_received or self.qty_returned or 0
self.message_post(
body=Markup(
'Resolution: <b>scrap</b>. %s units written off via RMA %s.'
) % (qty, self.name),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
for ncr in self.linked_ncr_ids:
ncr.message_post(
body=Markup('Resolution: <b>scrap</b> via RMA %s.') % self.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
def action_close(self):
for rec in self:
if rec.state != 'resolved':
raise UserError(_(
'RMA %s must be Resolved before it can be closed.'
) % rec.display_name)
open_ncrs = rec.linked_ncr_ids.filtered(
lambda n: n.state != 'closed'
)
if open_ncrs:
raise UserError(_(
'RMA %s has open NCRs (%s). Close the NCRs first.'
) % (
rec.display_name,
', '.join(open_ncrs.mapped('name')),
))
open_holds = rec.linked_hold_ids.filtered(
lambda h: h.state in ('on_hold', 'under_review')
)
if open_holds:
raise UserError(_(
'RMA %s still has active Holds (%s). Release, scrap, '
'or send to rework before closing the RMA.'
) % (
rec.display_name,
', '.join(open_holds.mapped('name')),
))
rec.state = 'closed'
rec._post_state_message('Closed')
def _fire_rma_notification(self, event):
"""Best-effort notification dispatch via fp.notification.template.
Silently skips if fusion_plating_notifications is absent or no
template is configured for this trigger event. Failures never
block the RMA state machine.
"""
if 'fp.notification.template' not in self.env:
return
Tpl = self.env['fp.notification.template'].sudo()
for rec in self:
partner = rec.partner_id
if not partner:
continue
try:
Tpl._dispatch(
event, rec, partner, sale_order=rec.sale_order_id,
)
except Exception as e:
_logger.warning(
'RMA %s: notification %s failed: %s',
rec.name, event, e,
)
def action_cancel(self):
is_manager = self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
)
if not is_manager:
raise UserError(_(
'Only Plating Managers can cancel an RMA.'
))
for rec in self:
if rec.state == 'closed':
raise UserError(_(
'RMA %s is already closed and cannot be cancelled.'
) % rec.display_name)
rec.state = 'cancelled'
rec._post_state_message('Cancelled')
# ------------------------------------------------------------------
# Smart-button actions
# ------------------------------------------------------------------
def action_view_ncrs(self):
self.ensure_one()
return {
'name': _('NCRs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.ncr',
'view_mode': 'list,form',
'domain': [('rma_id', '=', self.id)],
'context': {
'default_rma_id': self.id,
'default_customer_partner_id': self.partner_id.id,
'default_source': 'customer',
},
}
def action_view_holds(self):
self.ensure_one()
return {
'name': _('Holds'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('rma_id', '=', self.id)],
'context': {'default_rma_id': self.id},
}
def action_view_capas(self):
self.ensure_one()
return {
'name': _('CAPAs'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.capa',
'view_mode': 'list,form',
'domain': [('id', 'in', self.linked_capa_ids.ids)],
}
def action_view_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.sale_order_id.id,
}
def action_view_replacement_job(self):
self.ensure_one()
if not self.replacement_job_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job',
'view_mode': 'form',
'res_id': self.replacement_job_id.id,
}
def action_view_refund(self):
self.ensure_one()
if not self.refund_invoice_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'form',
'res_id': self.refund_invoice_id.id,
}
def action_view_inbound_receiving(self):
self.ensure_one()
if not self.inbound_receiving_id:
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.receiving',
'view_mode': 'form',
'res_id': self.inbound_receiving_id.id,
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _post_state_message(self, label):
for rec in self:
rec.message_post(
body=Markup('RMA status changed to <b>%s</b>.') % label,
message_type='comment',
subtype_xmlid='mail.mt_note',
)

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Sub 12 Phase A. Inverse Many2one fields on NCR, Hold and fp.receiving so
# RMA can hang One2many counterparts off them. Plus a tiny override on
# fp.receiving.create to flip a linked RMA into the `received` state and
# trigger the auto-spawn rules.
import logging
from markupsafe import Markup
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FpNcrRmaLink(models.Model):
_inherit = 'fusion.plating.ncr'
rma_id = fields.Many2one(
'fusion.plating.rma', string='RMA',
ondelete='set null', index=True,
help='Return that triggered this NCR (auto-set by RMA receive).',
)
class FpQualityHoldRmaLink(models.Model):
_inherit = 'fusion.plating.quality.hold'
rma_id = fields.Many2one(
'fusion.plating.rma', string='RMA',
ondelete='set null', index=True,
help='Return that placed these parts on hold.',
)
class FpReceivingRmaLink(models.Model):
_inherit = 'fp.receiving'
rma_id = fields.Many2one(
'fusion.plating.rma', string='Linked RMA',
ondelete='set null', index=True,
help='If set, this receiving is the inbound for a customer return. '
'When created, it transitions the RMA to `received` and may '
'auto-spawn an NCR + Hold per the RMA toggles.',
)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
# Walk new records, mirror back to RMA, walk the receiving's own
# state machine (draft → counted → staged → closed) so the linked
# SO's x_fc_receiving_status updates, then fire the RMA receive
# hook. Without this the receiving sat at draft and the SO read
# 'not_received' even though the parts were physically at the shop.
for rec in records:
if not rec.rma_id:
continue
rma = rec.rma_id.sudo()
# Mirror inbound link both ways.
if not rma.inbound_receiving_id:
rma.inbound_receiving_id = rec.id
if rma.state in ('authorised', 'shipped_to_us'):
# Use received_qty as qty_received fallback if not set.
if not rma.qty_received and rec.received_qty:
rma.qty_received = rec.received_qty
# Walk the receiving's lifecycle to closed so SO status
# updates. RMA receipts don't have a multi-day racking
# delay (parts are already plated and being inspected for
# the complaint, not racked for fresh plating), so we
# fast-forward all three transitions in one shot.
rec.sudo()._fp_rma_fast_close()
rma._enter_received_state(receiving=rec)
else:
_logger.info(
'RMA %s linked to fp.receiving %s but state %s does '
'not trigger auto-receive hook.',
rma.name, rec.name, rma.state,
)
return records
def _fp_rma_fast_close(self):
"""Walk an RMA-bound receiving from draft to closed in one call.
For RMA returns, the receiving's box-count → racking → close walk
is purely administrative — the parts are already plated and the
operator opens them on triage, not on intake. Fast-forwarding
here keeps the SO's x_fc_receiving_status accurate without
forcing the receiver to click three buttons in sequence.
"""
for rec in self:
if not rec.box_count_in:
# Best-effort default: 1 box if unknown. Real qty lives on
# the RMA's qty_returned / qty_received.
rec.box_count_in = 1
if rec.state == 'draft':
rec.action_mark_counted()
if rec.state == 'counted':
rec.action_mark_staged()
if rec.state == 'staged':
rec.action_close()
class AccountMoveRmaLink(models.Model):
"""Auto-link a credit note back to its RMA when the accountant
confirms the reversal wizard. Looks up by invoice_origin matching
an RMA's sale_order_id.name, scoped to RMAs in `resolving` state
with resolution_type='refund' and no refund_invoice_id yet.
Also flips the RMA from `resolving` to `resolved` once the credit
note is linked — mirrors the auto-progression for replace/rework
paths so the RMA doesn't get stuck after a refund.
"""
_inherit = 'account.move'
@api.model_create_multi
def create(self, vals_list):
moves = super().create(vals_list)
moves._fp_link_to_rma()
return moves
def write(self, vals):
result = super().write(vals)
if 'state' in vals and vals.get('state') == 'posted':
self._fp_link_to_rma()
return result
def _fp_link_to_rma(self):
Rma = self.env['fusion.plating.rma'].sudo()
for move in self:
if move.move_type != 'out_refund':
continue
if not move.invoice_origin:
continue
candidate = Rma.search([
('sale_order_id.name', '=', move.invoice_origin),
('resolution_type', '=', 'refund'),
('refund_invoice_id', '=', False),
('state', 'in', ('resolving', 'triaged')),
], limit=1)
if not candidate:
continue
candidate.refund_invoice_id = move.id
candidate.state = 'resolved'
candidate.message_post(
body=Markup(
'Refund credit note <b>%s</b> linked back to this RMA. '
'Marked Resolved.'
) % move.name,
message_type='comment',
subtype_xmlid='mail.mt_note',
)
candidate._fire_rma_notification('rma_resolved')

View File

@@ -0,0 +1,210 @@
# Battle test — real shop failure modes.
#
# This is the "what if my operator is sloppy / forgetful / lazy" suite.
# We document what the system does TODAY, then identify what's missing.
#
# Persona shorthand:
# Carlos = operator
# Mike = second operator
# Bob = supervisor / manager (admin in this DB)
import time
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
def make_job(po_suffix):
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': f'PO-BT-{po_suffix}',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# ====================================================================== 1
print('='*72)
print('SCENARIO 1 — Carlos forgot to click Start. Realizes 2 hours later.')
print('='*72)
job = make_job('S1-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
print(f' Setup: {job.name}, step "{step.name}" state={step.state}')
print(f' Reality: Carlos started masking 2h ago but forgot to click.')
print(f' Now he clicks Start, then immediately Finish.')
step.button_start()
step.button_finish()
print(f' Result: state={step.state}, duration_actual={step.duration_actual:.4f} min')
print(f' → Lost 2h of clock time. NO way to back-date date_started without admin SQL.')
print(f' → date_started field is readonly=True on the form.')
print(f' GAP: No "Adjust Time" affordance for forgetful operators.')
# ====================================================================== 2
print()
print('='*72)
print('SCENARIO 2 — Carlos finished step physically. Forgot Finish. Went home.')
print('='*72)
job = make_job('S2-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos starts {step.name} at {step.date_started}')
print(f' ... 12 hours later Mike notices the step is still in_progress ...')
# Simulate the time gap by setting started 12h ago
step.write({'date_started': fields.Datetime.now() - timedelta(hours=12)})
# Mike taps Finish now
step.button_finish()
print(f' Mike clicks Finish: duration_actual = {step.duration_actual:.1f} min')
print(f' Reality was probably 30 min. System recorded {step.duration_actual:.0f} min.')
print(f' Cost rollup is wildly wrong: cost_total = ${step.cost_total or 0:.2f}')
print(f' GAP: No way to retroactively correct the timelog interval.')
# ====================================================================== 3
print()
print('='*72)
print('SCENARIO 3 — Two operators tap Start on the same step.')
print('='*72)
job = make_job('S3-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos clicks Start → state={step.state}, '
f'open logs={len(step.time_log_ids.filtered(lambda l: not l.date_finished))}')
try:
# Mike "logs in as himself" then taps Start on the same step
step.button_start()
open_logs = step.time_log_ids.filtered(lambda l: not l.date_finished)
print(f' Mike clicks Start → state={step.state}, open logs={len(open_logs)}')
if len(open_logs) >= 2:
print(f' ❌ TWO open timelogs created. duration_actual will double-count.')
except Exception as e:
print(f' ✓ Blocked: {e}')
# ====================================================================== 4
print()
print('='*72)
print('SCENARIO 4 — Operator finishes step #6 before #5 is started.')
print('='*72)
job = make_job('S4-' + fields.Datetime.now().strftime('%H%M%S'))
steps = job.step_ids.sorted('sequence')
step5 = steps[4]
step6 = steps[5]
print(f' Step #5: {step5.name} state={step5.state}')
print(f' Step #6: {step6.name} state={step6.state}')
try:
step6.button_start()
print(f' ❌ Allowed start of step #6 while step #5 still ready')
step6.button_finish()
print(f' Step #6 done. Step #5 still: {step5.state}')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
print(f' GAP: No predecessor enforcement. Steps are independent.')
print(f' REALITY: This may be intentional (parallel work in different tanks).')
print(f' But there\'s no "force serial" flag for steps that MUST be in order.')
# ====================================================================== 5
print()
print('='*72)
print('SCENARIO 5 — Job stuck mid-process. Manager wants to take over.')
print('='*72)
job = make_job('S5-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.write({'assigned_user_id': env.user.id})
step.button_start()
print(f' Step assigned to Carlos, in progress.')
print(f' Carlos is on vacation. Bob needs to reassign + finish.')
print(f' Bob views step → assigned_user_id={step.assigned_user_id.name}')
# Can Bob reassign?
try:
step.write({'assigned_user_id': env.user.id})
print(f' ✓ Bob reassigned step (write to assigned_user_id allowed)')
except Exception as e:
print(f' ❌ Reassign blocked: {e}')
# Bob finishes
step.button_finish()
print(f' Bob finishes: state={step.state}, finished_by={step.finished_by_user_id.name}')
# ====================================================================== 6
print()
print('='*72)
print('SCENARIO 6 — Bake window expired (operator at lunch). Override?')
print('='*72)
BW = env['fusion.plating.bake.window']
Bath = env['fusion.plating.bath']
bath = Bath.search([], limit=1)
expired = BW.create({
'bath_id': bath.id,
'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
'window_hours': 4.0,
'part_ref': 'BT-EXPIRED',
'quantity': 5,
})
# Cron updates state if past required_by
BW._cron_update_states()
expired.invalidate_recordset()
print(f' Bake window {expired.name}: state={expired.state}, '
f'required_by={expired.bake_required_by} (10h ago)')
# Try to start_bake on a missed_window
try:
expired.action_start_bake()
print(f' ⚠️ action_start_bake worked even on missed_window: state={expired.state}')
print(f' GAP: No guard against starting bake after missing window. Should require manager override.')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
# ====================================================================== 7
print()
print('='*72)
print('SCENARIO 7 — Operator clocks 6 hours on a step expected to take 30 min.')
print('='*72)
job = make_job('S7-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.duration_expected = 30 # 30 min
step.button_start()
# Simulate 6h elapsed
step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
step.button_finish()
ratio = (step.duration_actual / step.duration_expected) if step.duration_expected else 0
print(f' duration_expected={step.duration_expected} min, duration_actual={step.duration_actual:.0f} min')
print(f' Ratio: {ratio:.1f}x expected')
print(f' GAP: System silently accepted 12x overrun. No alert, no chatter post.')
# ====================================================================== 8
print()
print('='*72)
print('SCENARIO 8 — Operator did 4 of 5 parts. 1 contaminated. Qty drift.')
print('='*72)
job = make_job('S8-' + fields.Datetime.now().strftime('%H%M%S'))
print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
# Operator finishes all steps
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
# Try to mark done — qty_done is still 0
try:
job.button_mark_done()
print(f' Job done: qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
print(f' ⚠️ System lets job close with qty_done=0 even though qty=5')
print(f' GAP: No reconciliation between qty + qty_done + qty_scrapped at close.')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
env.cr.commit()
print()
print('== Battle test complete ==')

View File

@@ -0,0 +1,150 @@
# Battle test v2 — re-verify after fixes for: bake-window override,
# duration overrun chatter, qty reconciliation, recompute-duration.
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
def make_job(po_suffix):
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': f'PO-BT2-{po_suffix}',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# ====================================================================== Fix 1
print('='*72)
print('FIX 1 — Bake-window: missed_window blocks, manager override allowed + audited')
print('='*72)
BW = env['fusion.plating.bake.window']
Bath = env['fusion.plating.bath']
expired = BW.create({
'bath_id': Bath.search([], limit=1).id,
'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
'window_hours': 4.0,
'part_ref': 'BT2-EXPIRED',
'quantity': 5,
})
BW._cron_update_states()
expired.invalidate_recordset()
print(f' Window {expired.name} state: {expired.state}')
# Naive operator (no override) — should fail
try:
expired.action_start_bake()
print(f' ❌ start_bake worked without override')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:120]}')
# Manager override
try:
expired.action_force_start_missed()
print(f' ✓ Manager override succeeded: state={expired.state}')
# Check chatter
msgs = expired.message_ids.filtered(lambda m: 'OVERRIDE' in (m.body or ''))
print(f' ✓ Chatter audit: {len(msgs)} OVERRIDE message logged')
except Exception as e:
print(f' ❌ Override failed: {e}')
# ====================================================================== Fix 2
print()
print('='*72)
print('FIX 2 — Duration overrun: > 1.5x expected posts chatter warning')
print('='*72)
job = make_job('F2-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.duration_expected = 30 # 30 min expected
step.button_start()
# Force a 6h elapsed via timelog backdate
step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
# Update the open timelog to start 6h ago too
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
step.button_finish()
print(f' duration_expected={step.duration_expected:.0f} min, '
f'duration_actual={step.duration_actual:.0f} min, '
f'ratio={step.duration_actual/step.duration_expected:.1f}x')
overrun_msgs = job.message_ids.filtered(lambda m: 'expected' in (m.body or ''))
print(f' Chatter overrun warnings on job: {len(overrun_msgs)}')
if overrun_msgs:
print(f' ✓ Posted: {overrun_msgs[0].body[:100]}...')
# ====================================================================== Fix 3
print()
print('='*72)
print('FIX 3 — Qty reconciliation: job mark-done blocks if qty mismatch')
print('='*72)
job = make_job('F3-' + fields.Datetime.now().strftime('%H%M%S'))
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
# Try mark done with qty_done = 0
try:
job.button_mark_done()
print(f' ❌ Job closed with qty_done=0!')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:160]}')
# Set qty_done = 4, qty_scrapped = 1, retry
job.qty_done = 4
job.qty_scrapped = 1
print(f' Update: qty_done=4, qty_scrapped=1 (sums to qty=5)')
try:
job.button_mark_done()
print(f' ✓ Closed with reconciled qty: state={job.state}')
except Exception as e:
print(f' ❌ Still blocked: {e}')
# ====================================================================== Fix 4
print()
print('='*72)
print('FIX 4 — Supervisor edits timelog → Recompute Duration action picks it up')
print('='*72)
job = make_job('F4-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
import time as _t
_t.sleep(1)
step.button_finish()
print(f' Initial: duration_actual={step.duration_actual:.4f} min, '
f'logs={len(step.time_log_ids)}')
# Bob backdates the timelog (operator forgot to start; was actually 30 min)
log = step.time_log_ids[0]
real_start = log.date_finished - timedelta(minutes=30)
log.write({'date_started': real_start})
print(f' Bob backdates log: started 30 min before finish')
print(f' log.duration_minutes (auto): {log.duration_minutes:.2f} min')
print(f' step.duration_actual STILL stale: {step.duration_actual:.2f} min')
# Apply recompute
step.action_recompute_duration_from_timelogs()
print(f' After Recompute: duration_actual={step.duration_actual:.2f} min')
recompute_msgs = job.message_ids.filtered(lambda m: 'recomputed' in (m.body or '').lower())
print(f' Chatter audit: {len(recompute_msgs)} recompute entry logged')
env.cr.commit()
print()
print('== Battle test v2 complete ==')

View File

@@ -0,0 +1,82 @@
# Scenario 10 — Carlos paused for lunch. Got pulled to another job. Step
# is now sitting in 'paused' state for 3 days. No alert. Costing is wrong
# (the open timelog row was already closed at pause, but the step shows
# zero progress).
#
# Real shop pattern: this happens daily — interruptions, shift change,
# operator pulled to rush job.
#
# What we want:
# 1. A way to find ALL steps stuck in 'paused' beyond a threshold
# 2. An automatic activity / chatter nudge to the supervisor
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S10-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[0]
# Carlos starts → pauses → walks away
step.button_start()
step.button_pause()
print(f'[Carlos] Started + paused step "{step.name}" (state={step.state})')
# Simulate 3 days passing — backdate the pause by setting date_started
step.date_started = fields.Datetime.now() - timedelta(days=3)
print(f' Pretending it has been paused 3 days')
# Today: how would a manager find this?
print()
print('=== Manager finds stale paused steps ===')
Step = env['fp.job.step']
all_paused = Step.search([('state', '=', 'paused')])
print(f' Total paused steps in DB: {len(all_paused)}')
print(f' Stale-paused (date_started > 1 day ago, state=paused):')
cutoff = fields.Datetime.now() - timedelta(days=1)
stale = Step.search([
('state', '=', 'paused'),
('date_started', '<', cutoff),
])
print(f' found {len(stale)} via search_count')
for s in stale[:5]:
age = (fields.Datetime.now() - s.date_started).days
print(f' - {s.job_id.name} step "{s.name}": paused {age}d, '
f'assigned={s.assigned_user_id.name or "(no one)"}')
# Is there a cron / activity nudge?
crons = env['ir.cron'].search([('name', 'ilike', 'pause')])
print()
print(f' Crons matching "pause": {len(crons)}')
activities = env['mail.activity'].search([
('res_model', '=', 'fp.job.step'),
('summary', 'ilike', 'paused'),
])
print(f' Activities about paused steps: {len(activities)}')
print()
if not activities and not crons:
print(' ❌ GAP: stale-paused steps live forever silently. No nudge.')
env.cr.commit()

View File

@@ -0,0 +1,110 @@
# Scenario 11 — Carlos plating step #4 in tank 3. 8 minutes in, the
# rectifier dies. Parts come out half-plated. Carlos needs to:
# 1. Abort the current step (parts not finished — but partial work
# already happened)
# 2. Switch to backup tank 5
# 3. Restart the step there
#
# What does the system support today?
#
# Fields on fp.job.step that exist:
# - state machine: pending/ready/in_progress/paused/done/skipped/cancelled
# - bath_id, tank_id (the tank picked at start)
# - time_log_ids
#
# Operator's options today:
# A) button_cancel → state=cancelled, but then step shows as cancelled
# and won't be replayed. Not what we want — we WANT a retry.
# B) button_finish + open NCR manually + create a new step manually?
# Way too much paperwork.
# C) button_pause + change tank_id + button_start → preserves history
# but doesn't capture WHY (equipment failure)
#
# Real shop need:
# - "Abort + restart" action that:
# 1. Closes the current timelog (capturing the partial time)
# 2. Resets state to ready
# 3. Lets operator pick a new tank/bath
# 4. Posts chatter on the JOB explaining (equipment failure → tank)
# 5. Optionally fires an NCR / Maintenance request
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S11-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Pick the plating step
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not plating:
plating = job.step_ids.sorted('sequence')[3:4]
# Walk earlier steps to done
for s in job.step_ids.sorted('sequence'):
if s == plating:
break
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' [Carlos] About to start: {plating.name}')
Tank = env['fusion.plating.tank']
Bath = env['fusion.plating.bath']
tanks = Tank.search([], limit=2)
if len(tanks) < 2:
print(f' ⚠️ Need 2+ tanks for the test, only have {len(tanks)}')
else:
tank3, tank5 = tanks[0], tanks[1]
plating.write({'tank_id': tank3.id})
print(f' Initial tank: {plating.tank_id.name}')
plating.button_start()
print(f' Started → state={plating.state}, started_by={plating.started_by_user_id.name}')
print(f' Open timelog rows: {len(plating.time_log_ids)}')
print()
print(f' ⚡ 8 MINUTES LATER: Rectifier dies on tank {plating.tank_id.name}')
print(f' Carlos needs to abort and restart on backup tank.')
print()
# Today's options:
print(f' Today\'s options the operator has:')
print(f' A) button_cancel → step becomes cancelled (job stuck — no replay)')
print(f' B) button_pause + write tank_id + button_start (no failure record)')
print(f' C) ???')
print()
# Try option B (the workaround)
print(f' Trying option B (pause → change tank → resume):')
plating.button_pause()
print(f' Paused: state={plating.state}, logs={len(plating.time_log_ids)}')
if len(tanks) >= 2:
plating.write({'tank_id': tanks[1].id})
print(f' Changed tank to: {plating.tank_id.name}')
plating.button_start()
print(f' Resumed: state={plating.state}, logs={len(plating.time_log_ids)}')
print()
print(f' ❌ GAP: NO RECORD of WHY the tank change happened.')
print(f' ❌ GAP: Workaround works but loses the equipment-failure event.')
print(f' ❌ GAP: No automatic Maintenance Request / NCR creation for the failed equipment.')
env.cr.commit()

View File

@@ -0,0 +1,82 @@
# Verify action_abort_for_retry on a fresh job.
import time
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
Tank = env['fusion.plating.tank']
tanks = Tank.search([], limit=2)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S11V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[3] # plating
step.tank_id = tanks[0].id
step.button_start()
print(f' [Carlos] Started {step.name} on tank {step.tank_id.name}')
time.sleep(2)
# Equipment fails
print(f' ⚡ Rectifier dies on tank {step.tank_id.name}')
print()
before_msgs = len(job.message_ids)
step.action_abort_for_retry(
reason='Rectifier #3 tripped breaker; sparking on bus bar',
new_tank_id=tanks[1].id if len(tanks) > 1 else False,
)
print(f' After abort:')
print(f' state={step.state}')
print(f' tank_id={step.tank_id.name}')
print(f' duration_actual (partial work)={step.duration_actual:.4f} min')
print(f' timelogs={len(step.time_log_ids)}, all closed: '
f'{all(l.date_finished for l in step.time_log_ids)}')
print()
after_msgs = len(job.message_ids)
print(f' Job chatter: {before_msgs}{after_msgs} (delta {after_msgs - before_msgs})')
abort_msg = job.message_ids[0]
print(f' Latest message:')
print(f' {abort_msg.body[:300]}...')
# Operator restarts on the new tank
print()
print(f' [Carlos] Restarts the step on the new tank')
step.button_start()
time.sleep(2)
step.button_finish()
print(f' Final state={step.state}, total duration_actual={step.duration_actual:.4f} min')
print(f' Total timelogs={len(step.time_log_ids)} (1 from abort + 1 from retry)')
# Failure case: try to abort a step in 'ready' state
print()
print(f' Failure test: try abort on a ready (not in_progress) step')
ready_step = job.step_ids.filtered(lambda s: s.state == 'ready')[:1]
if ready_step:
try:
ready_step.action_abort_for_retry(reason='test')
print(f' ❌ Allowed abort on ready step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:100]}')
env.cr.commit()

View File

@@ -0,0 +1,76 @@
# Scenario 12 — Sarah enters SO qty=5. Job spawns with qty=5. Carlos
# starts step 1. Customer calls — they want 8 instead of 5. Sarah edits
# the SO line from 5 to 8.
#
# Question: does the job pick up the change?
# Reality: a stale qty on the job means Carlos plates 5 (per his router)
# but invoice goes for 8 (per the SO).
#
# OR Sarah can't edit a confirmed-SO line (Odoo standard locks it),
# in which case Sarah cancels + reorders, and we have ANOTHER problem.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Build SO with qty=5
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S12-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
sol = so.order_line[:1]
print(f' Initial: SO line qty={sol.product_uom_qty}, job qty={job.qty}')
# Carlos starts the first step
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos started step "{step.name}" (state={step.state})')
print()
# Customer calls — wants 8 not 5
print(f' 📞 Customer: "Make it 8 instead of 5"')
print(f' Sarah edits SO line qty from 5 to 8...')
try:
sol.product_uom_qty = 8
print(f' Edit succeeded: SO line qty={sol.product_uom_qty}')
except Exception as e:
print(f' ❌ Edit blocked: {e}')
# Did the job qty propagate?
job.invalidate_recordset()
print(f' Job qty AFTER SO edit: {job.qty}')
print()
if job.qty != sol.product_uom_qty:
print(f' ❌ GAP: Job qty stale ({job.qty}) vs SO line qty ({sol.product_uom_qty}).')
print(f' Carlos will plate {job.qty} parts. Invoice ships for {sol.product_uom_qty}.')
print(f' No automatic resync, no warning.')
else:
print(f' ✓ Job qty auto-updated.')
# Try the reverse — what if Sarah tries to LOWER the qty?
print()
print(f' Customer changes mind: now wants 3 instead of 8')
try:
sol.product_uom_qty = 3
job.invalidate_recordset()
print(f' SO line qty={sol.product_uom_qty}, job qty={job.qty}')
except Exception as e:
print(f' ❌ Blocked: {e}')
env.cr.commit()

View File

@@ -0,0 +1,59 @@
# Verify mid-job qty change posts chatter + sync action works.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S12V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
sol = so.order_line[:1]
job.step_ids.sorted('sequence')[0].button_start()
print(f' Initial: SO={sol.product_uom_qty}, job.qty={job.qty}')
before_msgs = len(job.message_ids)
print()
print(f' Sarah edits SO line qty 5 → 8 mid-job')
sol.product_uom_qty = 8
job.invalidate_recordset()
after_msgs = len(job.message_ids)
print(f' Job chatter: {before_msgs}{after_msgs} (delta {after_msgs - before_msgs})')
warn = job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or ''))
print(f' Warning messages on job: {len(warn)}')
if warn:
print(f' ✓ Chatter warning posted')
print(f' Job.qty still: {job.qty} (unchanged — supervisor must explicitly sync)')
print()
print(f' Bob clicks "Sync qty from SO" on the job')
job.action_sync_qty_from_so()
print(f' Job.qty after sync: {job.qty} (expect 8)')
sync_msgs = job.message_ids.filtered(lambda m: 'synced from SO' in (m.body or ''))
print(f' Sync chatter messages: {len(sync_msgs)}')
print()
# Now what about LOWER qty
print(f' Customer reduces to 3...')
sol.product_uom_qty = 3
job.invalidate_recordset()
warn2 = len(job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or '')))
print(f' Warnings now: {warn2}')
job.action_sync_qty_from_so()
print(f' After sync: job.qty={job.qty}')
env.cr.commit()

View File

@@ -0,0 +1,93 @@
# Verify shopfloor scan + tablet_overview now expose step instructions.
from odoo.tests.common import HOST
from odoo import fields
# Build a job with instructions on a step
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S13-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Add detailed instructions to the plating step
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not plating:
plating = job.step_ids.sorted('sequence')[3:4]
plating.instructions = (
'<p><b>Plating bath checklist:</b></p><ul>'
'<li>Verify nickel concentration is 4.05.5 g/L (Fischerscope reading)</li>'
'<li>pH must be 4.44.8 — adjust with ammonium hydroxide if needed</li>'
'<li>Bath temp 8893°C, agitation ON</li>'
'<li>Dwell 45 minutes for 25 µm coating; longer for thicker</li>'
'<li>Rinse for 60s before next station</li></ul>'
)
plating.thickness_target = 25.0
plating.thickness_uom = 'um'
plating.dwell_time_minutes = 45.0
plating.bake_setpoint_temp = 0 # not a bake step
print(f' Step "{plating.name}":')
print(f' instructions length: {len(plating.instructions or "")} chars')
print(f' thickness_target: {plating.thickness_target} {plating.thickness_uom}')
print()
# Now simulate scan endpoint via the controller
from odoo.addons.fusion_plating_shopfloor.controllers import shopfloor_controller as sc
print(f' Tablet operator scans the step QR code (simulating /fp/shopfloor/scan)')
# Build a fake request env
from odoo.http import request as _req
# Call the underlying logic directly
# Find code prefix used
print(f' Step code: {plating.id}, name: {plating.name}')
# Direct call to the scan response builder (no http) — easier approach:
# The scan endpoint builds the dict inline. Verify by replicating its code path.
step = plating
payload = {
'ok': True, 'model': 'fp.job.step',
'id': step.id, 'name': step.name, 'state': step.state,
'duration_actual': step.duration_actual,
'duration_expected': step.duration_expected,
'job_name': step.job_id.name or '',
'product_name': step.job_id.product_id.display_name or '',
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'dwell_time_minutes': step.dwell_time_minutes or 0,
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
}
print(f' Scan payload now includes:')
print(f' instructions: {len(payload["instructions"])} chars')
print(f' thickness_target: {payload["thickness_target"]} {payload["thickness_uom"]}')
print(f' dwell_time_minutes: {payload["dwell_time_minutes"]}')
print(f' duration_expected: {payload["duration_expected"]}')
# Tablet overview check via JSONRPC
# We'll just check the controller method directly
print()
print(f' Tablet overview payload (simulate /fp/shopfloor/tablet_overview):')
# Just verify the field is in _step_payload by introspection
import inspect
src = inspect.getsource(sc.FpShopfloorController)
print(f' _step_payload includes "instructions"? {"instructions" in src and "step.instructions" in src}')
print(f' _step_payload includes "thickness_target"? {"step.thickness_target" in src}')
print(f' _step_payload includes "dwell_time_minutes"? {"step.dwell_time_minutes" in src}')
env.cr.commit()

View File

@@ -0,0 +1,63 @@
# Scenario 14 — Recipe author wants step "Plating" to be hard-blocked
# until step "Acid Etch" finishes. (Real reason: passivation layer
# starts forming on bare metal in seconds; if Plating starts before
# acid etch is done, adhesion fails.)
#
# Today the system allows ANY step to start any time. Out-of-order is
# allowed for parallel work — but for SERIAL-MUST steps, there's no
# enforcement. We need an opt-in flag the recipe author can set per
# step: requires_predecessor_done.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
steps = job.step_ids.sorted('sequence')
print(f' Job {job.name} steps:')
for i, s in enumerate(steps[:6]):
print(f' #{i+1} ({s.sequence}): {s.name} state={s.state}')
# Today: skip step #1, #2, #3, jump to step #4 (plating).
print()
print(' [Operator] Tries to skip earlier steps and start plating directly:')
plating = steps.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if plating:
try:
plating.button_start()
print(f' state={plating.state}')
print(f' ❌ NO PREDECESSOR CHECK. Plating started while Masking/Racking still ready.')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
# Check if requires_predecessor_done field exists
rec_step = plating.recipe_node_id if plating else False
fields_on_node = list(env['fusion.plating.process.node']._fields.keys())
print()
print(f' Looking for requires_predecessor_done field on fp.process.node:')
print(f' Found: {"requires_predecessor_done" in fields_on_node}')
print(f' Looking for requires_predecessor_done field on fp.job.step:')
print(f' Found: {"requires_predecessor_done" in env["fp.job.step"]._fields}')
print()
print(f' ❌ GAP: No way for the recipe author to mark a step as serial-required.')
env.cr.commit()

View File

@@ -0,0 +1,91 @@
# Verify predecessor enforcement
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Find plating step + flag its recipe node as serial-required
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if plating and plating.recipe_node_id:
plating.recipe_node_id.requires_predecessor_done = True
print(f' Recipe author flagged "{plating.name}" requires_predecessor_done')
plating.invalidate_recordset()
print(f' Step picks it up via related: {plating.requires_predecessor_done}')
# Try to start plating with earlier steps still ready
print()
print(f' [Operator] Tries to start plating WITHOUT finishing earlier steps:')
try:
plating.button_start()
print(f' ❌ Allowed early start! state={plating.state}')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:200]}')
# Walk earlier steps to done
print()
print(f' [Operator] Walks earlier steps to done:')
for s in job.step_ids.sorted('sequence'):
if s == plating:
break
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' Earlier steps now: {set(job.step_ids.filtered(lambda x: x.sequence < plating.sequence).mapped("state"))}')
# Try plating again
print()
print(f' [Operator] Tries plating again after earlier steps done:')
try:
plating.button_start()
print(f' ✓ Allowed: state={plating.state}')
except Exception as e:
print(f' ❌ Still blocked: {e}')
# Test manager bypass via context
print()
print(f' Test manager bypass on a fresh job:')
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14B-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
Line.create({
'wizard_id': w2.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r2 = w2.action_create_order()
so2 = env['sale.order'].browse(r2['res_id'])
so2.action_confirm()
job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
plating2 = job2.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
# (the recipe_node already has requires_predecessor_done=True from earlier write)
print(f' Plating step requires_predecessor_done: {plating2.requires_predecessor_done}')
try:
plating2.with_context(fp_skip_predecessor_check=True).button_start()
print(f' ✓ Manager bypass: state={plating2.state}')
except Exception as e:
print(f' ❌ Bypass failed: {e}')
env.cr.commit()

View File

@@ -0,0 +1,72 @@
# Scenario 15 — Job has a coating that requires hydrogen embrittlement
# bake. Operator finishes plating step → bake.window auto-spawns
# (state=awaiting_bake). Operator finishes the rest of the steps and
# clicks Mark Done on the job — but never started the bake.
#
# Today: job closes done. Customer ships parts. Field failure 3 weeks
# later. AS9100 auditor: "Show me the bake record for lot X." There's
# no bake record. NCR + customer credit hit.
#
# Want: button_mark_done blocks if any linked bake.window is in state
# awaiting_bake or bake_in_progress. Manager bypass for one-off
# deviations.
import time
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
part = Part.create({
'partner_id': target.id,
'part_number': 'S15-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'substrate_material': 'steel',
'x_fc_default_coating_config_id': coating.id,
})
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S15-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Walk all steps to done — the plating step will spawn a bake.window
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.qty_done = 5 # satisfy reconciliation gate
print(f' Job {job.name}: all steps done')
BW = env['fusion.plating.bake.window']
bws = BW.search([('part_ref', '=', job.name)])
print(f' Bake windows linked to job: {len(bws)}')
for bw in bws:
print(f' {bw.name}: state={bw.state}, required_by={bw.bake_required_by}')
print()
print(f' [Operator — careless] Clicks Mark Done WITHOUT starting bake')
try:
job.button_mark_done()
print(f' ❌ Job closed with bake awaiting! state={job.state}')
print(f' COMPLIANCE BOMB — no bake record but parts ship.')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:200]}')
env.cr.commit()

View File

@@ -0,0 +1,67 @@
# Scenario 16 — Carlos clicked Start on a step. Got pulled to a rush
# job. Forgot to come back. The original step is still in_progress 8
# hours later. The open timelog row is accumulating phantom time. Cost
# rollup is wrong. Manager has no nudge.
#
# Mirror of S10 (stale-paused) but for in_progress.
from datetime import timedelta
from odoo import fields
# Find existing stale in_progress steps in DB to test against
Step = env['fp.job.step']
cutoff = fields.Datetime.now() - timedelta(hours=8)
stale = Step.search([
('state', '=', 'in_progress'),
('date_started', '<', cutoff),
('date_started', '!=', False),
])
print(f' Total in_progress steps started > 8h ago: {len(stale)}')
for s in stale[:5]:
age = (fields.Datetime.now() - s.date_started).total_seconds() / 3600.0
print(f' {s.job_id.name} step "{s.name}": in_progress {age:.1f}h, '
f'started_by={s.started_by_user_id.name or "(none)"}')
if not stale:
print(f' Building one synthetic case...')
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S16-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
s = job.step_ids.sorted('sequence')[0]
s.button_start()
s.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
open_log = s.time_log_ids.filtered(lambda l: not l.date_finished)
if open_log:
open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
print(f' Created stale: {s.job_id.name} step "{s.name}"')
# Look for cron / activity
crons = env['ir.cron'].search([
('name', 'ilike', 'in_progress'), ('name', 'ilike', 'stale'),
])
print()
print(f' Crons matching stale-in_progress: {len(crons)}')
acts = env['mail.activity'].search([('summary', 'like', 'Stale in-progress%')])
print(f' Activities about stale in_progress: {len(acts)}')
if not crons and not acts:
print(f' ❌ GAP: no nudge for phantom in_progress steps either.')
env.cr.commit()

View File

@@ -0,0 +1,60 @@
# Scenario 17 — Mid-job Carlos drops 2 parts (out of 5). Sets
# qty_scrapped from 0 → 2. With my qty-reconciliation gate, he MUST
# update this for the job to close — but there's no NCR / hold record
# explaining WHY 2 parts went away.
#
# Real shop: every scrap event is investigated. Material cost lost,
# customer not told (because the qty_done went down, not the order
# qty), and the AS9100 audit asks "where's the disposition record for
# scrapped parts?"
#
# Want: when qty_scrapped increases on fp.job, auto-create a
# fusion.plating.quality.hold + post chatter for the operator to
# document the cause.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S17-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Carlos starts working
job.step_ids.sorted('sequence')[0].button_start()
# Count holds linked before
Hold = env['fusion.plating.quality.hold']
holds_before = Hold.search_count([('part_ref', '=', part.part_number)])
print(f' Holds for {part.part_number} before scrap: {holds_before}')
# Drop 2 parts
print(f' [Carlos] Drops 2 parts. Updates qty_scrapped 0 → 2')
job.qty_scrapped = 2
holds_after = Hold.search_count([('part_ref', '=', part.part_number)])
print(f' Holds for {part.part_number} after scrap: {holds_after}')
if holds_after > holds_before:
print(f' ✓ Auto-Hold spawned')
else:
print(f' ❌ GAP: qty_scrapped went up but NO hold/NCR auto-created.')
print(f' No record of what happened. AS9100 auditor unhappy.')
env.cr.commit()

View File

@@ -0,0 +1,121 @@
# Scenario 18 — Certificate flow simulation.
# Persona: Sarah (CSR) → Carlos (operator) → Tom (shipper)
# Goal: complete cert issuance from SO entry to customer email.
# Track every gap.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
coating = Coating.search([('spec_reference', '!=', False)], limit=1) \
or Coating.search([], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
print(f' Coating: {coating.name}, spec_reference={coating.spec_reference}')
# Build the SO + walk the full flow
import base64
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-CERT-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 25.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f' Job {job.name} confirmed, qty=5')
# Walk all steps to done
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
# Set qty_done so reconciliation gate passes
job.qty_done = 5
# Bake-window if any
BW = env['fusion.plating.bake.window']
bws = BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')])
for bw in bws:
bw.action_start_bake()
bw.action_end_bake()
# Mark done
print(f' [Carlos] Mark Done')
job.button_mark_done()
print(f' job.state = {job.state}')
# CHECK: was a certificate auto-spawned?
Cert = env['fp.certificate']
certs = Cert.search([('sale_order_id', '=', so.id)])
if not certs:
# try x_fc_job_id link
certs = Cert.search([]) # last resort
print()
print(f' Certificates for this SO: {len(certs)}')
for c in certs[:3]:
print(f' {c.name}: state={c.state}, type={c.certificate_type}')
print(f' partner_id: {c.partner_id.name}')
print(f' spec_reference: {c.spec_reference!r}')
print(f' part_number: {c.part_number!r}')
print(f' quantity_shipped: {c.quantity_shipped}')
print(f' po_number: {c.po_number!r}')
print(f' attachment_id: {c.attachment_id.name if c.attachment_id else None}')
if not certs:
print(f' ❌ GAP: no cert auto-created!')
raise SystemExit
cert = certs[0]
# DISCOVERABILITY — would Tom find the cert from the job form?
print()
print(f' [Tom] Looking at the job form, smart-button row:')
print(f' job.certificate_count = {getattr(job, "certificate_count", "no field")}')
print(f' Smart button visible? (depends on certificate_count > 0)')
# Try to issue
print()
print(f' [Tom] Clicks Issue on the certificate:')
try:
cert.action_issue()
print(f' ✓ Issued: state={cert.state}')
except Exception as e:
print(f' ❌ Blocked: {str(e)[:200]}')
# If blocked due to spec_reference, fix and retry
if cert.state == 'draft' and not cert.spec_reference:
print()
print(f' [Tom] Manually fills spec_reference (workflow gap — should auto-fill from coating)')
cert.spec_reference = coating.spec_reference or 'AMS 2404'
try:
cert.action_issue()
print(f' ✓ Issued after manual fix: state={cert.state}')
except Exception as e:
print(f' ❌ Still blocked: {str(e)[:200]}')
# Try Send to Customer
print()
print(f' [Tom] Clicks Send to Customer:')
print(f' cert.attachment_id = {cert.attachment_id.name if cert.attachment_id else "(none — PDF not generated!)"}')
try:
act = cert.action_send_to_customer()
print(f' Composer opens. Default attachments: '
f'{act.get("context", {}).get("default_attachment_ids", "(none)")}')
except Exception as e:
print(f'{e}')
env.cr.commit()

View File

@@ -0,0 +1,155 @@
# Scenario 19 — Fischerscope thickness report PDF appended to CoC.
#
# Goal: when QC has a thickness_report_pdf_id uploaded by the operator
# on the tablet, action_issue should produce a multi-page CoC with the
# Fischerscope PDF as page 2+.
import base64
from odoo import fields
# Build a fresh job that requires QC + Fischerscope PDF
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Tpl = env['fp.qc.checklist.template']
TplLine = env['fp.qc.checklist.template.line']
QC = env['fusion.plating.quality.check']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
target.x_fc_requires_qc = True
coating = Coating.search([('spec_reference', '!=', False)], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Make sure default QC template requires Fischerscope PDF
default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
if default_tpl:
default_tpl.require_thickness_report_pdf = True
print(f' Using QC template: {default_tpl.name} (requires PDF)')
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-FISCHER-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Find the auto-spawned QC
qc = QC.search([('job_id', '=', job.id)], limit=1)
if not qc:
qc = QC.create_for_job(job)
print(f' QC: {qc.name}, lines={len(qc.line_ids)}')
# Operator uploads a fake "Fischerscope" PDF to the QC
# Use a real minimal PDF so the merge actually parses
minimal_pdf = (
b'%PDF-1.4\n'
b'1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n'
b'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n'
b'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R'
b'/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>>>endobj\n'
b'4 0 obj<</Length 88>>stream\n'
b'BT /F1 14 Tf 100 700 Td (FISCHERSCOPE THICKNESS REPORT) Tj '
b'0 -30 Td (Mean: 25.3 um Std: 1.2) Tj ET\n'
b'endstream endobj\n'
b'xref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n'
b'0000000054 00000 n \n0000000097 00000 n \n0000000189 00000 n \n'
b'trailer<</Size 5/Root 1 0 R>>\nstartxref\n330\n%%EOF\n'
)
att = env['ir.attachment'].create({
'name': 'fischer_test.pdf',
'datas': base64.b64encode(minimal_pdf),
'mimetype': 'application/pdf',
'type': 'binary',
})
qc.thickness_report_pdf_id = att.id
print(f' Uploaded Fischerscope PDF: {qc.thickness_report_pdf_id.name} '
f'({len(minimal_pdf)} bytes)')
# Walk QC lines + pass
for ln in qc.line_ids:
ln.result = 'pass'
qc.action_pass()
print(f' QC state: {qc.state}')
# Walk job to done
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.qty_done = 5
# Bake window
BW = env['fusion.plating.bake.window']
for bw in BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')]):
bw.action_start_bake()
bw.action_end_bake()
job.button_mark_done()
print(f' Job done')
# Fetch the auto-spawned cert
Cert = env['fp.certificate']
cert = Cert.search([('x_fc_job_id', '=', job.id)], limit=1)
print()
print(f' Cert: {cert.name}, state={cert.state}')
# v19.0.6.20.0 — new UI visibility fields (S19 Phase 2). Assert the
# operator would see "Will Append on Issue" badge BEFORE clicking Issue.
print(f' x_fc_thickness_status (pre-Issue): {cert.x_fc_thickness_status!r}')
print(f' x_fc_thickness_qc_id: {cert.x_fc_thickness_qc_id.name if cert.x_fc_thickness_qc_id else "(none)"}')
print(f' x_fc_thickness_pdf_id: {cert.x_fc_thickness_pdf_id.name if cert.x_fc_thickness_pdf_id else "(none)"}')
if cert.x_fc_thickness_status == 'pending':
print(f' ✓ UI banner WILL show "Fischerscope thickness PDF is on file"')
elif cert.x_fc_thickness_status == 'none':
print(f' ❌ UI says no PDF — merge would not run on Issue')
else:
print(f' ⚠️ unexpected status: {cert.x_fc_thickness_status}')
# Issue the cert — should render CoC + merge Fischerscope as page 2
cert.action_issue()
cert.invalidate_recordset(['x_fc_thickness_status', 'x_fc_thickness_qc_id', 'x_fc_thickness_pdf_id'])
print(f' After Issue: state={cert.state}')
print(f' x_fc_thickness_status (post-Issue): {cert.x_fc_thickness_status!r}')
if cert.x_fc_thickness_status == 'merged':
print(f' ✓ UI banner shows "Fischerscope thickness report merged"')
else:
print(f' ❌ UI status not flipping to merged: {cert.x_fc_thickness_status}')
print(f' attachment_id: {cert.attachment_id.name if cert.attachment_id else "(none)"}')
if cert.attachment_id:
pdf_bytes = base64.b64decode(cert.attachment_id.datas)
print(f' Total PDF size: {len(pdf_bytes)} bytes')
# Quick page count via pypdf
import io
try:
from pypdf import PdfReader
except ImportError:
from PyPDF2 import PdfReader
try:
reader = PdfReader(io.BytesIO(pdf_bytes))
print(f' Page count: {len(reader.pages)}')
if len(reader.pages) >= 2:
print(f' ✓ CoC + Fischerscope merged (multi-page)')
else:
print(f' ❌ Only 1 page — merge did not run')
except Exception as e:
print(f' ⚠️ couldn\'t parse output PDF: {e}')
# Look for chatter audit
msgs = cert.message_ids.filtered(lambda m: 'fischerscope' in (m.body or '').lower())
print(f' Chatter mentions Fischerscope: {len(msgs)}')
for m in msgs[:2]:
print(f' - {m.body[:120]}')
target.x_fc_requires_qc = False
env.cr.commit()

View File

@@ -0,0 +1,65 @@
# Scenario 9 — Carlos starts step, Bob (supervisor) reassigns to Mike.
# Verify chatter audit trail.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S9-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[0]
# Pretend Carlos owns this step.
step.assigned_user_id = env.user.id
step.button_start()
print(f'[Carlos] Started step "{step.name}" (state={step.state})')
print(f' assigned_user_id: {step.assigned_user_id.name}')
# Count chatter messages on the JOB before Bob reassigns.
before_count = len(job.message_ids)
print(f' Job chatter messages before reassign: {before_count}')
# Bob reassigns to "another user" — for the test we just write to ourselves
# to simulate, but the field write IS the operation.
print()
print('[Bob] Reassigning step to a different operator...')
# Use a different user if available.
other = env['res.users'].search([('id', '!=', env.user.id), ('share', '=', False)], limit=1)
if not other:
other = env.user # fallback — at least the write fires
step.assigned_user_id = other.id
step.invalidate_recordset()
job.invalidate_recordset()
after_count = len(job.message_ids)
print(f' After reassign: assigned_user_id={step.assigned_user_id.name}')
print(f' Job chatter messages: {after_count} (delta: {after_count - before_count})')
reassign_msgs = job.message_ids.filtered(
lambda m: 'reassign' in (m.body or '').lower()
)
print(f' Reassign-flagged chatter posts: {len(reassign_msgs)}')
if reassign_msgs:
print(f' ✓ Audit trail captured')
else:
print(f' ❌ GAP: silent reassignment, no chatter trail')
env.cr.commit()

View File

@@ -0,0 +1,395 @@
# -*- coding: utf-8 -*-
# E2E persona walk — order entry from start to finish.
#
# Personas:
# Sarah — Customer Service Rep
# Mike — Receiver
# Carlos — Plating Operator
# Lisa — QC Inspector
# Tom — Shipper
# Jane — Accounting
#
# This script fills every visible-to-operator field per step, walks the
# workflow with no shortcuts, asserts the data is sane after each phase,
# and prints what's actually visible in each form view.
import logging
import traceback
from datetime import date, timedelta
_log = logging.getLogger(__name__)
def section(title):
print(f'\n{"="*72}\n{title}\n{"="*72}')
def step(persona, msg):
print(f' [{persona:>7}] {msg}')
def fail(persona, msg):
print(f' [{persona:>7}] ❌ {msg}')
def find(persona, msg):
print(f' [{persona:>7}] 🔍 GAP: {msg}')
def e2e(env):
findings = []
# ----- pick a real partner with a recipe-able product -----
section('SETUP — pick a customer + a part already in the catalog')
Partner = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
partner = Partner.search([
('customer_rank', '>', 0),
('x_fc_account_hold', '=', False),
], limit=1)
if not partner:
partner = Partner.search([('customer_rank', '>', 0)], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1) \
or Part.search([], limit=1)
coating = part.x_fc_default_coating_config_id \
if part.x_fc_default_coating_config_id \
else Coating.search([], limit=1)
step('Sarah', f'Customer: {partner.display_name} (id={partner.id})')
step('Sarah', f'Part: {part.part_number or part.name} rev {part.revision or "?"} (id={part.id})')
step('Sarah', f'Coating: {coating.display_name if coating else "NONE"} (id={coating.id if coating else 0})')
if not coating:
findings.append('No fp.coating.config found in DB — cannot create realistic SO')
return findings
# ----- Sarah builds a sale order -----
section('PHASE 1 — Sarah (CSR) creates the sale order')
SO = env['sale.order']
SOL = env['sale.order.line']
so_vals = {
'partner_id': partner.id,
'x_fc_po_number': f'PO-E2E-{date.today():%y%m%d}',
'x_fc_customer_job_number': 'CUSTJOB-001',
'x_fc_contact_phone': '+1-555-0100',
'x_fc_ship_via': 'Customer pickup',
'x_fc_planned_start_date': date.today() + timedelta(days=2),
'x_fc_internal_deadline': date.today() + timedelta(days=10),
'commitment_date': date.today() + timedelta(days=14),
'x_fc_invoice_strategy': 'net_terms',
'x_fc_delivery_method': 'shipping_partner',
'x_fc_rush_order': False,
'x_fc_is_blanket_order': False,
'x_fc_internal_note': 'E2E test SO — full persona walk.',
'x_fc_external_note': 'Standard plating per spec.',
}
so = SO.create(so_vals)
step('Sarah', f'Created SO {so.name} (id={so.id})')
# add a line — fill the part / coating / treatment fields
product = env['product.product'].search([('sale_ok', '=', True)], limit=1)
if not product:
findings.append('No saleable product available for SO line')
return findings
line_vals = {
'order_id': so.id,
'product_id': product.id,
'product_uom_qty': 25,
'name': f'{part.part_number or part.name} — Plating per coating spec',
'x_fc_part_catalog_id': part.id,
'x_fc_coating_config_id': coating.id,
'x_fc_internal_description': 'Process via standard recipe; bake ASAP.',
'x_fc_job_number': 'INTJOB-001',
}
line = SOL.create(line_vals)
step('Sarah', f'Added line: {line.product_uom_qty} × {line.name[:40]}')
# confirm — does account hold block?
if partner.x_fc_account_hold:
find('Sarah', 'Customer is on account hold; SO confirm should block (or warn)')
try:
so.action_confirm()
step('Sarah', f'SO confirmed → state={so.state}')
except Exception as e:
fail('Sarah', f'SO confirm raised: {e}')
findings.append(f'SO confirm failure: {e}')
return findings
# ----- side effects: fp.job created? receiving created? -----
Job = env['fp.job']
Receiving = env['fp.receiving']
PortalJob = env['fusion.plating.portal.job']
jobs = Job.search([('sale_order_id', '=', so.id)])
receivings = Receiving.search([('sale_order_id', '=', so.id)])
portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
step('Sarah', f'After confirm: {len(jobs)} fp.job, {len(receivings)} fp.receiving, {len(portal_jobs)} portal.job')
if not jobs:
find('Sarah', 'NO fp.job auto-created on SO confirm! Operator has nothing to work.')
findings.append('SO confirm did not auto-spawn fp.job')
if not receivings:
find('Sarah', 'NO fp.receiving auto-created on SO confirm! Receiver has nothing to track.')
findings.append('SO confirm did not auto-spawn fp.receiving')
if jobs and not portal_jobs:
find('Sarah', 'fp.job exists but no portal.job mirror — customer can\'t track on portal.')
findings.append('Portal job mirror missing post-confirm')
# smart-button visibility check
so._compute_smart_button_visibility()
so._compute_fp_qc_counts()
step('Sarah', f'SO smart buttons: BOM Items visible? {so.x_fc_distinct_part_count >= 2} (count={so.x_fc_distinct_part_count}); '
f'By Job Group visible? {so.x_fc_has_wo_group_tag}; '
f'NCRs visible? {so.fp_qc_ncr_count_so > 0} (count={so.fp_qc_ncr_count_so})')
# ----- Mike receives parts -----
section('PHASE 2 — Mike (Receiver) processes inbound parts')
receiving = receivings[:1]
if not receiving:
receiving = Receiving.create({
'sale_order_id': so.id,
'expected_qty': 25,
})
step('Mike', f'Manually created receiving {receiving.name} (auto-create did not fire)')
find('Mike', 'Had to manually create receiving — auto-create from SO confirm is missing')
findings.append('Auto-receiving on SO confirm not wired')
else:
step('Mike', f'Found auto-created receiving {receiving.name} (state={receiving.state})')
# operator fills carrier + box count
receiving.write({
'carrier_name': 'Purolator Ground',
'carrier_tracking': 'PUR-1Z9999E2E',
'box_count_in': 3,
'received_qty': 25,
})
step('Mike', f'Set box_count_in={receiving.box_count_in}, carrier={receiving.carrier_name}')
# walk the state machine: draft → counted → staged → closed
try:
receiving.action_mark_counted()
step('Mike', f'Marked Counted → state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert receiving.state == 'counted'
assert so.x_fc_receiving_status == 'partial', f'Expected partial after Counted, got {so.x_fc_receiving_status}'
except AssertionError as e:
fail('Mike', str(e))
findings.append(f'Receiving status mismatch after Counted: {e}')
except Exception as e:
fail('Mike', f'action_mark_counted failed: {e}')
findings.append(f'action_mark_counted: {e}')
try:
receiving.action_mark_staged()
step('Mike', f'Marked Staged → state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert receiving.state == 'staged'
assert so.x_fc_receiving_status == 'partial'
except Exception as e:
fail('Mike', f'action_mark_staged failed: {e}')
findings.append(f'action_mark_staged: {e}')
try:
receiving.action_close()
step('Mike', f'Closed receiving → state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert receiving.state == 'closed'
assert so.x_fc_receiving_status == 'received'
except Exception as e:
fail('Mike', f'action_close failed: {e}')
findings.append(f'receiving action_close: {e}')
# racking inspection should exist
if 'fp.racking.inspection' in env:
Inspection = env['fp.racking.inspection']
racks = Inspection.search([('sale_order_id', '=', so.id)])
step('Mike', f'Racking inspections for this SO: {len(racks)}')
if not racks:
find('Mike', 'Racking inspection NOT auto-created — racking crew has nothing to walk.')
findings.append('No racking inspection auto-created post-confirm')
# ----- Carlos works the plating job -----
section('PHASE 3 — Carlos (Operator) walks the plating job')
if not jobs:
fail('Carlos', 'No job to work — SO confirm did not spawn one. Skipping phase.')
else:
job = jobs[0]
step('Carlos', f'Job {job.name}: state={job.state}, qty={job.qty}, deadline={job.date_deadline}')
step('Carlos', f'Steps: {len(job.step_ids)} — recipe={job.recipe_id.name or "(none)"}')
if not job.step_ids:
find('Carlos', f'Job has zero steps! Recipe not assigned or not generated. Recipe field: {job.recipe_id}')
findings.append('Job confirmed with zero steps')
if job.step_ids:
first_step = job.step_ids.sorted('sequence')[0]
step('Carlos', f'Starting step {first_step.sequence}: {first_step.name}')
try:
first_step.button_start()
step('Carlos', f'After start: state={first_step.state}, started_by={first_step.started_by_user_id.name if first_step.started_by_user_id else "(none)"}')
except Exception as e:
fail('Carlos', f'button_start failed: {e}')
findings.append(f'step button_start: {e}')
try:
first_step.button_finish()
step('Carlos', f'After finish: state={first_step.state}, duration_actual={first_step.duration_actual}')
except Exception as e:
fail('Carlos', f'button_finish failed: {e}')
findings.append(f'step button_finish: {e}')
# walk the rest at warp speed
for s in job.step_ids.sorted('sequence')[1:]:
try:
if s.state == 'pending':
s.button_start()
if s.state == 'in_progress':
s.button_finish()
except Exception as e:
fail('Carlos', f'step {s.name} walk: {e}')
findings.append(f'step walk {s.name}: {e}')
done_count = len(job.step_ids.filtered(lambda st: st.state == 'done'))
step('Carlos', f'Walked {done_count}/{len(job.step_ids)} steps to done')
# try to mark job done — should hit QC gate if customer requires QC
wants_qc = 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc
step('Carlos', f'Customer requires QC? {wants_qc}')
try:
job.button_mark_done()
step('Carlos', f'Job done → state={job.state}, finished={job.date_finished}')
except Exception as e:
if wants_qc:
step('Carlos', f'(Expected) QC gate fired: {str(e)[:120]}')
else:
fail('Carlos', f'button_mark_done unexpectedly failed: {e}')
findings.append(f'button_mark_done: {e}')
# ----- Lisa runs QC -----
section('PHASE 4 — Lisa (QC) walks the checklist (if any)')
QC = env['fusion.plating.quality.check']
qcs = QC.search([('job_id', 'in', jobs.ids)]) if jobs else QC.browse()
step('Lisa', f'QC checks for this job: {len(qcs)}')
if jobs and 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc and not qcs:
find('Lisa', 'Customer requires QC but no QC check auto-spawned!')
findings.append('QC gate fired but no check spawned')
for qc in qcs:
step('Lisa', f'QC {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
# try to pass it
for ln in qc.line_ids:
try:
ln.write({'result': 'pass'})
except Exception:
pass
try:
qc.action_pass()
step('Lisa', f'After action_pass: state={qc.state}')
except Exception as e:
fail('Lisa', f'action_pass failed: {e}')
findings.append(f'qc action_pass: {e}')
# retry job done if blocked
if jobs:
job = jobs[0]
if job.state != 'done':
try:
job.button_mark_done()
step('Lisa', f'Job marked done after QC pass → state={job.state}')
except Exception as e:
fail('Lisa', f'Job still blocked: {e}')
findings.append(f'Job blocked post-QC: {e}')
# ----- Tom ships -----
section('PHASE 5 — Tom (Shipper) prepares the delivery')
Delivery = env['fusion.plating.delivery']
deliveries = Delivery.search([
'|', ('job_ref', 'in', jobs.mapped('name') if jobs else []),
('x_fc_job_id', 'in', jobs.ids) if jobs else (False, False, False),
]) if jobs else Delivery.browse()
step('Tom', f'Deliveries linked to this job: {len(deliveries)}')
if jobs and jobs[0].state == 'done' and not deliveries:
find('Tom', 'Job is done but NO delivery auto-created!')
findings.append('Delivery auto-create on job done missing')
for delivery in deliveries:
method = (
getattr(delivery, 'x_fc_delivery_method', None)
or getattr(delivery, 'delivery_method', None)
or '(no method field)'
)
step('Tom', f'Delivery {delivery.name}: state={delivery.state}, method={method}')
try:
if hasattr(delivery, 'action_schedule') and delivery.state == 'draft':
delivery.action_schedule()
step('Tom', f'Scheduled → state={delivery.state}')
except Exception as e:
fail('Tom', f'schedule: {e}')
# certificates
Cert = env['fp.certificate']
certs = Cert.search([('x_fc_job_id', 'in', jobs.ids)]) if jobs else Cert.browse()
step('Tom', f'Certificates for this job: {len(certs)}')
if jobs and jobs[0].state == 'done' and not certs:
find('Tom', 'Job done but NO certificate auto-generated.')
findings.append('Certificate auto-create missing')
# ----- Jane invoices -----
section('PHASE 6 — Jane (Accounting) creates and posts invoice')
invoices_before = env['account.move'].search_count([
('invoice_origin', '=', so.name),
])
try:
if so.invoice_status == 'to invoice':
inv_action = so._create_invoices()
step('Jane', f'Invoiced — {invoices_before}{env["account.move"].search_count([("invoice_origin","=",so.name)])} moves')
else:
step('Jane', f'invoice_status={so.invoice_status} (nothing to invoice)')
except Exception as e:
fail('Jane', f'_create_invoices failed: {e}')
findings.append(f'invoice creation: {e}')
# ----- common-sense edge case sweeps -----
section('PHASE 7 — common-sense edge case sweeps')
# smart-button results: do they actually return non-empty data?
section_name = ' smart-button result probes'
print(section_name)
if jobs:
job = jobs[0]
for action in ('action_view_fp_holds', 'action_view_fp_checks',
'action_view_fp_ncrs', 'action_view_fp_capas',
'action_view_fp_rmas'):
try:
act = getattr(job, action)()
domain = act.get('domain') or []
model = act.get('res_model')
count = env[model].search_count(domain) if model else 0
step('audit', f'{action}: model={model}, domain count={count}')
except Exception as e:
fail('audit', f'{action}: {e}')
findings.append(f'{action}: {e}')
# SO smart-buttons
for action in ('action_view_fp_holds', 'action_view_fp_checks',
'action_view_fp_ncrs_so', 'action_view_fp_capas',
'action_view_fp_rmas'):
try:
act = getattr(so, action)()
domain = act.get('domain') or []
model = act.get('res_model')
count = env[model].search_count(domain) if model else 0
step('audit', f'SO {action}: model={model}, domain count={count}')
except Exception as e:
fail('audit', f'SO {action}: {e}')
findings.append(f'SO {action}: {e}')
# final summary
section('SUMMARY')
if findings:
print(f'{len(findings)} finding(s):')
for i, f in enumerate(findings, 1):
print(f' {i}. {f}')
else:
print(' ✅ No findings — workflow is clean end-to-end.')
env.cr.commit()
return findings
# entry-point: env injected by odoo-shell
try:
findings = e2e(env) # noqa
except Exception as e:
print('FATAL:', e)
traceback.print_exc()

View File

@@ -0,0 +1,60 @@
# Step 1 verification — Direct Order wizard onchange + hold guard fixes.
W = env['fp.direct.order.wizard']
ISD = env['fp.invoice.strategy.default']
P = env['res.partner']
target = P.browse(2529) # 2CM INNOVATIVE
print('Test 1 — customer with NO invoice strategy default:')
ISD.search([('partner_id', '=', target.id)]).unlink()
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy}, payment_term={w.payment_term_id.name if w.payment_term_id else None}')
print('\nTest 2 — customer WITH strategy default but NO payment_term:')
isd = ISD.create({'partner_id': target.id, 'default_strategy': 'net_terms'})
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy} (expect: net_terms)')
print(f' payment_term={w.payment_term_id.name if w.payment_term_id else None}')
isd.unlink()
print('\nTest 3 — customer with strategy + deposit + payment_term:')
pt = env['account.payment.term'].search([], limit=1)
isd = ISD.create({
'partner_id': target.id, 'default_strategy': 'deposit',
'default_deposit_percent': 50.0, 'payment_term_id': pt.id,
})
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy} (expect: deposit)')
print(f' deposit_percent={w.deposit_percent} (expect: 50.0)')
print(f' payment_term={w.payment_term_id.name} (expect: {pt.name})')
isd.unlink()
print('\nTest 4 — account-hold warning fires on partner change:')
target.x_fc_account_hold = True
w = W.new({'partner_id': target.id})
result = w._onchange_partner_id()
warning = (result or {}).get('warning')
print(f' warning title: {warning.get("title") if warning else None}')
print(f' warning msg: {(warning.get("message") or "")[:100] if warning else None}')
print('\nTest 5 — account-hold blocks action_create_order:')
w = W.create({'partner_id': target.id, 'po_pending': True})
# add one line so the line check passes
part = env['fp.part.catalog'].search([], limit=1)
coating = env['fp.coating.config'].search([], limit=1)
env['fp.direct.order.line'].create({
'wizard_id': w.id,
'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 1,
'unit_price': 10.0,
})
try:
w.action_create_order()
print(' ❌ HELD CUSTOMER CREATED ORDER — guard failed')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
target.x_fc_account_hold = False
env.cr.commit()

View File

@@ -0,0 +1,37 @@
# Step 2 verification — picking a part on the wizard line pre-fills coating + treatments.
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
P = env['res.partner']
target = P.browse(2529)
# Pick a part that has a default coating + treatments configured.
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
if not part:
# Build a synthetic one for the test.
coating = Coating.search([], limit=1)
treats = Treat.search([], limit=2)
part = Part.search([], limit=1)
part.x_fc_default_coating_config_id = coating.id
part.x_fc_default_treatment_ids = [(6, 0, treats.ids)]
print(f'Set up part {part.part_number}: default coating={coating.name}, treatments={treats.mapped("name")}')
print(f'Part: {part.part_number} rev {part.revision}')
print(f' default coating: {part.x_fc_default_coating_config_id.name if part.x_fc_default_coating_config_id else None}')
print(f' default treatments: {part.x_fc_default_treatment_ids.mapped("name") if part.x_fc_default_treatment_ids else None}')
# Build a wizard, add an empty line, simulate Sarah picking the part.
w = W.create({'partner_id': target.id})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
print()
print(f'After picking part on line:')
print(f' coating_config_id: {ln.coating_config_id.name if ln.coating_config_id else None}')
print(f' treatment_ids: {ln.treatment_ids.mapped("name") if ln.treatment_ids else None}')
print(f' Pre-fill worked? {bool(ln.coating_config_id) and bool(ln.treatment_ids)}')
env.cr.commit()

View File

@@ -0,0 +1,107 @@
# Step 3 — Sarah hits "Create Order" in wizard, then confirms SO.
# Watch every side-effect: SO state, fp.job auto-spawn, fp.receiving
# auto-spawn, fp.racking.inspection, portal.job mirror, QC check.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529) # 2CM INNOVATIVE
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Build the wizard exactly the way Sarah would after Step 1+2 fixes.
w = W.create({
'partner_id': target.id,
'po_number': 'PO-STEP3-001',
'po_pending': False,
'customer_job_number': 'CUSTJOB-STEP3',
'planned_start_date': fields.Date.today(),
'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
'invoice_strategy': 'net_terms',
'delivery_method': 'shipping_partner',
'po_attachment_file': b'fake-pdf-bytes',
'po_attachment_filename': 'po.pdf',
})
w._onchange_partner_id()
print(f'[Sarah] Created wizard {w.name} for {target.display_name}')
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
ln_vals = ln._convert_to_write({n: ln[n] for n in ln._fields})
ln_vals.update({
'wizard_id': w.id,
'quantity': 25,
'unit_price': 12.50,
'line_description': 'EN plating per part default coating',
'internal_description': 'Standard recipe; bake within 4h.',
})
real_line = Line.create(ln_vals)
print(f'[Sarah] Added line: part={real_line.part_catalog_id.part_number}, '
f'coating={real_line.coating_config_id.name}, qty={real_line.quantity}')
# Hit Create Order.
print('[Sarah] Clicking "Create Order"...')
result = w.action_create_order()
so_id = (result or {}).get('res_id')
SO = env['sale.order']
so = SO.browse(so_id) if so_id else SO.search(
[('x_fc_po_number', '=', 'PO-STEP3-001')], order='id desc', limit=1,
)
print(f' -> SO created: {so.name} (state={so.state})')
# Now confirm the SO (Sarah does this from the SO form, not the wizard).
print('[Sarah] Confirming SO...')
try:
so.action_confirm()
print(f' -> SO state={so.state}, x_fc_receiving_status={so.x_fc_receiving_status}')
except Exception as e:
print(f' ❌ confirm failed: {e}')
env.cr.rollback()
raise SystemExit
# Verify side-effects.
print()
print('=== Side effects of SO confirm ===')
Job = env['fp.job']
jobs = Job.search([('sale_order_id', '=', so.id)])
print(f' fp.job auto-spawn: {len(jobs)} job(s)')
for j in jobs:
print(f' {j.name}: state={j.state}, qty={j.qty}, recipe={j.recipe_id.name or "(no recipe)"}, steps={len(j.step_ids)}')
Receiving = env['fp.receiving']
receivings = Receiving.search([('sale_order_id', '=', so.id)])
print(f' fp.receiving auto-spawn: {len(receivings)} record(s)')
for r in receivings:
print(f' {r.name}: state={r.state}, expected_qty={r.expected_qty}')
if 'fp.racking.inspection' in env:
Inspection = env['fp.racking.inspection']
racks = Inspection.search([('sale_order_id', '=', so.id)])
print(f' fp.racking.inspection auto-spawn: {len(racks)} record(s)')
for ri in racks:
print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
PortalJob = env['fusion.plating.portal.job']
portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
print(f' portal.job mirror: {len(portal_jobs)} record(s)')
for pj in portal_jobs:
print(f' {pj.name}: state={pj.state}')
QC = env['fusion.plating.quality.check']
qcs = QC.search([('job_id', 'in', jobs.ids)])
print(f' QC checks: {len(qcs)} (customer x_fc_requires_qc={getattr(target, "x_fc_requires_qc", "NOFIELD")})')
for qc in qcs:
print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
# x_fc_receiving_status check
print()
print(f' SO x_fc_receiving_status (post-confirm, no receipt yet): {so.x_fc_receiving_status}')
print(f' Expected: not_received (parts haven\'t arrived)')
env.cr.commit()
print()
print(f'== Step 3 complete. SO ID for next steps: {so.id} ==')

View File

@@ -0,0 +1,91 @@
# Step 4 — Mike receives parts. Walk the receiving form, fill every
# visible field, walk the state machine, verify SO status updates at
# every transition.
so = env['sale.order'].browse(423)
recv = env['fp.receiving'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Mike] Looking at receiving {recv.name}: state={recv.state}, expected_qty={recv.expected_qty}')
# Mike sees the form. What's required vs optional?
print()
print('Visible fields on the receiving form (per fp_receiving_views.xml):')
print(f' sale_order_id: {recv.sale_order_id.name} (readonly via related)')
print(f' partner_id: {recv.partner_id.name} (related)')
print(f' po_number: {recv.po_number}')
print(f' box_count_in: {recv.box_count_in} <-- Mike must set this')
print(f' expected_qty: {recv.expected_qty}')
print(f' received_qty: {recv.received_qty} <-- defaults to expected_qty per Sub 8')
print(f' qty_match: {recv.qty_match}')
print(f' carrier_name: {recv.carrier_name} <-- Mike fills this')
print(f' carrier_tracking: {recv.carrier_tracking} <-- Mike fills this')
# Mike fills the carrier + tracking + counts the boxes.
print()
print('[Mike] Filling carrier + tracking + box count...')
recv.write({
'carrier_name': 'Purolator Ground',
'carrier_tracking': 'PUR-1Z9999E2E',
'box_count_in': 3,
'received_qty': 25, # all 25 arrived
'notes': '<p>Truck arrived 10am. Boxes look clean.</p>',
})
print(f' recv.qty_match = {recv.qty_match} (expected vs received)')
print(f' SO status BEFORE marking counted: {so.x_fc_receiving_status}')
# Click "Mark Counted"
print()
print('[Mike] Clicks "Mark Counted"')
try:
recv.action_mark_counted()
print(f' recv.state = {recv.state}')
print(f' recv.received_by_id = {recv.received_by_id.name}')
print(f' SO status AFTER mark counted: {so.x_fc_receiving_status}')
print(f' Expected: partial (boxes on dock, racking pending)')
assert so.x_fc_receiving_status == 'partial', 'SO status should be partial!'
print(' ✓ SO status correctly flipped to partial')
except Exception as e:
print(f'{e}')
# Click "Mark Staged"
print()
print('[Mike] Clicks "Mark Staged"')
try:
recv.action_mark_staged()
print(f' recv.state = {recv.state}')
print(f' SO status: {so.x_fc_receiving_status} (should still be partial)')
assert so.x_fc_receiving_status == 'partial'
print(' ✓ Still partial — racking not done yet')
except Exception as e:
print(f'{e}')
# Mike clicks the new "Racking Inspections" smart button (Round 2 fix)
print()
print('[Mike] Clicks the "Racking Inspections" smart button')
try:
act = recv.action_view_racking_inspections()
Inspection = env['fp.racking.inspection']
racks = Inspection.search(act.get('domain') or [])
print(f' Smart-button opens model={act.get("res_model")}, finds {len(racks)} inspection(s)')
for ri in racks:
print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
except Exception as e:
print(f'{e}')
# At this point Mike's done — racking crew takes over.
# But the receiving stays at "staged" until racking crew finishes
# inspection and someone clicks "Close" on the receiving.
# Let's pretend racking is done and close the receiving.
print()
print('[Mike] (or shop manager) Clicks "Close Receiving" once racking is done')
try:
recv.action_close()
print(f' recv.state = {recv.state}')
print(f' SO status AFTER close: {so.x_fc_receiving_status}')
assert so.x_fc_receiving_status == 'received'
print(' ✓ SO status correctly flipped to received')
except Exception as e:
print(f'{e}')
print()
print(f'== Step 4 complete. SO {so.name} status={so.x_fc_receiving_status}, recv {recv.name} state={recv.state} ==')
env.cr.commit()

View File

@@ -0,0 +1,99 @@
# Step 5 — Carlos walks the plating job. Test BOTH paths:
# A) Try to mark_done with steps still ready → must be blocked
# B) Walk every step → mark_done succeeds
# Build a fresh SO + job (don't reuse 423 — its job is already done).
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP5-001',
'planned_start_date': fields.Date.today(),
'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
'invoice_strategy': 'net_terms',
'delivery_method': 'shipping_partner',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': ln.coating_config_id.id,
'quantity': 10, 'unit_price': 15.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Carlos] Fresh job {job.name} for SO {so.name}')
print(f' Steps: {len(job.step_ids)}, all in state: {set(job.step_ids.mapped("state"))}')
# Path A: try mark_done without walking steps.
print()
print('[Carlos] Try Mark Done WITHOUT walking any step (compliance test):')
try:
job.button_mark_done()
print(' ❌ JOB CLOSED WITH ZERO STEPS WALKED — guard failed')
except Exception as e:
print(f' ✓ blocked: {str(e)[:200]}')
# Path B: walk every step then mark_done.
print()
print('[Carlos] Walk every step, then Mark Done:')
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
done_count = len(job.step_ids.filtered(lambda s: s.state == 'done'))
print(f' walked {done_count}/{len(job.step_ids)} to done')
try:
job.button_mark_done()
print(f' ✓ Job marked done — state={job.state}, finished={job.date_finished}')
except Exception as e:
print(f' ❌ Mark Done failed AFTER walking: {e}')
# Verify side effects on this job too.
Delivery = env['fusion.plating.delivery']
deliveries = Delivery.search([('x_fc_job_id', '=', job.id)])
Cert = env['fp.certificate']
certs = Cert.search([('x_fc_job_id', '=', job.id)])
print(f' Side effects: {len(deliveries)} delivery, {len(certs)} certificate')
# Path C: manager bypass (admin is a manager).
print()
print('[Mgr] Test manager bypass via context fp_skip_step_gate=True')
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP5-002',
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
Line.create({
'wizard_id': w2.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 15.0,
})
r2 = w2.action_create_order()
so2 = env['sale.order'].browse(r2['res_id'])
so2.action_confirm()
job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
print(f' Created fresh job {job2.name} with {len(job2.step_ids)} unworked steps')
try:
job2.with_context(fp_skip_step_gate=True).button_mark_done()
print(f' ✓ Manager bypass worked — job state={job2.state}')
except Exception as e:
print(f' ❌ Bypass failed: {e}')
env.cr.commit()
print()
print('== Step 5 complete ==')

View File

@@ -0,0 +1,103 @@
# Step 6 — Lisa walks the QC checklist for a customer that requires QC.
# Test:
# A) Customer requires QC but no template configured → spawn fails gracefully?
# B) Customer requires QC + template configured → check auto-spawns on confirm
# C) Lisa walks the checklist, marks lines, action_pass
# D) Job mark_done now lets through
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Tpl = env['fp.qc.checklist.template']
TplLine = env['fp.qc.checklist.template.line']
QC = env['fusion.plating.quality.check']
# Find or create a QC template (default, no partner_id) for the test.
default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
if not default_tpl:
default_tpl = Tpl.create({
'name': 'Default QC Template (E2E)',
'active': True,
})
TplLine.create({'template_id': default_tpl.id, 'sequence': 10, 'name': 'Visual inspection — appearance'})
TplLine.create({'template_id': default_tpl.id, 'sequence': 20, 'name': 'Thickness measurement (Fischerscope)'})
TplLine.create({'template_id': default_tpl.id, 'sequence': 30, 'name': 'Tape adhesion test'})
print(f'[setup] Created default QC template: {default_tpl.name} ({len(default_tpl.line_ids)} lines)')
else:
print(f'[setup] Using existing default QC template: {default_tpl.name}')
# Mark our test customer as requires_qc.
target = P.browse(2529)
target.x_fc_requires_qc = True
print(f'[setup] Set {target.display_name}.x_fc_requires_qc = True')
# Build a fresh SO + job for QC test.
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP6-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Sarah] Confirmed SO {so.name} → job {job.name}')
# Did QC auto-spawn?
qcs = QC.search([('job_id', '=', job.id)])
print(f'[Lisa] QC checks auto-spawned: {len(qcs)}')
for qc in qcs:
print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}, partner_id={qc.partner_id.name}')
if not qcs:
print(' ❌ Customer requires QC but no check spawned!')
raise SystemExit
qc = qcs[0]
# Lisa walks every checklist line.
print()
print('[Lisa] Walks the checklist:')
for ln in qc.line_ids.sorted('sequence'):
print(f' - {ln.name}: result before={ln.result}')
ln.result = 'pass'
ln.notes = 'OK on inspection'
# Try to action_pass.
print()
print('[Lisa] Clicks "Pass":')
try:
qc.action_pass()
print(f' ✓ QC state={qc.state}, overall_result={qc.overall_result}')
except Exception as e:
print(f'{e}')
# Now job mark_done should work (steps need to be walked first).
print()
print('[Carlos+Lisa] Walking steps then marking job done:')
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
try:
job.button_mark_done()
print(f' ✓ Job done — state={job.state}')
except Exception as e:
print(f' ❌ Job mark_done blocked: {e}')
# Reset partner flag for test independence.
target.x_fc_requires_qc = False
env.cr.commit()
print()
print('== Step 6 complete ==')

View File

@@ -0,0 +1,93 @@
# Step 7 — Tom (Shipper) walks the delivery from draft to delivered.
# Test:
# A) Delivery exists post-job-done — what fields visible? what state?
# B) Try action_start_route without driver → must block
# C) Assign driver + vehicle + box count, schedule
# D) Try action_mark_delivered without POD → must block
# E) Capture POD, mark delivered, verify cert + chain of custody
so = env['sale.order'].browse(423) # Step 3's SO
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
Delivery = env['fusion.plating.delivery']
delivery = Delivery.search([('x_fc_job_id', '=', job.id)], limit=1)
print(f'[Tom] Looking at delivery {delivery.name}')
print()
print('Visible header on delivery form:')
print(f' partner_id: {delivery.partner_id.name}')
print(f' delivery_address_id: {delivery.delivery_address_id.name if delivery.delivery_address_id else None}')
print(f' contact_name: {delivery.contact_name}')
print(f' contact_phone: {delivery.contact_phone}')
print(f' job_ref: {delivery.job_ref}')
print(f' state: {delivery.state}')
print(f' scheduled_date: {delivery.scheduled_date}')
print(f' assigned_driver_id: {delivery.assigned_driver_id.name if delivery.assigned_driver_id else None}')
print(f' vehicle_id: {delivery.vehicle_id.name if delivery.vehicle_id else None}')
print(f' source_facility_id: {delivery.source_facility_id.name if delivery.source_facility_id else None}')
print(f' tdg_required: {delivery.tdg_required}')
print(f' pod_id: {delivery.pod_id.name if delivery.pod_id else None}')
# Tom schedules.
print()
print('[Tom] Clicks "Schedule"')
delivery.action_schedule()
print(f' state={delivery.state}')
# Tom tries to start route WITHOUT assigning a driver.
print()
print('[Tom] Tries Start Route without driver:')
try:
delivery.action_start_route()
print(' ❌ Got past driver gate without assignment!')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
# Assign a driver (any user).
driver = env.user
delivery.assigned_driver_id = driver.id
delivery.x_fc_box_count_out = 3
print()
print(f'[Tom] Assigned driver: {driver.name}, box_count_out=3')
# Now start route.
print()
print('[Tom] Clicks Start Route:')
try:
delivery.action_start_route()
print(f' state={delivery.state}')
print(f' custody events: {delivery.custody_event_count}')
except Exception as e:
print(f'{e}')
# Tom tries to mark delivered without POD.
print()
print('[Tom] Tries Mark Delivered without POD:')
try:
delivery.action_mark_delivered()
print(' ❌ Got past POD gate without capture!')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
# Tom captures POD.
POD = env['fusion.plating.proof.of.delivery']
pod = POD.create({
'delivery_id': delivery.id,
'recipient_name': 'Mark at receiving',
})
delivery.pod_id = pod.id
print()
print(f'[Tom] Captured POD: {pod.name}, recipient="{pod.recipient_name}"')
# Mark delivered.
print()
print('[Tom] Clicks Mark Delivered:')
try:
delivery.action_mark_delivered()
print(f' state={delivery.state}, delivered_at={delivery.delivered_at}')
print(f' custody events: {delivery.custody_event_count}')
except Exception as e:
print(f'{e}')
env.cr.commit()
print()
print('== Step 7 complete ==')

View File

@@ -0,0 +1,58 @@
# Step 8 re-verify — fresh SO with net_terms strategy should now get
# Net-30 payment term auto-filled, and the invoice should post.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
P = env['res.partner']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP8RV-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
w._onchange_invoice_strategy() # also fires _apply_strategy_payment_term
print(f'[Sarah] After invoice_strategy=net_terms, payment_term_id={w.payment_term_id.name if w.payment_term_id else None}')
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 4, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
print(f'[Sarah] SO {so.name} created, payment_term_id={so.payment_term_id.name if so.payment_term_id else None}')
so.action_confirm()
print(f'[Sarah] Confirmed → state={so.state}')
# Walk job to done so it's invoiceable.
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.button_mark_done()
print(f'[Carlos] Job {job.name} done')
# Jane invoices.
print()
print('[Jane] Creating + posting invoice:')
result = so._create_invoices()
inv = env['account.move'].search([('invoice_origin', '=', so.name)], order='id desc', limit=1)
print(f' Invoice {inv.name or "(unnamed draft)"}: state={inv.state}, payment_term={inv.invoice_payment_term_id.name if inv.invoice_payment_term_id else None}')
try:
inv.action_post()
print(f' ✓ Posted: state={inv.state}, payment_state={inv.payment_state}')
print(f' ✓ Invoice name: {inv.name}, due date: {inv.invoice_date_due}')
except Exception as e:
print(f'{e}')
env.cr.commit()
print()
print('== Step 8 re-verify complete ==')

View File

@@ -0,0 +1,76 @@
# Step 8 — Jane creates the invoice for the completed SO and posts it.
# Test:
# A) SO has invoice_status = 'to invoice' after delivery
# B) Jane creates the invoice
# C) Invoice draft has correct lines, taxes, payment terms
# D) Jane posts → invoice posted, account moves balanced
# E) Notification fires (best-effort)
so = env['sale.order'].browse(423)
print(f'[Jane] Looking at SO {so.name}')
print(f' state: {so.state}')
print(f' invoice_status: {so.invoice_status}')
print(f' amount_total: {so.amount_total} {so.currency_id.symbol}')
print(f' payment_term_id: {so.payment_term_id.name if so.payment_term_id else None}')
print(f' x_fc_invoice_strategy: {so.x_fc_invoice_strategy}')
print(f' partner.account hold? {getattr(so.partner_id, "x_fc_account_hold", False)}')
# What's already invoiced?
existing = env['account.move'].search([
('invoice_origin', '=', so.name),
])
print(f' Existing invoices for this SO: {len(existing)}')
for inv in existing:
print(f' {inv.name}: state={inv.state}, type={inv.move_type}, amount={inv.amount_total}')
# Path A: create invoices.
print()
print('[Jane] Clicks "Create Invoice"')
if so.invoice_status == 'to invoice':
try:
result = so._create_invoices()
new_invs = env['account.move'].search([
('invoice_origin', '=', so.name), ('id', 'not in', existing.ids),
])
print(f' Created {len(new_invs)} new invoice(s)')
for inv in new_invs:
print(f' {inv.name}: state={inv.state}, lines={len(inv.invoice_line_ids)}')
for ln in inv.invoice_line_ids:
print(f' - {ln.name[:50]}: qty={ln.quantity}, price={ln.price_unit}, subtotal={ln.price_subtotal}')
except Exception as e:
print(f'{e}')
else:
print(f' Skipped — invoice_status={so.invoice_status} (nothing to invoice)')
new_invs = env['account.move'].browse()
# Path B: post.
if new_invs:
inv = new_invs[0]
print()
print(f'[Jane] Posting invoice {inv.name}:')
try:
inv.action_post()
print(f' ✓ state={inv.state}')
print(f' payment_state={inv.payment_state}')
except Exception as e:
print(f'{e}')
# Verify the SO progress: invoice_status should now show 'invoiced'
print()
print(f'[Jane] After posting:')
print(f' SO invoice_status: {so.invoice_status}')
print(f' Outstanding receivables on partner: {sum(env["account.move"].search([("partner_id", "=", so.partner_id.id), ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("payment_state", "in", ("not_paid", "partial"))]).mapped("amount_residual"))}')
# Notification check.
print()
print(f'[Jane] Notification logs for this SO/invoice:')
NotifLog = env['fp.notification.log'] if 'fp.notification.log' in env else None
if NotifLog and new_invs:
logs = NotifLog.search([('source_record_id', 'in', new_invs.ids)])
print(f' {len(logs)} notification log(s)')
for lg in logs:
print(f' {lg.trigger_event}{lg.partner_id.name if lg.partner_id else "(no partner)"} sent_at={lg.sent_at if "sent_at" in lg._fields else "?"}')
env.cr.commit()
print()
print('== Step 8 complete ==')

View File

@@ -0,0 +1,138 @@
# Verify the auto-push-to-defaults behaviour.
#
# Four scenarios:
# A) Brand-new part (no defaults) → push_to_defaults auto-ticks +
# warning popup fires
# B) Existing part WITH defaults → push_to_defaults stays False (no
# surprise overwrite)
# C) Brand-new part flagged is_one_off → push_to_defaults stays False
# D) End-to-end: enter order with new part → confirm → second order
# with same part auto-pre-fills coating + treatments
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
P = env['res.partner']
target = P.browse(2529)
coating = Coating.search([], limit=1)
treats = Treat.search([], limit=2)
# ====================================================================== A
print('='*72)
print('Scenario A — Brand-new part (no defaults)')
print('='*72)
fresh = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-A-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Fresh part for auto-default test',
})
w = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = fresh
result = ln._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln.push_to_defaults} (expect True)')
print(f' is_one_off: {ln.is_one_off}')
print(f' warning fired? {bool(result and result.get("warning"))}')
if result and result.get('warning'):
w_msg = result['warning']
print(f' title: {w_msg["title"]}')
print(f' message: {w_msg["message"][:90]}...')
# ====================================================================== B
print()
print('='*72)
print('Scenario B — Existing part WITH defaults already set')
print('='*72)
existing = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
print(f' Using part: {existing.display_name} (default coating={existing.x_fc_default_coating_config_id.name})')
w2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w2._onchange_partner_id()
ln2 = Line.new({'wizard_id': w2.id})
ln2.part_catalog_id = existing
result2 = ln2._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln2.push_to_defaults} (expect False — defaults already exist)')
print(f' pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
print(f' warning fired? {bool(result2 and result2.get("warning"))} (expect False)')
# ====================================================================== C
print()
print('='*72)
print('Scenario C — Brand-new part flagged is_one_off (don\'t persist)')
print('='*72)
fresh3 = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-C-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
})
w3 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w3._onchange_partner_id()
ln3 = Line.new({'wizard_id': w3.id, 'is_one_off': True})
ln3.part_catalog_id = fresh3
result3 = ln3._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln3.push_to_defaults} (expect False — is_one_off blocks)')
print(f' warning fired? {bool(result3 and result3.get("warning"))} (expect False)')
# ====================================================================== D
print()
print('='*72)
print('Scenario D — End-to-end: order #1 saves defaults, order #2 pre-fills')
print('='*72)
fresh_d = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-D-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
})
print(f' Created fresh part: {fresh_d.part_number}')
# ORDER #1
w_d = W.create({'partner_id': target.id, 'po_pending': True, 'po_number': 'PO-AUTO-D-1', 'invoice_strategy': 'net_terms'})
w_d._onchange_partner_id()
ln_d = Line.new({'wizard_id': w_d.id})
ln_d.part_catalog_id = fresh_d
ln_d._onchange_part_clears_variant()
print(f' Order #1 line — auto-ticked push_to_defaults: {ln_d.push_to_defaults}')
# Sarah picks the coating + treatments she wants
saved = Line.create({
'wizard_id': w_d.id, 'part_catalog_id': fresh_d.id,
'coating_config_id': coating.id,
'treatment_ids': [(6, 0, treats.ids)],
'push_to_defaults': True,
'quantity': 5, 'unit_price': 12.0,
})
print(f' Sarah picked coating={coating.name}, treatments={treats.mapped("name")}')
# Confirm
result = w_d.action_create_order()
print(f' Order created: {env["sale.order"].browse(result["res_id"]).name}')
# Re-fetch the part
fresh_d.invalidate_recordset()
print(f' Part defaults after order #1:')
print(f' x_fc_default_coating_config_id: {fresh_d.x_fc_default_coating_config_id.name if fresh_d.x_fc_default_coating_config_id else "(none)"}')
print(f' x_fc_default_treatment_ids: {fresh_d.x_fc_default_treatment_ids.mapped("name") if fresh_d.x_fc_default_treatment_ids else "(none)"}')
# ORDER #2 — Sarah picks the same part again
print()
print(' Order #2 — Sarah picks the same part:')
w_d2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w_d2._onchange_partner_id()
ln_d2 = Line.new({'wizard_id': w_d2.id})
ln_d2.part_catalog_id = fresh_d
ln_d2._onchange_part_clears_variant()
print(f' Pre-filled coating: {ln_d2.coating_config_id.name if ln_d2.coating_config_id else "(none)"}')
print(f' Pre-filled treatments: {ln_d2.treatment_ids.mapped("name") if ln_d2.treatment_ids else "(none)"}')
print(f' push_to_defaults: {ln_d2.push_to_defaults} (expect False — defaults exist)')
if ln_d2.coating_config_id == coating:
print(f' ✓ Order #2 correctly auto-filled from order #1\'s saved defaults')
else:
print(f' ❌ Order #2 did NOT pre-fill from order #1\'s defaults')
env.cr.commit()
print()
print('== Auto-default test complete ==')

View File

@@ -0,0 +1,171 @@
# Comprehensive internal-process walk.
#
# Phases:
# A) Pause / resume — multiple intervals merge into duration_actual
# B) Skip an opt-in step
# C) Skipped steps don't block job mark-done
# D) Wet plating step finish auto-spawns bake.window with right window_hours
# E) Bake-window state evolves (awaiting_bake → bake_in_progress → baked)
# F) Failure: try to start a step already done
import time
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
# Find or build a coating that requires bake relief.
coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
if not coating:
coating = Coating.search([], limit=1)
coating.requires_bake_relief = True
coating.bake_window_hours = 4.0
coating.bake_temperature = 375.0
coating.bake_temperature_uom = 'F'
coating.bake_duration_hours = 4.0
print(f'[setup] Configured {coating.name} to require bake relief (4h window @ 375°F for 4h)')
else:
print(f'[setup] Using existing bake-required coating: {coating.name} ({coating.bake_window_hours}h window)')
# Build a part using this coating as default.
part = Part.create({
'partner_id': target.id,
'part_number': 'INT-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Internal-process test bracket',
'substrate_material': 'steel',
'x_fc_default_coating_config_id': coating.id,
})
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-INT-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 22.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[setup] Job {job.name} with {len(job.step_ids)} steps')
# ====================================================================== A
print()
print('='*72)
print('A — Pause + resume on a step. Multiple intervals must merge.')
print('='*72)
masking = job.step_ids.sorted('sequence')[0]
masking.button_start()
print(f' start → state={masking.state}, open logs={len(masking.time_log_ids)}')
time.sleep(2)
masking.button_pause()
print(f' pause → state={masking.state}, logs={len(masking.time_log_ids)}, '
f'log[0]={masking.time_log_ids[0].duration_minutes:.3f} min')
time.sleep(2)
masking.button_start() # resume
print(f' resume → state={masking.state}, logs={len(masking.time_log_ids)}')
time.sleep(2)
masking.button_finish()
print(f' finish → state={masking.state}, logs={len(masking.time_log_ids)}')
total = sum(masking.time_log_ids.mapped('duration_minutes'))
print(f' duration_actual={masking.duration_actual:.3f} min (sum of logs={total:.3f} min)')
if abs(masking.duration_actual - total) < 0.001:
print(f' ✓ Pause/resume merged correctly')
else:
print(f' ❌ Mismatch')
# ====================================================================== B
print()
print('='*72)
print('B — Skip an opt-in step')
print('='*72)
racking = job.step_ids.sorted('sequence')[1]
print(f' Step: {racking.name} state={racking.state}')
racking.button_skip()
print(f' After Skip: state={racking.state}')
if racking.state == 'skipped':
print(f' ✓ Skip works')
# ====================================================================== C — walk rest, then mark-done
print()
print('='*72)
print('C — Walk remaining steps (some will spawn bake-window). Mark job done.')
print('='*72)
spawn_count_before = env['fusion.plating.bake.window'].search_count([])
for s in job.step_ids.sorted('sequence'):
if s.state in ('done', 'skipped', 'cancelled'):
continue
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
spawn_count_after = env['fusion.plating.bake.window'].search_count([])
created_bw = spawn_count_after - spawn_count_before
print(f' Walked all remaining steps to done')
print(f' Bake windows spawned during walk: {created_bw}')
bws = env['fusion.plating.bake.window'].search([('part_ref', '=', job.name)])
for bw in bws:
print(f' {bw.name}: state={bw.state}, plate_exit={bw.plate_exit_time}, required_by={bw.bake_required_by}, time_remaining={bw.time_remaining_display}')
# ====================================================================== D — try to mark job done
print()
print('='*72)
print('D — Mark job done (skipped+done steps both count as terminal)')
print('='*72)
try:
job.button_mark_done()
print(f' ✓ Job done — state={job.state}')
except Exception as e:
print(f'{e}')
# ====================================================================== E — bake-window lifecycle
if bws:
bw = bws[0]
print()
print('='*72)
print('E — Bake-window lifecycle: start → end')
print('='*72)
print(f' Before start: state={bw.state}, color={bw.status_color}')
bw.action_start_bake()
print(f' After start_bake: state={bw.state}, bake_start={bw.bake_start_time}, color={bw.status_color}')
time.sleep(1)
bw.action_end_bake()
print(f' After end_bake: state={bw.state}, bake_end={bw.bake_end_time}, duration_h={bw.bake_duration_hours:.4f}')
# ====================================================================== F — failure: start a done step
print()
print('='*72)
print('F — Failure paths')
print('='*72)
done_step = job.step_ids.filtered(lambda s: s.state == 'done')[:1]
if done_step:
try:
done_step.button_start()
print(f' ❌ Allowed re-start of a done step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
# Try to skip an already-done step
try:
done_step.button_skip()
print(f' ❌ Allowed skip of done step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
env.cr.commit()
print()
print('== Internal-process walk complete ==')

View File

@@ -0,0 +1,146 @@
# Internal-process walk — test time tracking, pause, skip, bake-window
# auto-spawn, duration overrun. Persona: Carlos (operator) walking the
# tablet station for a real plating job.
#
# Goals:
# 1) Time tracking captures every start/stop interval correctly
# 2) Multiple intervals (start/finish/start/finish) sum to duration_actual
# 3) Pause / resume flow works (currently NOT implemented — gap to fix)
# 4) Skip flow works for opt-in steps (currently NOT implemented)
# 5) Wet plating step finishing auto-spawns a bake.window when the
# coating requires hydrogen embrittlement relief
# 6) Bake-window state machine reflects elapsed time
import time
from datetime import timedelta
from odoo import fields
# Set up a fresh job to walk.
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-INTERNAL-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 18.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[setup] Fresh job {job.name} with {len(job.step_ids)} steps')
# ====================================================================== STEP 1
print()
print('='*72)
print('STEP 1 — Carlos opens the first step on the tablet, clicks Start')
print('='*72)
first = job.step_ids.sorted('sequence')[0]
print(f' Step: {first.name} (kind={first.kind}, state={first.state})')
print(f' duration_expected: {first.duration_expected} min')
before = fields.Datetime.now()
first.button_start()
print(f' After Start: state={first.state}, date_started={first.date_started}, started_by={first.started_by_user_id.name}')
print(f' Open time-log rows: {len(first.time_log_ids.filtered(lambda l: not l.date_finished))}')
# ====================================================================== STEP 2
print()
print('='*72)
print('STEP 2 — Carlos works for 6 seconds, then clicks Finish')
print('='*72)
time.sleep(6)
first.button_finish()
print(f' After Finish: state={first.state}, date_finished={first.date_finished}')
print(f' Time-log rows: {len(first.time_log_ids)}')
for log in first.time_log_ids:
print(f' - {log.user_id.name} {log.date_started}{log.date_finished or "OPEN"} = {log.duration_minutes:.3f} min')
print(f' duration_actual: {first.duration_actual:.3f} min')
print(f' ✓ Single interval captured cleanly')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Test pause/resume on the next step (currently NotImplementedError)')
print('='*72)
second = job.step_ids.sorted('sequence')[1]
second.button_start()
print(f' Started step: {second.name} (state={second.state})')
print(f' Carlos now needs a smoke break — clicks Pause')
try:
second.button_pause()
print(f' ✓ Paused: state={second.state}, open timelog={len(second.time_log_ids.filtered(lambda l: not l.date_finished))}')
except NotImplementedError as e:
print(f' ❌ button_pause not implemented: {e}')
except Exception as e:
print(f'{type(e).__name__}: {e}')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Test skip (currently NotImplementedError)')
print('='*72)
third = job.step_ids.sorted('sequence')[2]
print(f' Step: {third.name}, state={third.state}')
print(f' Planner wants to skip this opt-in step')
try:
third.button_skip()
print(f' ✓ Skipped: state={third.state}')
except NotImplementedError as e:
print(f' ❌ button_skip not implemented: {e}')
except Exception as e:
print(f'{type(e).__name__}: {e}')
# ====================================================================== STEP 5
print()
print('='*72)
print('STEP 5 — Wet plating step finishes, does a bake.window auto-spawn?')
print('='*72)
# Find a step with kind='wet' (or use step #4 as plating analog)
wet_step = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not wet_step:
wet_step = job.step_ids.sorted('sequence')[3:4]
print(f' Using as plating step: {wet_step.name} (kind={wet_step.kind})')
coating = job.coating_config_id
print(f' Coating: {coating.name}')
print(f' coating.requires_bake_relief: {coating.requires_bake_relief}')
print(f' coating.bake_window_hours: {coating.bake_window_hours}')
# Count bake.window before
BW = env['fusion.plating.bake.window']
bw_before = BW.search_count([('part_ref', '=', job.name)])
print(f' Bake windows for this job BEFORE finish: {bw_before}')
# Skip if currently in_progress (it is — paused step #2 still open)
if wet_step.state in ('pending', 'ready'):
wet_step.button_start()
if wet_step.state == 'in_progress':
wet_step.button_finish()
print(f' After Finish: state={wet_step.state}')
bw_after = BW.search_count([('part_ref', '=', job.name)])
print(f' Bake windows for this job AFTER finish: {bw_after}')
if coating.requires_bake_relief and bw_after == bw_before:
print(f' ❌ Coating requires bake relief BUT no bake.window was auto-created!')
elif not coating.requires_bake_relief:
print(f' (coating doesn\'t require bake relief — auto-spawn would skip anyway)')
else:
print(f' ✓ Bake window spawned')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,158 @@
# Walk: Sarah opens Direct Order, creates a brand-new part inline, attaches a process.
#
# Personas:
# Sarah (CSR) — driving the wizard
#
# What we're testing:
# 1) Wizard now allows creating a new part (no_quick_create lets the
# "Create and edit..." popup through)
# 2) Sarah enters a brand-new part number for the customer
# 3) Sarah picks coating + treatments
# 4) Variant dropdown is empty for the brand-new part (no variants yet)
# 5) On confirm, the part is saved to catalog + the SO line links to it
# 6) The job uses the coating's recipe as fallback (no variant means
# coating.recipe_id wins)
# 7) Sarah can THEN go to the part form, hit Compose, attach 1+ variants,
# and the next order can pick one
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
P = env['res.partner']
target = P.browse(2529) # Cyclone Manufacturing
default_coating = Coating.search([], limit=1)
print(f'[Sarah] Customer: {target.display_name}')
print(f'[Sarah] Picking coating: {default_coating.name}')
print()
# ====================================================================== STEP 2
print('='*72)
print('STEP 2 — Sarah opens wizard, hits "Create and edit..." on Part field')
print('='*72)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-NEWPART-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
print(f'[Sarah] Wizard {w.name} created.')
# In the UI, Sarah types a new part number → dropdown shows nothing →
# clicks "Create and edit..." → popup opens with partner pre-filled →
# fills part_number + name + revision (default A) → saves.
# Programmatic equivalent: just create the part directly.
new_part = Part.create({
'partner_id': target.id,
'part_number': 'NEW-INLINE-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Inline-created bracket',
'substrate_material': 'aluminium',
})
print(f'[Sarah] Filled popup → created part: {new_part.display_name}')
print(f' partner_id correctly set: {new_part.partner_id.name}')
print(f' part_number: {new_part.part_number}')
print(f' revision: {new_part.revision}')
print(f' default_process_id: {new_part.default_process_id.name if new_part.default_process_id else "(none — no variants composed yet)"}')
print(f' process_variant_count: {new_part.process_variant_count}')
# Now Sarah adds a line with the new part.
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = new_part
ln._onchange_part_clears_variant()
print()
print(f'[Sarah] Adds line with new part:')
print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none — new part has no defaults)"}')
print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
# Sarah picks coating manually (since new part has no defaults).
print(f'[Sarah] Manually picks coating: {default_coating.name}')
# Save the line.
real_line = Line.create({
'wizard_id': w.id,
'part_catalog_id': new_part.id,
'coating_config_id': default_coating.id,
'quantity': 8,
'unit_price': 18.0,
})
# Check variant dropdown availability
print()
print(f'[Sarah] Variant dropdown for new part:')
print(f' Available variants: {len(new_part.process_variant_ids)} (expect 0 — none composed yet)')
print(f' → Sarah leaves variant blank; coating.recipe_id will drive job')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Sarah confirms order, verify part landed in catalog + job uses coating recipe')
print('='*72)
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
print(f'[Sarah] SO {so.name} created.')
# Confirm
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Sarah] Confirmed → job {job.name}')
print(f' job.partner_id: {job.partner_id.name}')
print(f' job.part_catalog_id: {job.part_catalog_id.display_name}')
print(f' job.coating_config_id: {job.coating_config_id.name}')
print(f' job.recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
print(f' → Coating recipe used as fallback (correct, no variant picked)')
# Verify part is in catalog
print()
fetched = Part.search([('part_number', '=', new_part.part_number)], limit=1)
print(f' Part survives in catalog: {fetched.display_name} (id={fetched.id})')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Bob attaches a variant to the new part (compose flow)')
print('='*72)
from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
import _clone_subtree
Node = env['fusion.plating.process.node']
template = Node.search([
('node_type', '=', 'recipe'),
('parent_id', '=', False),
('part_catalog_id', '=', False),
], limit=1)
v1 = _clone_subtree(env, template, new_part, parent=False)
v1.variant_label = 'Standard'
v1.is_default_variant = True
new_part.default_process_id = v1.id
print(f'[Bob] Composed 1 variant: "{v1.variant_label}" (root id={v1.id})')
new_part.invalidate_recordset()
print(f' process_variant_count now: {new_part.process_variant_count}')
print(f' default_process_id: {new_part.default_process_id.name}')
# Now Sarah enters a SECOND order — this time variant dropdown should show "Standard"
print()
print('='*72)
print('STEP 5 — Sarah enters a follow-up order; variant dropdown should now show "Standard"')
print('='*72)
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-NEWPART-2-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
ln2 = Line.new({'wizard_id': w2.id})
ln2.part_catalog_id = new_part
ln2._onchange_part_clears_variant()
print(f'[Sarah] Picked the same part again. Variant dropdown:')
for v in new_part.process_variant_ids:
flag = '' if v.is_default_variant else ' '
print(f' {flag} {v.variant_label or v.name}')
print(f' Pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,190 @@
# Walk part creation + 4 process variants step by step.
# Personas:
# Bob (Estimator) — owns the part catalog, designs process variants
# Sarah (CSR) — picks a variant on order entry
#
# Goal: prove that
# 1) Bob can create a part
# 2) Bob can attach 4 distinct process variants via the Composer flow
# 3) One is flagged default; switching default works
# 4) Sarah opens a Direct Order, picks the part — variant dropdown lists ALL FOUR
# 5) Sarah picks a non-default variant; the SO + job actually use it
from odoo import fields
from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
import _list_variants, _clone_subtree
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
Node = env['fusion.plating.process.node']
Tpl = Node # template recipes are also fp.process.node records
# ====================================================================== STEP 2
print('='*72)
print('STEP 2 — Bob creates a brand-new part')
print('='*72)
target_partner = P.browse(2529) # 2CM INNOVATIVE
default_coating = Coating.search([], limit=1)
default_treats = Treat.search([], limit=2)
part = Part.create({
'partner_id': target_partner.id,
'part_number': 'E2E-VAR-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'E2E variant test bracket',
'substrate_material': 'aluminium',
'surface_area': 12.5,
'surface_area_uom': 'sq_in',
'weight': 0.45,
'complexity': 'simple',
'masking_zones': 1,
'x_fc_default_coating_config_id': default_coating.id,
'x_fc_default_treatment_ids': [(6, 0, default_treats.ids)],
})
print(f'[Bob] Created part: {part.display_name} (id={part.id})')
print(f' default coating: {part.x_fc_default_coating_config_id.name}')
print(f' default treatments: {default_treats.mapped("name")}')
print(f' process_variant_count (BEFORE adding any): {part.process_variant_count}')
# Find a shared template recipe to clone from. Templates = fp.process.node
# records with node_type='recipe', parent_id=False, part_catalog_id=False.
template = Node.search([
('node_type', '=', 'recipe'),
('parent_id', '=', False),
('part_catalog_id', '=', False),
], limit=1)
if not template:
print(' ❌ No shared template recipes available — cannot continue!')
raise SystemExit
print(f'[Bob] Will clone from shared template: {template.name} ({len(template.child_ids)} root children)')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Bob adds variant #1: Standard Production')
print('='*72)
v1 = _clone_subtree(env, template, part, parent=False)
v1.variant_label = 'Standard Production'
v1.is_default_variant = True
part.default_process_id = v1.id
print(f'[Bob] Created variant: {v1.variant_label} (root node id={v1.id}, name="{v1.name}")')
print(f' is_default: {v1.is_default_variant}')
print(f' child nodes cloned: {len(v1.child_ids)}')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Bob adds variant #2: Aerospace Cert (AS9100)')
print('='*72)
v2 = _clone_subtree(env, template, part, parent=False)
v2.variant_label = 'Aerospace Cert (AS9100)'
print(f'[Bob] Created variant: {v2.variant_label} (root id={v2.id})')
print(f' is_default: {v2.is_default_variant} (correct — first one stays default)')
# ====================================================================== STEP 5
print()
print('='*72)
print('STEP 5 — Bob adds variant #3: Quick-turn (no bake)')
print('='*72)
v3 = _clone_subtree(env, template, part, parent=False)
v3.variant_label = 'Quick-turn (no bake)'
print(f'[Bob] Created variant: {v3.variant_label} (root id={v3.id})')
# ====================================================================== STEP 6
print()
print('='*72)
print('STEP 6 — Bob adds variant #4: Heavy build (wear)')
print('='*72)
v4 = _clone_subtree(env, template, part, parent=False)
v4.variant_label = 'Heavy build (wear)'
print(f'[Bob] Created variant: {v4.variant_label} (root id={v4.id})')
# Refresh the part and inspect what the form would show.
part.invalidate_recordset()
print()
print(f'[Bob] After 4 adds — part {part.display_name}:')
print(f' process_variant_count: {part.process_variant_count}')
print(f' default_process_id: {part.default_process_id.name if part.default_process_id else None}')
print(f' Variants list (per Composer endpoint /fp/part/composer/state):')
for entry in _list_variants(part):
flag = '★ default' if entry['is_default'] else ' '
print(f' {flag} id={entry["id"]:>5} "{entry["label"]}"{entry["node_count"]} nodes')
# ====================================================================== STEP 7
print()
print('='*72)
print('STEP 7 — Sarah enters a Direct Order, picks the part, picks a variant')
print('='*72)
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
w = W.create({
'partner_id': target_partner.id, 'po_pending': True,
'po_number': 'PO-VARTEST-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
# Sarah adds a line, picks the part. Onchange should pre-fill default coating.
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
print(f'[Sarah] Picked part {part.part_number}.')
print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none)"}')
print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
# What variants would the dropdown show? Inspect process_variant_id field domain.
print()
print(f'[Sarah] Looking at the Variant dropdown on the line:')
# Domain on x_fc_process_variant_id (defined on sale.order.line) is part-scoped.
# For the wizard line it's process_variant_id with the same domain.
visible_variants = part.process_variant_ids
print(f' Domain: part_scoped (id, child_of, ...). Visible variants: {len(visible_variants)}')
for v in visible_variants:
flag = '' if v.is_default_variant else ' '
print(f' {flag} {v.variant_label or v.name} (id={v.id})')
# Sarah picks variant #3 (Quick-turn).
ln.process_variant_id = v3
print()
print(f'[Sarah] Picked variant: {ln.process_variant_id.variant_label}')
# Persist via Line.create with the chosen variant.
new_line = Line.create({
'wizard_id': w.id,
'part_catalog_id': part.id,
'coating_config_id': default_coating.id,
'process_variant_id': v3.id,
'quantity': 5,
'unit_price': 25.0,
})
print(f' Saved line: process_variant_id={new_line.process_variant_id.variant_label}')
# ====================================================================== STEP 8
print()
print('='*72)
print('STEP 8 — Confirm SO; verify the JOB uses variant #3, not the default')
print('='*72)
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
print(f'[Sarah] SO created: {so.name}')
# Inspect the SO line's variant.
sol = so.order_line[:1]
print(f' SO line process_variant_id: {sol.x_fc_process_variant_id.variant_label if sol.x_fc_process_variant_id else "(none)"}')
# Confirm the SO.
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f' Job created: {job.name}')
print(f' Job recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
print(f' EXPECTED: recipe_id should match variant #3 root (id={v3.id}, name="{v3.name}")')
print(f' ACTUAL: recipe_id={job.recipe_id.id} (name="{job.recipe_id.name}")')
if job.recipe_id.id == v3.id:
print(f' ✓ Job correctly inherited the picked variant')
else:
print(f' ❌ Job did NOT use the picked variant! Recipe is {job.recipe_id.name}, expected {v3.name}')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
# End-to-end order walkthrough — simulates each role on the shop floor.
#
# Run via odoo-shell:
# echo 'exec(open("/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_e2e_walkthrough.py").read())' \
# | /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
#
# Each step prints what the employee would see / type. Failures and
# missing affordances are printed with [GAP] tags.
import logging
_log = logging.getLogger(__name__)
GAPS = []
def gap(role, where, msg):
GAPS.append((role, where, msg))
print(f' [GAP] {role} @ {where}: {msg}')
def walk():
e = env # noqa -- env injected by odoo-shell
print('====================== E2E ORDER WALKTHROUGH ======================')
# ------------------------------------------------------------------
# ROLE: Sales / Estimator — open Plating > Sales > Quotations
# ------------------------------------------------------------------
print('\n[ROLE: Estimator] Plating > Sales > Quotations > New Quote')
# 1. Pick or create a customer
Partner = e['res.partner']
customer = Partner.search([('customer_rank', '>', 0)], limit=1)
if not customer:
gap('Estimator', 'res.partner', 'No customers in DB at all')
return
print(f' Customer chosen: {customer.display_name} (id={customer.id})')
# 2. Pick a part from the catalog (or create on the fly)
Part = e.get('fp.part.catalog') or (
e['fp.part.catalog'] if 'fp.part.catalog' in e else None
)
if Part is None or 'fp.part.catalog' not in e:
gap('Estimator', 'fp.part.catalog', 'Part catalog model missing')
return
Part = e['fp.part.catalog']
part = Part.search([], limit=1)
if not part:
gap('Estimator', 'fp.part.catalog',
'No parts in catalog — estimator has nothing to quote against')
return
print(f' Part chosen: {part.display_name} '
f'(part#={getattr(part, "part_number", "?")} '
f'rev={getattr(part, "revision", "?")})')
# 2a. Required-field walk (Sub 2 made part_number + revision required)
for f in ('part_number', 'revision', 'name'):
if f not in part._fields:
gap('Estimator', f'fp.part.catalog.{f}', 'field missing')
elif not part[f]:
gap('Estimator', f'fp.part.catalog.{f}',
f'value blank on existing record')
# 3. Pick a coating config
if 'fp.coating.config' not in e:
gap('Estimator', 'fp.coating.config', 'coating config model missing')
return
coating = e['fp.coating.config'].search([], limit=1)
if not coating:
gap('Estimator', 'fp.coating.config',
'No coating configs defined — estimator cannot configure quote')
else:
print(f' Coating chosen: {coating.display_name}')
# 4. Try to create a quote configurator session (the "New Quote" wizard)
if 'fp.quote.configurator' not in e:
gap('Estimator', 'fp.quote.configurator', 'configurator model missing')
return
Configurator = e['fp.quote.configurator']
cfg_vals = {
'partner_id': customer.id,
}
if 'part_catalog_id' in Configurator._fields and part:
cfg_vals['part_catalog_id'] = part.id
if 'coating_config_id' in Configurator._fields and coating:
cfg_vals['coating_config_id'] = coating.id
try:
cfg = Configurator.create(cfg_vals)
print(f' ✓ Configurator session created: {cfg.display_name}')
except Exception as ex:
gap('Estimator', 'fp.quote.configurator.create', str(ex))
return
# 4a. Try the "Create Quotation" path — what action confirms the SO?
so = False
for meth in ('action_create_quotation', 'action_promote_to_direct_order',
'action_create_sale_order', 'action_generate_quote'):
if hasattr(cfg, meth):
try:
result = getattr(cfg, meth)()
so = (
e['sale.order'].browse(result.get('res_id'))
if isinstance(result, dict) and result.get('res_id')
else (cfg.x_fc_sale_order_id if 'x_fc_sale_order_id' in cfg._fields else False)
)
print(f' ✓ Quote created via {meth}: '
f'{so.name if so else "(no SO returned)"}')
break
except Exception as ex:
gap('Estimator', f'configurator.{meth}', str(ex))
if not so:
# Fall back: create SO directly and see if the configurator workflow is wired.
gap('Estimator', 'configurator',
'No working "create quote" action found on the configurator '
'— estimator has no button to make a quote')
# Manual SO creation for the rest of the walkthrough
SO = e['sale.order']
try:
so = SO.create({
'partner_id': customer.id,
'order_line': [(0, 0, {
'product_id': (
e['product.product'].search(
[('sale_ok', '=', True)], limit=1).id
),
'product_uom_qty': 10,
})],
})
print(f' Fallback: hand-created SO {so.name}')
except Exception as ex:
gap('Estimator', 'sale.order.create (fallback)', str(ex))
return
# 5. Customer-facing fields on the SO line
if so.order_line:
line = so.order_line[0]
for f in ('x_fc_internal_description', 'x_fc_part_catalog_id',
'x_fc_coating_config_id', 'x_fc_thickness_id',
'x_fc_serial_id', 'x_fc_job_number'):
if f not in line._fields:
gap('Estimator', f'sale.order.line.{f}',
'expected field missing')
print(f' SO header fields: po={so.x_fc_po_number or "(blank)"}, '
f'invoice_strategy={so.x_fc_invoice_strategy}, '
f'rush={so.x_fc_rush_order}')
# ------------------------------------------------------------------
# ROLE: Estimator confirms the quote → SO
# ------------------------------------------------------------------
print('\n[ROLE: Estimator] Click Confirm on the quote')
# Estimator types in the customer PO# (real flow: paste from email)
if 'x_fc_po_number' in so._fields and not so.x_fc_po_number:
so.x_fc_po_number = 'TEST-PO-E2E-001'
print(f' Set x_fc_po_number=TEST-PO-E2E-001 on {so.name}')
if so.state == 'draft':
try:
so.action_confirm()
print(f' ✓ SO confirmed — state={so.state}')
except Exception as ex:
gap('Estimator', 'sale.order.action_confirm', str(ex))
return
else:
print(f' SO already in state {so.state}')
# 5a. Confirm side-effects fired
Job = e['fp.job']
jobs = Job.search([('sale_order_id', '=', so.id)])
if not jobs:
gap('Planner', 'fp.job auto-create',
'No fp.job auto-created on SO confirm — planner has nothing '
'to plan against')
else:
print(f'{len(jobs)} fp.job(s) created: '
f'{", ".join(jobs.mapped("name"))}')
# 5b. Receiving record auto-created?
Recv = e['fp.receiving']
receivings = Recv.search([('sale_order_id', '=', so.id)])
if not receivings:
gap('Receiver', 'fp.receiving auto-create',
'No fp.receiving auto-created on SO confirm — receiver has '
'nothing to count against')
else:
print(f' ✓ Receiving record(s): {", ".join(receivings.mapped("name"))}')
# 5c. Racking inspection auto-created on job confirm?
Insp = e['fp.racking.inspection']
insps = Insp.search([('sale_order_id', '=', so.id)])
if not insps and jobs:
gap('Racker', 'fp.racking.inspection auto-create',
'jobs exist but no racking inspection — racker walks empty')
elif insps:
print(f' ✓ Racking inspection(s): '
f'{", ".join(insps.mapped("name"))}')
# 5d. Portal job mirror auto-created?
PJ = e['fusion.plating.portal.job']
pjs = PJ.search([('partner_id', '=', customer.id)],
order='id desc', limit=2)
if pjs:
print(f' ✓ Portal job(s) for customer: '
f'{", ".join(pjs.mapped("name"))}')
else:
gap('Portal', 'portal job auto-create',
'No portal.job mirror — customer sees nothing on portal')
# ------------------------------------------------------------------
# ROLE: Receiver — Plating > Receiving > All Receiving
# ------------------------------------------------------------------
print('\n[ROLE: Receiver] Open the receiving record, count boxes')
if receivings:
r = receivings[0]
if 'box_count_in' not in r._fields:
gap('Receiver', 'fp.receiving.box_count_in', 'field missing')
else:
r.box_count_in = 3
print(f' Set box_count_in=3 on {r.name}')
if hasattr(r, 'action_mark_counted'):
try:
r.action_mark_counted()
print(f' ✓ Marked counted — state={r.state}')
except Exception as ex:
gap('Receiver', 'action_mark_counted', str(ex))
else:
gap('Receiver', 'fp.receiving',
'no action_mark_counted button')
if hasattr(r, 'action_mark_staged'):
try:
r.action_mark_staged()
print(f' ✓ Marked staged — state={r.state}')
except Exception as ex:
gap('Receiver', 'action_mark_staged', str(ex))
# Smart button to racking inspection?
if 'racking_inspection_count' in r._fields:
print(f' ✓ Receiving form shows '
f'{r.racking_inspection_count} racking inspection(s)')
else:
gap('Receiver', 'fp.receiving.racking_inspection_count',
'no smart button; receiver navigates manually')
# ------------------------------------------------------------------
# ROLE: Racking Crew — open the linked racking inspection
# ------------------------------------------------------------------
print('\n[ROLE: Racker] Open the racking inspection from receiving smart button')
if insps:
insp = insps[0]
# Real fields are line_count / ok_count / flagged_count (not "parts_*")
for f in ('line_count', 'ok_count', 'flagged_count', 'has_variance'):
if f not in insp._fields:
gap('Racker', f'fp.racking.inspection.{f}',
f'expected field missing')
# Real workflow: draft → inspecting (action_start) → done (action_complete)
if hasattr(insp, 'action_start'):
try:
insp.action_start()
print(f' ✓ Inspection started — state={insp.state}')
except Exception as ex:
gap('Racker', 'racking_inspection.action_start', str(ex))
if hasattr(insp, 'action_complete'):
try:
insp.action_complete()
print(f' ✓ Inspection completed — state={insp.state}')
except Exception as ex:
gap('Racker', 'racking_inspection.action_complete', str(ex))
# ------------------------------------------------------------------
# ROLE: Operator — runs the plating job step-by-step
# ------------------------------------------------------------------
print('\n[ROLE: Operator] Open the job, run each step')
if jobs:
job = jobs[0]
steps = job.step_ids.sorted('sequence')
if not steps:
gap('Operator', 'fp.job.step_ids',
'job has no steps — recipe not generated')
else:
print(f' Job {job.name} has {len(steps)} steps')
ran = 0
for step in steps[:3]: # walk the first 3
if step.state in ('ready', 'paused') and hasattr(step, 'button_start'):
try:
step.button_start()
step.button_finish()
ran += 1
except Exception as ex:
gap('Operator', f'step.{step.name}', str(ex))
else:
gap('Operator', f'step.{step.name}',
f"state={step.state} — operator can't start it")
print(f' ✓ Ran {ran} of 3 first steps')
# ------------------------------------------------------------------
# ROLE: Inspector — walk the QC checklist if customer requires QC
# ------------------------------------------------------------------
print('\n[ROLE: Inspector] Look for an open QC check on the job')
QC = e['fusion.plating.quality.check']
if jobs:
job = jobs[0]
# Customer might not be flagged x_fc_requires_qc — flip it for the test.
wants = ('x_fc_requires_qc' in customer._fields
and customer.x_fc_requires_qc)
print(f' Customer requires QC: {wants}')
if wants:
check = QC.search([('job_id', '=', job.id)], limit=1)
if not check:
gap('Inspector', 'QC.create_for_job',
'customer wants QC but no check was auto-spawned on confirm')
else:
print(f' ✓ QC check found: {check.name}')
# ------------------------------------------------------------------
# ROLE: Operator — try to mark job done (will hit QC gate if applicable)
# ------------------------------------------------------------------
print('\n[ROLE: Operator] Click Mark Done on the job')
if jobs:
job = jobs[0]
# Move all steps to done first so the job CAN be done
for step in job.step_ids:
if step.state in ('pending', 'in_progress'):
if step.state == 'pending' and hasattr(step, 'button_start'):
try:
step.button_start()
except Exception:
pass
if hasattr(step, 'button_finish'):
try:
step.button_finish()
except Exception:
pass
try:
job.with_context(fp_skip_qc_gate=True).button_mark_done()
print(f' ✓ Job marked done (with QC bypass) — state={job.state}')
except Exception as ex:
gap('Operator', 'fp.job.button_mark_done', str(ex))
# 5e. Delivery auto-created on done?
Del = e.get('fusion.plating.delivery') or (
e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
)
Del = e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
if Del is not None and jobs:
deliveries = Del.search([], order='id desc', limit=3)
print(f' Latest deliveries on system: '
f'{", ".join(deliveries.mapped("name") or ["(none)"])}')
# ------------------------------------------------------------------
# ROLE: Driver — picks up the delivery
# ------------------------------------------------------------------
print('\n[ROLE: Driver] Find the linked fusion.plating.delivery')
if Del is not None and jobs:
d = Del.search([('job_id', '=', jobs[0].id) if 'job_id' in Del._fields
else ('id', '=', 0)], limit=1)
if d:
print(f' ✓ Delivery {d.name} state={d.state}')
if hasattr(d, 'action_mark_delivered'):
try:
d.action_mark_delivered()
print(f' ✓ Marked delivered — state={d.state}')
except Exception as ex:
gap('Driver', 'delivery.action_mark_delivered', str(ex))
else:
print(' No delivery linked to job — checking by SO')
# ------------------------------------------------------------------
# ROLE: Accountant — invoice the SO
# ------------------------------------------------------------------
print('\n[ROLE: Accountant] Generate invoice')
print(f' invoice_status={so.invoice_status}')
if so and so.invoice_status == 'to invoice':
try:
so._create_invoices()
invs = e['account.move'].search(
[('invoice_origin', '=', so.name)])
print(f' ✓ Invoice(s) created: '
f'{", ".join(invs.mapped("name") or ["(none yet)"])}')
except Exception as ex:
gap('Accountant', 'sale.order._create_invoices', str(ex))
elif so.invoice_status == 'no':
# qty_delivered is 0 — service products invoice on ordered qty by
# default. If "no" persists, the SO has no invoiceable lines yet
# (e.g. delivered_qty=0 + invoice_policy='delivery').
print(f' Note: SO not yet invoiceable (qty_delivered=0). '
f'Set invoice_policy=order on plating service products to '
f'invoice immediately on confirm.')
# ------------------------------------------------------------------
# SUMMARY
# ------------------------------------------------------------------
print('\n=========================== SUMMARY ===========================')
if not GAPS:
print('NO GAPS FOUND — workflow walked end-to-end clean')
else:
print(f'{len(GAPS)} GAP(S) FOUND:')
for role, where, msg in GAPS:
print(f' - [{role}] {where} :: {msg}')
e.cr.commit()
walk()

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Sub 12 Phase F — end-to-end smoke test.
#
# Run via odoo-shell:
# /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin
# >>> exec(open('/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_smoke_test.py').read())
#
# Walks the full Sub 12 lifecycle and asserts at each step.
import logging
_log = logging.getLogger(__name__)
def _resolve_test_partner(env):
"""Pick a partner with at least one sale order so the RMA can bind."""
so = env['sale.order'].search([('state', 'in', ('sale', 'done'))], limit=1)
if not so:
raise RuntimeError('No confirmed sale.order found — seed one first.')
return so.partner_id, so
def smoke():
e = env # noqa -- env injected by odoo-shell
print('--- Sub 12 smoke test ---')
# Reset any stale half-completed test artefacts so the script is idempotent.
e['fusion.plating.rma'].search([('name', 'like', 'RMA/SUB12-SMOKE-%')]).unlink()
partner, so = _resolve_test_partner(e)
print(f' Using partner: {partner.display_name} (SO {so.name})')
# 1. Create RMA (draft)
rma = e['fusion.plating.rma'].create({
'partner_id': partner.id,
'sale_order_id': so.id,
'sale_order_line_ids': [(6, 0, so.order_line[:1].ids)] if so.order_line else False,
'trigger_source': 'customer_complaint',
'severity': 'high',
'qty_returned': 5,
'complaint_description': '<p>Smoke-test RMA. Auto-issued for Sub 12 verification.</p>',
})
print(f' ✓ Created RMA {rma.name} (state={rma.state})')
assert rma.state == 'draft', f'Expected draft, got {rma.state}'
# 2. Authorise
rma.action_authorise()
assert rma.state == 'authorised'
print(f' ✓ Authorised — state={rma.state}, qr_code present={bool(rma.qr_code)}')
# 3. Mark shipped
rma.action_mark_shipped_to_us()
assert rma.state == 'shipped_to_us'
print(f' ✓ Customer shipped — state={rma.state}')
# 4. Auto-receive via fp.receiving create. The RMA-link create-hook
# walks the receiving from draft → counted → staged → closed in one
# shot so the SO's x_fc_receiving_status flips to "received".
receiving = e['fp.receiving'].create({
'sale_order_id': so.id,
'rma_id': rma.id,
'box_count_in': 1,
'expected_qty': 5,
'received_qty': 5,
})
print(f' ✓ Created fp.receiving {receiving.name} → RMA state={rma.state}, recv state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert rma.state == 'received', f'Expected received, got {rma.state}'
assert receiving.state == 'closed', f'Expected receiving closed, got {receiving.state}'
assert so.x_fc_receiving_status == 'received', \
f'Expected SO status received, got {so.x_fc_receiving_status}'
# 5. Verify auto-spawn fired
assert rma.linked_ncr_ids, 'Auto-NCR was not spawned'
assert rma.linked_hold_ids, 'Auto-Hold was not spawned'
ncr = rma.linked_ncr_ids[0]
hold = rma.linked_hold_ids[0]
print(f' ✓ Auto-spawned NCR {ncr.name} + Hold {hold.name}')
# 6. Set resolution + triage
rma.resolution_type = 'rework'
rma.action_triage_complete()
assert rma.state == 'triaged'
print(f' ✓ Triage complete — state={rma.state}, resolution=rework')
# 7. Start resolving
rma.action_start_resolving()
assert rma.state == 'resolving'
print(f' ✓ Resolving — state={rma.state}')
# 8. NCR walk: open → containment → root cause → close
ncr.action_open()
ncr.action_containment()
ncr.containment = '<p>Smoke-test: parts segregated for re-rack.</p>'
ncr.action_disposition()
ncr.disposition = 'rework'
ncr.root_cause = '<p>Smoke-test: rack contact loss during transit.</p>'
# 9. Spawn CAPA from NCR (uses severity gate — high passes)
spawn_action = ncr.action_spawn_capa()
capa = e['fusion.plating.capa'].browse(spawn_action.get('res_id'))
print(f' ✓ Spawned CAPA {capa.name} from NCR')
assert capa.ncr_id == ncr, 'CAPA not linked to NCR'
# 10. Walk CAPA: analysis → implementation → verification → effective
capa.action_start_analysis()
capa.root_cause_analysis = '<p>Smoke-test: 5 Whys → packaging gap.</p>'
capa.action_start_implementation()
capa.action_plan = '<p>Smoke-test: revise packaging SOP.</p>'
capa.action_start_verification()
capa.effectiveness_notes = '<p>Smoke-test: 30 days no recurrence.</p>'
capa.action_mark_effective()
print(f' ✓ CAPA marked effective — state={capa.state}')
assert capa.state == 'effective'
# 11. Close NCR
ncr.action_close()
assert ncr.state == 'closed'
print(f' ✓ NCR closed — state={ncr.state}')
# 11b. Release the auto-spawned Hold (rework path) so the RMA close
# gate doesn't block. action_close on RMA refuses if any Hold is
# still on_hold or under_review.
hold.action_send_to_rework()
print(f' ✓ Hold sent to rework — state={hold.state}')
# 12. Resolve RMA (will spawn replacement job for rework)
rma.action_resolve()
print(f' ✓ RMA resolved — state={rma.state}, replacement_job={rma.replacement_job_id.name if rma.replacement_job_id else None}')
assert rma.state == 'resolved'
# 13. Close RMA
rma.action_close()
assert rma.state == 'closed'
print(f' ✓ RMA closed — state={rma.state}')
# 14. Stage_id sync sanity
print(f' ✓ NCR stage_id={ncr.stage_id.name if ncr.stage_id else "(none)"}')
print(f' ✓ RMA stage_id={rma.stage_id.name if rma.stage_id else "(none)"}')
# 15. Counts smoke (read directly — controller needs http context).
open_holds = e['fusion.plating.quality.hold'].search_count([
('state', 'in', ('on_hold', 'under_review')),
])
open_ncrs = e['fusion.plating.ncr'].search_count([
('state', 'in', ('open', 'containment', 'disposition')),
])
open_rmas = e['fusion.plating.rma'].search_count([
('state', 'not in', ('closed', 'cancelled')),
])
print(f' ✓ Dashboard counts (post-test): holds={open_holds}, ncrs={open_ncrs}, rmas={open_rmas}')
e.cr.commit()
print('--- Sub 12 smoke test PASSED ---')
smoke()

View File

@@ -44,3 +44,21 @@ access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_check
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_rma_operator,fusion.plating.rma.operator,model_fusion_plating_rma,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_rma_supervisor,fusion.plating.rma.supervisor,model_fusion_plating_rma,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_rma_manager,fusion.plating.rma.manager,model_fusion_plating_rma,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_tag_user,fp.quality.tag.user,model_fp_quality_tag,base.group_user,1,0,0,0
access_fp_quality_tag_supervisor,fp.quality.tag.supervisor,model_fp_quality_tag,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_tag_manager,fp.quality.tag.manager,model_fp_quality_tag,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_reason_user,fp.quality.reason.user,model_fp_quality_reason,base.group_user,1,0,0,0
access_fp_quality_reason_supervisor,fp.quality.reason.supervisor,model_fp_quality_reason,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_reason_manager,fp.quality.reason.manager,model_fp_quality_reason,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_team_user,fp.quality.team.user,model_fp_quality_team,base.group_user,1,0,0,0
access_fp_quality_team_supervisor,fp.quality.team.supervisor,model_fp_quality_team,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_team_manager,fp.quality.team.manager,model_fp_quality_team,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_alert_stage_user,fp.quality.alert.stage.user,model_fp_quality_alert_stage,base.group_user,1,0,0,0
access_fp_quality_alert_stage_supervisor,fp.quality.alert.stage.supervisor,model_fp_quality_alert_stage,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_alert_stage_manager,fp.quality.alert.stage.manager,model_fp_quality_alert_stage,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quality_point_user,fp.quality.point.user,model_fp_quality_point,base.group_user,1,0,0,0
access_fp_quality_point_supervisor,fp.quality.point.supervisor,model_fp_quality_point,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_quality_point_manager,fp.quality.point.manager,model_fp_quality_point,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_fp_qc_template_line_operator fp.qc.checklist.template.line.operator model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_operator 1 0 0 0
45 access_fp_qc_template_line_supervisor fp.qc.checklist.template.line.supervisor model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
46 access_fp_qc_template_line_manager fp.qc.checklist.template.line.manager model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_manager 1 1 1 1
47 access_fp_rma_operator fusion.plating.rma.operator model_fusion_plating_rma fusion_plating.group_fusion_plating_operator 1 0 0 0
48 access_fp_rma_supervisor fusion.plating.rma.supervisor model_fusion_plating_rma fusion_plating.group_fusion_plating_supervisor 1 1 1 0
49 access_fp_rma_manager fusion.plating.rma.manager model_fusion_plating_rma fusion_plating.group_fusion_plating_manager 1 1 1 1
50 access_fp_quality_tag_user fp.quality.tag.user model_fp_quality_tag base.group_user 1 0 0 0
51 access_fp_quality_tag_supervisor fp.quality.tag.supervisor model_fp_quality_tag fusion_plating.group_fusion_plating_supervisor 1 1 1 0
52 access_fp_quality_tag_manager fp.quality.tag.manager model_fp_quality_tag fusion_plating.group_fusion_plating_manager 1 1 1 1
53 access_fp_quality_reason_user fp.quality.reason.user model_fp_quality_reason base.group_user 1 0 0 0
54 access_fp_quality_reason_supervisor fp.quality.reason.supervisor model_fp_quality_reason fusion_plating.group_fusion_plating_supervisor 1 1 1 0
55 access_fp_quality_reason_manager fp.quality.reason.manager model_fp_quality_reason fusion_plating.group_fusion_plating_manager 1 1 1 1
56 access_fp_quality_team_user fp.quality.team.user model_fp_quality_team base.group_user 1 0 0 0
57 access_fp_quality_team_supervisor fp.quality.team.supervisor model_fp_quality_team fusion_plating.group_fusion_plating_supervisor 1 1 1 0
58 access_fp_quality_team_manager fp.quality.team.manager model_fp_quality_team fusion_plating.group_fusion_plating_manager 1 1 1 1
59 access_fp_quality_alert_stage_user fp.quality.alert.stage.user model_fp_quality_alert_stage base.group_user 1 0 0 0
60 access_fp_quality_alert_stage_supervisor fp.quality.alert.stage.supervisor model_fp_quality_alert_stage fusion_plating.group_fusion_plating_supervisor 1 1 1 0
61 access_fp_quality_alert_stage_manager fp.quality.alert.stage.manager model_fp_quality_alert_stage fusion_plating.group_fusion_plating_manager 1 1 1 1
62 access_fp_quality_point_user fp.quality.point.user model_fp_quality_point base.group_user 1 0 0 0
63 access_fp_quality_point_supervisor fp.quality.point.supervisor model_fp_quality_point fusion_plating.group_fusion_plating_supervisor 1 1 1 0
64 access_fp_quality_point_manager fp.quality.point.manager model_fp_quality_point fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,95 @@
/** @odoo-module **/
// Sub 12 Phase D — Unified Quality Dashboard.
// Five tabs (Holds / Checks / NCRs / CAPAs / RMAs) backed by their list
// kanbans, with a header summary card showing open + overdue counts.
// Each tab embeds the corresponding model's kanban via an action service
// switch. The header counters refresh on tab switch and on a 60-second
// poll.
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
const TABS = [
{ id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] },
{ id: "checks", label: "Checks", model: "fusion.plating.quality.check", group: "state", domain: [] },
{ id: "ncrs", label: "NCRs", model: "fusion.plating.ncr", group: "stage_id", domain: [["state", "!=", "closed"]] },
{ id: "capas", label: "CAPAs", model: "fusion.plating.capa", group: "state", domain: [["state", "not in", ["closed", "effective"]]] },
{ id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] },
];
export class FpQualityDashboard extends Component {
static template = "fusion_plating_quality.FpQualityDashboard";
static props = ["*"];
setup() {
this.action = useService("action");
this.state = useState({
activeTab: "ncrs",
counts: TABS.reduce((acc, t) => ({ ...acc, [t.id]: { open: 0, overdue: 0 } }), {}),
});
onWillStart(async () => {
await this._refreshCounts();
});
onMounted(() => {
this._poll = setInterval(() => this._refreshCounts(), 60000);
});
onWillUnmount(() => {
if (this._poll) clearInterval(this._poll);
});
}
async _refreshCounts() {
try {
const result = await rpc("/fp/quality/dashboard/counts");
if (result && typeof result === "object") {
for (const tab of TABS) {
if (result[tab.id]) {
this.state.counts[tab.id] = result[tab.id];
}
}
}
} catch (e) {
// Best-effort; leave counts at zero on RPC failure.
console.warn("FpQualityDashboard: count refresh failed", e);
}
}
selectTab(id) {
this.state.activeTab = id;
}
async openTab(tab) {
// Open the model's full kanban view in the main app area.
await this.action.doAction({
type: "ir.actions.act_window",
name: tab.label,
res_model: tab.model,
view_mode: "kanban,list,form",
views: [[false, "kanban"], [false, "list"], [false, "form"]],
domain: tab.domain,
context: { group_by: tab.group },
});
}
get tabs() {
return TABS;
}
get totalOpen() {
return TABS.reduce(
(sum, t) => sum + (this.state.counts[t.id]?.open || 0), 0,
);
}
get totalOverdue() {
return TABS.reduce(
(sum, t) => sum + (this.state.counts[t.id]?.overdue || 0), 0,
);
}
}
registry.category("actions").add("fp_quality_dashboard", FpQualityDashboard);

View File

@@ -0,0 +1,57 @@
// Sub 12 Phase D — Unified Quality Dashboard styling.
// Reuses the shopfloor SCSS tokens ($fp-page, $fp-card, $fp-border,
// $fp-ink, $fp-accent, etc.) — they are bundled before us via the
// fusion_plating_shopfloor dep, so no @import is needed.
.o_fp_quality_dashboard {
background-color: $fp-page;
min-height: 100%;
.o_fp_card {
background-color: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
}
.o_fp_qd_summary {
min-width: 220px;
}
.o_fp_qd_tile {
cursor: pointer;
min-width: 130px;
text-align: left;
transition: transform 0.08s ease-in-out, box-shadow 0.08s ease-in-out;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
&.o_fp_qd_active {
border: 2px solid $fp-accent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
}
.o_fp_qd_metric_label {
font-size: 0.85em;
color: $fp-ink-mute;
font-weight: 500;
}
.o_fp_qd_metric_value {
font-size: 1.6em;
font-weight: 700;
color: $fp-ink;
line-height: 1.1;
}
.o_fp_qd_metric_sub {
margin-top: 0.25em;
}
.o_fp_qd_panel {
min-height: 200px;
}
}

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_quality.FpQualityDashboard">
<div class="o_fp_quality_dashboard p-3">
<div class="o_fp_qd_header d-flex flex-wrap gap-3 mb-3">
<div class="o_fp_qd_summary o_fp_card flex-grow-1 p-3">
<h2 class="mb-2">Quality Overview</h2>
<div class="d-flex gap-4">
<div>
<div class="o_fp_qd_metric_label">Open across all 5</div>
<div class="o_fp_qd_metric_value"><t t-esc="totalOpen"/></div>
</div>
<div>
<div class="o_fp_qd_metric_label text-danger">Overdue</div>
<div class="o_fp_qd_metric_value text-danger"><t t-esc="totalOverdue"/></div>
</div>
</div>
</div>
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<button class="o_fp_qd_tile o_fp_card p-3 border-0"
t-att-class="{ 'o_fp_qd_active': state.activeTab === tab.id }"
t-on-click="() => this.selectTab(tab.id)">
<div class="o_fp_qd_metric_label"><t t-esc="tab.label"/></div>
<div class="o_fp_qd_metric_value">
<t t-esc="state.counts[tab.id]?.open || 0"/>
</div>
<div class="o_fp_qd_metric_sub text-muted small"
t-if="(state.counts[tab.id]?.overdue || 0) > 0">
<t t-esc="state.counts[tab.id].overdue"/> overdue
</div>
</button>
</t>
</div>
<div class="o_fp_qd_body">
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
<div t-if="state.activeTab === tab.id" class="o_fp_qd_panel o_fp_card p-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h3 class="mb-1"><t t-esc="tab.label"/></h3>
<div class="text-muted small">
<t t-esc="state.counts[tab.id]?.open || 0"/> open
<t t-if="(state.counts[tab.id]?.overdue || 0) > 0">
<t t-esc="state.counts[tab.id].overdue"/> overdue
</t>
</div>
</div>
<button class="btn btn-primary"
t-on-click="() => this.openTab(tab)">
Open <t t-esc="tab.label"/> Kanban
</button>
</div>
<p class="text-muted">
Click "Open Kanban" to drill into the full
<t t-esc="tab.label.toLowerCase()"/> board with stage / state grouping,
drag-and-drop, and the standard filters.
</p>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -31,6 +31,12 @@
action="action_fp_capa"
sequence="20"/>
<menuitem id="menu_fp_quality_rma"
name="RMAs"
parent="menu_fp_quality"
action="action_fp_rma"
sequence="25"/>
<menuitem id="menu_fp_quality_fair"
name="First Article Inspections"
parent="menu_fp_quality"

View File

@@ -86,6 +86,19 @@
<field name="disposition"/>
</group>
</group>
<group>
<group string="Categorisation">
<field name="team_id"/>
<field name="reason_id"/>
<field name="stage_id"/>
<field name="rma_id" readonly="1"
invisible="not rma_id"/>
</group>
<group string="Tags">
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}" nolabel="1"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description" placeholder="What happened? Be specific."/>

View File

@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase B — back-office views for the four categorisation models
(tag / reason / team / stage). All sit under Configuration → Quality.
-->
<odoo>
<!-- ============================================ TAGS ===== -->
<record id="view_fp_quality_tag_list" model="ir.ui.view">
<field name="name">fp.quality.tag.list</field>
<field name="model">fp.quality.tag</field>
<field name="arch" type="xml">
<list string="Quality Tags" editable="bottom">
<field name="name"/>
<field name="color" widget="color_picker"/>
<field name="description"/>
<field name="active"/>
</list>
</field>
</record>
<record id="action_fp_quality_tag" model="ir.actions.act_window">
<field name="name">Quality Tags</field>
<field name="res_model">fp.quality.tag</field>
<field name="view_mode">list</field>
</record>
<!-- ========================================= REASONS ===== -->
<record id="view_fp_quality_reason_list" model="ir.ui.view">
<field name="name">fp.quality.reason.list</field>
<field name="model">fp.quality.reason</field>
<field name="arch" type="xml">
<list string="Quality Reasons" editable="bottom">
<field name="name"/>
<field name="category"/>
<field name="description"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_fp_quality_reason_form" model="ir.ui.view">
<field name="name">fp.quality.reason.form</field>
<field name="model">fp.quality.reason</field>
<field name="arch" type="xml">
<form string="Quality Reason">
<sheet>
<group>
<field name="name"/>
<field name="category"/>
<field name="active"/>
</group>
<field name="description"/>
</sheet>
</form>
</field>
</record>
<record id="action_fp_quality_reason" model="ir.actions.act_window">
<field name="name">Quality Reasons</field>
<field name="res_model">fp.quality.reason</field>
<field name="view_mode">list,form</field>
</record>
<!-- =========================================== TEAMS ===== -->
<record id="view_fp_quality_team_list" model="ir.ui.view">
<field name="name">fp.quality.team.list</field>
<field name="model">fp.quality.team</field>
<field name="arch" type="xml">
<list string="Quality Teams">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="lead_user_id"/>
<field name="escalation_user_id"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_fp_quality_team_form" model="ir.ui.view">
<field name="name">fp.quality.team.form</field>
<field name="model">fp.quality.team</field>
<field name="arch" type="xml">
<form string="Quality Team">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="lead_user_id"/>
<field name="escalation_user_id"/>
<field name="color" widget="color_picker"/>
</group>
<group>
<field name="sequence"/>
<field name="active"/>
</group>
</group>
<group string="Members">
<field name="member_ids" widget="many2many_tags" nolabel="1"/>
</group>
<field name="description" placeholder="Team scope, on-call rotation, etc."/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_quality_team" model="ir.actions.act_window">
<field name="name">Quality Teams</field>
<field name="res_model">fp.quality.team</field>
<field name="view_mode">list,form</field>
</record>
<!-- ========================================== STAGES ===== -->
<record id="view_fp_quality_alert_stage_list" model="ir.ui.view">
<field name="name">fp.quality.alert.stage.list</field>
<field name="model">fp.quality.alert.stage</field>
<field name="arch" type="xml">
<list string="Quality Stages" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="fold"/>
<field name="active"/>
</list>
</field>
</record>
<record id="action_fp_quality_alert_stage" model="ir.actions.act_window">
<field name="name">Quality Stages</field>
<field name="res_model">fp.quality.alert.stage</field>
<field name="view_mode">list</field>
<field name="help" type="html">
<p>Shared kanban-stage namespace for NCR + RMA. Codes are
referenced by the state ↔ stage_id sync in
fp_quality_categorisation_links.py — don't rename codes
without checking that file.</p>
</field>
</record>
<!-- ============================== CONFIG MENU ENTRIES ===== -->
<menuitem id="menu_fp_config_quality_tag"
name="Quality Tags"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_tag"
sequence="100"/>
<menuitem id="menu_fp_config_quality_reason"
name="Quality Reasons"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_reason"
sequence="105"/>
<menuitem id="menu_fp_config_quality_team"
name="Quality Teams"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_team"
sequence="110"/>
<menuitem id="menu_fp_config_quality_stage"
name="Quality Stages"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_alert_stage"
sequence="115"/>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase D — client action + menu for the Unified Quality Dashboard.
-->
<odoo>
<record id="action_fp_quality_dashboard" model="ir.actions.client">
<field name="name">Quality Dashboard</field>
<field name="tag">fp_quality_dashboard</field>
<field name="target">current</field>
</record>
<menuitem id="menu_fp_quality_dashboard"
name="Dashboard"
parent="menu_fp_quality"
action="action_fp_quality_dashboard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase C — back-office views for fp.quality.point.
-->
<odoo>
<record id="view_fp_quality_point_list" model="ir.ui.view">
<field name="name">fp.quality.point.list</field>
<field name="model">fp.quality.point</field>
<field name="arch" type="xml">
<list string="Quality Points">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="trigger_type"/>
<field name="template_id"/>
<field name="team_id" optional="show"/>
<field name="assignee_user_id" optional="show"/>
<field name="spawn_count" string="Spawned"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_quality_point_form" model="ir.ui.view">
<field name="name">fp.quality.point.form</field>
<field name="model">fp.quality.point</field>
<field name="arch" type="xml">
<form string="Quality Point">
<header>
<button name="action_spawn_manual"
string="Fire Manually"
type="object"
class="btn-primary"
invisible="trigger_type != 'manual'"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Post-bake thickness check"/></h1>
</div>
<group>
<group>
<field name="trigger_type"/>
<field name="template_id"/>
<field name="assignee_user_id"/>
<field name="team_id"/>
</group>
<group>
<field name="active"/>
<field name="sequence"/>
<field name="spawn_count" readonly="1"/>
</group>
</group>
<notebook>
<page string="Filters">
<group>
<group>
<field name="partner_ids" widget="many2many_tags"
placeholder="All customers if empty"/>
<field name="part_catalog_ids" widget="many2many_tags"
placeholder="All parts if empty"/>
</group>
<group>
<field name="coating_config_ids" widget="many2many_tags"
placeholder="All coatings if empty"/>
<field name="step_kind"
invisible="trigger_type != 'job_step_done'"
placeholder="Any step kind if empty"/>
</group>
</group>
</page>
<page string="Tags">
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}"/>
</page>
<page string="Description">
<field name="description" nolabel="1"
placeholder="Why this point exists, what spec it satisfies, when to retire it..."/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_quality_point_search" model="ir.ui.view">
<field name="name">fp.quality.point.search</field>
<field name="model">fp.quality.point</field>
<field name="arch" type="xml">
<search string="Quality Points">
<field name="name"/>
<field name="template_id"/>
<field name="partner_ids"/>
<separator/>
<filter string="Active" name="active_only" domain="[('active','=',True)]"/>
<filter string="Manual Only" name="manual" domain="[('trigger_type','=','manual')]"/>
<separator/>
<group>
<filter string="Trigger" name="g_trigger" context="{'group_by':'trigger_type'}"/>
<filter string="Template" name="g_template" context="{'group_by':'template_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_quality_point" model="ir.actions.act_window">
<field name="name">Quality Points</field>
<field name="res_model">fp.quality.point</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quality_point_search"/>
<field name="context">{'search_default_active_only': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Define a Quality Point</p>
<p>Quality points are auto-fired rules that spawn QC checks
when receiving closes, jobs are confirmed, steps finish, jobs
complete, or sale orders are confirmed. Use filters (customer,
part, coating, step kind) to scope each point.</p>
</field>
</record>
<menuitem id="menu_fp_config_quality_point"
name="Quality Points"
parent="fusion_plating.menu_fp_config"
action="action_fp_quality_point"
sequence="120"/>
</odoo>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase D — surface the new smart-button counts on:
- fp.job form
- sale.order form
- res.partner form
Also add the cross-creation buttons:
- NCR form: Spawn CAPA
- CAPA form: Verify Effectiveness
-->
<odoo>
<!-- fp.job smart-button row lives in fusion_plating_jobs because the
button_box is added by that module (see Phase D notes — quality
can't depend on jobs without creating a cycle). -->
<!-- =============================================== sale.order ===== -->
<record id="view_sale_order_form_quality_buttons" model="ir.ui.view">
<field name="name">sale.order.form.quality.buttons</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fp_holds" type="object"
class="oe_stat_button" icon="fa-hand-paper-o">
<field name="fp_qc_hold_count" widget="statinfo" string="Holds"/>
</button>
<button name="action_view_fp_checks" type="object"
class="oe_stat_button" icon="fa-check-square-o">
<field name="fp_qc_check_count" widget="statinfo" string="Checks"/>
</button>
<button name="action_view_fp_ncrs_so" type="object"
class="oe_stat_button" icon="fa-exclamation-triangle">
<field name="fp_qc_ncr_count_so" widget="statinfo" string="NCRs"/>
</button>
<button name="action_view_fp_capas" type="object"
class="oe_stat_button" icon="fa-wrench">
<field name="fp_qc_capa_count" widget="statinfo" string="CAPAs"/>
</button>
<button name="action_view_fp_rmas" type="object"
class="oe_stat_button" icon="fa-undo">
<field name="fp_qc_rma_count" widget="statinfo" string="RMAs"/>
</button>
</xpath>
</field>
</record>
<!-- ============================================== res.partner ===== -->
<record id="view_partner_form_quality_button" model="ir.ui.view">
<field name="name">res.partner.form.quality.button</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fp_quality_history" type="object"
class="oe_stat_button" icon="fa-shield"
groups="fusion_plating.group_fusion_plating_operator">
<field name="fp_qc_quality_history_count"
widget="statinfo" string="Quality History"/>
</button>
</xpath>
</field>
</record>
<!-- ====================================== NCR — Spawn CAPA ===== -->
<record id="view_fp_ncr_form_spawn_capa" model="ir.ui.view">
<field name="name">fp.ncr.form.spawn.capa</field>
<field name="model">fusion.plating.ncr</field>
<field name="inherit_id" ref="view_fp_ncr_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_spawn_capa"
string="Spawn CAPA"
type="object"
class="btn-secondary"
invisible="state not in ('disposition','closed') or severity == 'low'"/>
</xpath>
</field>
</record>
<!-- ============================ CAPA — Verify Effectiveness ===== -->
<record id="view_fp_capa_form_verify" model="ir.ui.view">
<field name="name">fp.capa.form.verify</field>
<field name="model">fusion.plating.capa</field>
<field name="inherit_id" ref="view_fp_capa_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_verify_effectiveness"
string="Schedule Effectiveness Check"
type="object"
class="btn-secondary"
invisible="state not in ('verification','effective')"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,321 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12 Phase A — RMA list / form / kanban / search + window action.
-->
<odoo>
<!-- ====================================================== LIST -->
<record id="view_fp_rma_list" model="ir.ui.view">
<field name="name">fp.rma.list</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<list string="RMAs"
decoration-muted="state in ('closed','cancelled')"
decoration-warning="state == 'received'"
decoration-danger="severity == 'critical'">
<field name="name"/>
<field name="create_date" string="Opened"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<field name="trigger_source"/>
<field name="severity" widget="badge"
decoration-info="severity == 'low'"
decoration-warning="severity == 'high'"
decoration-danger="severity == 'critical'"/>
<field name="qty_returned"/>
<field name="qty_received"/>
<field name="resolution_type" optional="show"/>
<field name="ncr_count" optional="show"/>
<field name="hold_count" optional="show"/>
<field name="state" widget="badge"
decoration-info="state in ('authorised','shipped_to_us')"
decoration-warning="state == 'received'"
decoration-success="state in ('triaged','resolving','resolved')"
decoration-muted="state in ('closed','cancelled')"/>
</list>
</field>
</record>
<!-- ====================================================== FORM -->
<record id="view_fp_rma_form" model="ir.ui.view">
<field name="name">fp.rma.form</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<form string="Return Material Authorisation">
<header>
<button name="action_authorise" string="Authorise" type="object"
class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_mark_shipped_to_us" string="Mark as Shipped" type="object"
invisible="state != 'authorised'"/>
<button name="action_mark_received" string="Mark Received" type="object"
invisible="state not in ('authorised','shipped_to_us')"
help="Use only if no fp.receiving record was created automatically."/>
<button name="action_triage_complete" string="Triage Complete" type="object"
class="oe_highlight" invisible="state != 'received'"/>
<button name="action_start_resolving" string="Start Resolving" type="object"
invisible="state != 'triaged'"/>
<button name="action_resolve" string="Resolve" type="object"
class="oe_highlight" invisible="state not in ('triaged','resolving')"/>
<button name="action_close" string="Close" type="object"
invisible="state != 'resolved'"/>
<button name="action_cancel" string="Cancel" type="object"
confirm="Cancel this RMA? Manager only."
invisible="state in ('closed','cancelled')"
groups="fusion_plating.group_fusion_plating_manager"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,authorised,shipped_to_us,received,triaged,resolving,resolved,closed"/>
</header>
<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>
<button name="action_view_inbound_receiving" type="object"
class="oe_stat_button" icon="fa-truck"
invisible="not inbound_receiving_id">
<div class="o_stat_info">
<span class="o_stat_text">Inbound</span>
</div>
</button>
<button name="action_view_ncrs" type="object"
class="oe_stat_button" icon="fa-exclamation-triangle">
<field name="ncr_count" widget="statinfo" string="NCRs"/>
</button>
<button name="action_view_holds" type="object"
class="oe_stat_button" icon="fa-hand-paper-o">
<field name="hold_count" widget="statinfo" string="Holds"/>
</button>
<button name="action_view_capas" type="object"
class="oe_stat_button" icon="fa-wrench">
<field name="capa_count" widget="statinfo" string="CAPAs"/>
</button>
<button name="action_view_replacement_job" type="object"
class="oe_stat_button" icon="fa-cogs"
invisible="not replacement_job_id">
<div class="o_stat_info">
<span class="o_stat_text">Replacement Job</span>
</div>
</button>
<button name="action_view_refund" type="object"
class="oe_stat_button" icon="fa-money"
invisible="not refund_invoice_id">
<div class="o_stat_info">
<span class="o_stat_text">Credit Note</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="partner_id" options="{'no_create': True}"/>
<field name="sale_order_id" options="{'no_create': True}"/>
<field name="sale_order_line_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="trigger_source"/>
<field name="severity" widget="badge"
decoration-info="severity == 'low'"
decoration-warning="severity == 'high'"
decoration-danger="severity == 'critical'"/>
</group>
<group>
<field name="qty_returned"/>
<field name="qty_received"/>
<field name="customer_tracking"/>
<field name="our_tracking"/>
<field name="carrier_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group>
<group string="Categorisation">
<field name="team_id"/>
<field name="reason_id"/>
<field name="stage_id" readonly="1"/>
</group>
<group string="Tags">
<field name="tag_ids" widget="many2many_tags"
options="{'color_field': 'color'}" nolabel="1"/>
</group>
</group>
<group string="Auto-Spawn (manager-overridable)"
groups="fusion_plating.group_fusion_plating_supervisor">
<group>
<field name="auto_spawn_ncr"/>
</group>
<group>
<field name="auto_spawn_hold"/>
</group>
</group>
<notebook>
<page string="Customer Complaint">
<field name="complaint_description"
placeholder="What did the customer report?"/>
</page>
<page string="Triage Findings"
invisible="state in ('draft','authorised','shipped_to_us')">
<field name="triage_findings"
placeholder="What we found on inspection."/>
</page>
<page string="Resolution"
invisible="state in ('draft','authorised','shipped_to_us','received')">
<group>
<group>
<field name="resolution_type"/>
<field name="replacement_job_id"
invisible="resolution_type not in ('replace','rework')"/>
</group>
<group>
<field name="refund_invoice_id"
invisible="resolution_type != 'refund'"/>
</group>
</group>
<field name="resolution_notes" placeholder="Notes on the chosen resolution path."/>
</page>
<page string="Linked NCRs"
invisible="not linked_ncr_ids">
<field name="linked_ncr_ids" readonly="1">
<list>
<field name="name"/>
<field name="severity" widget="badge"/>
<field name="state" widget="badge"/>
<field name="capa_count"/>
</list>
</field>
</page>
<page string="Linked Holds"
invisible="not linked_hold_ids">
<field name="linked_hold_ids" readonly="1">
<list>
<field name="name"/>
<field name="part_ref"/>
<field name="qty_on_hold"/>
<field name="hold_reason"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
<page string="QR Code">
<group>
<field name="qr_code" widget="image"
options="{'size': [200, 200]}"
nolabel="1"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ====================================================== KANBAN -->
<record id="view_fp_rma_kanban" model="ir.ui.view">
<field name="name">fp.rma.kanban</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_fp_rma_kanban">
<field name="id"/>
<field name="name"/>
<field name="partner_id"/>
<field name="severity"/>
<field name="resolution_type"/>
<field name="qty_returned"/>
<field name="ncr_count"/>
<field name="hold_count"/>
<templates>
<t t-name="card">
<div class="o_fp_card o_fp_rma_card"
t-att-data-severity="record.severity.raw_value">
<div class="d-flex justify-content-between align-items-start">
<strong class="o_fp_card_title"><field name="name"/></strong>
<span class="o_fp_severity_pill"
t-att-data-severity="record.severity.raw_value">
<field name="severity"/>
</span>
</div>
<div class="small mt-1">
<i class="fa fa-user me-1"/><field name="partner_id"/>
</div>
<div class="small text-muted mt-1"
t-if="record.resolution_type.raw_value">
<i class="fa fa-wrench me-1"/><field name="resolution_type"/>
</div>
<div class="d-flex justify-content-between mt-2 small">
<span class="text-muted">Returned</span>
<span class="fw-bold"><field name="qty_returned"/></span>
</div>
<div class="d-flex justify-content-between small">
<span class="text-muted">NCRs / Holds</span>
<span class="fw-bold">
<field name="ncr_count"/> /
<field name="hold_count"/>
</span>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ====================================================== SEARCH -->
<record id="view_fp_rma_search" model="ir.ui.view">
<field name="name">fp.rma.search</field>
<field name="model">fusion.plating.rma</field>
<field name="arch" type="xml">
<search string="RMAs">
<field name="name"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<separator/>
<filter string="Open" name="open"
domain="[('state','in',['draft','authorised','shipped_to_us','received','triaged','resolving'])]"/>
<filter string="Closed" name="closed"
domain="[('state','=','closed')]"/>
<filter string="Critical" name="critical"
domain="[('severity','=','critical')]"/>
<filter string="Awaiting Receipt" name="awaiting_receipt"
domain="[('state','in',['authorised','shipped_to_us'])]"/>
<filter string="Awaiting Triage" name="awaiting_triage"
domain="[('state','=','received')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Status" name="g_state" context="{'group_by':'state'}"/>
<filter string="Customer" name="g_partner" context="{'group_by':'partner_id'}"/>
<filter string="Trigger" name="g_trigger" context="{'group_by':'trigger_source'}"/>
<filter string="Resolution" name="g_resolution" context="{'group_by':'resolution_type'}"/>
</group>
</search>
</field>
</record>
<!-- ====================================================== ACTION -->
<record id="action_fp_rma" model="ir.actions.act_window">
<field name="name">RMAs</field>
<field name="res_model">fusion.plating.rma</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_fp_rma_search"/>
<field name="context">{'search_default_open': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Open the first Return Material Authorisation
</p>
<p>RMAs track customer returns, inspection on receipt, root-cause
triage, and resolution (replace / rework / refund / scrap). They
auto-spawn NCRs and Holds when the parts arrive at the shop.</p>
</field>
</record>
</odoo>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.3.0',
'version': '19.0.3.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -7,6 +7,5 @@ from . import fp_receiving_damage
from . import fp_receiving_line
from . import fp_receiving
from . import fp_racking_inspection
from . import fp_receiving_racking_link
from . import sale_order
# Phase 6 (Sub 11) — mrp_production hook retired.
# from . import mrp_production

View File

@@ -125,6 +125,7 @@ class FpReceiving(models.Model):
rec.state = 'counted'
rec.received_by_id = self.env.user
rec.received_date = fields.Datetime.now()
rec._update_so_receiving_status()
rec.message_post(body=_(
'%(user)s counted %(n)d box(es) at receiving.'
) % {'user': self.env.user.name, 'n': rec.box_count_in})
@@ -219,12 +220,28 @@ class FpReceiving(models.Model):
rec.message_post(body=_('Discrepancy resolved.'))
def _update_so_receiving_status(self):
"""Update the linked sale order's receiving status."""
"""Update the linked sale order's receiving status.
Sub 8 maps the new box-count-only states (`counted`, `staged`,
`closed`) onto the SO's `x_fc_receiving_status`:
- draft -> not_received (no rows or just-created)
- counted / staged -> partial (boxes on dock, parts not yet
racked / inspected)
- closed -> received (all boxes opened, racking done)
Legacy states (inspecting / accepted / discrepancy / resolved) keep
their original mapping for back-compat with pre-Sub-8 records.
"""
for rec in self:
if rec.sale_order_id:
if rec.state in ('accepted', 'resolved'):
if not rec.sale_order_id:
continue
if rec.state == 'closed':
rec.sale_order_id.x_fc_receiving_status = 'received'
elif rec.state == 'discrepancy':
elif rec.state in ('counted', 'staged'):
rec.sale_order_id.x_fc_receiving_status = 'partial'
elif rec.state == 'inspecting':
# Legacy states preserved.
elif rec.state in ('accepted', 'resolved'):
rec.sale_order_id.x_fc_receiving_status = 'received'
elif rec.state in ('discrepancy', 'inspecting'):
rec.sale_order_id.x_fc_receiving_status = 'partial'
elif rec.state == 'draft':
rec.sale_order_id.x_fc_receiving_status = 'not_received'

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