changes
This commit is contained in:
@@ -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 S1–S8 (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.
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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:
|
||||
part.workorder_count = 0
|
||||
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:
|
||||
if MrpWO is None:
|
||||
part.workorder_count = 0
|
||||
continue
|
||||
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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
string="BOM Items"/>
|
||||
invisible="x_fc_distinct_part_count < 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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
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:
|
||||
addrs = self.partner_id.address_get(['invoice', 'delivery'])
|
||||
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
|
||||
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
|
||||
# Seed payment terms: customer's invoice-strategy default wins;
|
||||
# fallback to partner.property_payment_term_id.
|
||||
term = False
|
||||
isd = self.env['fp.invoice.strategy.default'].search(
|
||||
[('partner_id', '=', self.partner_id.id)], limit=1,
|
||||
)
|
||||
if isd and isd.payment_term_id:
|
||||
term = isd.payment_term_id
|
||||
# Also seed strategy from the same record if not already set.
|
||||
if not self.invoice_strategy:
|
||||
self.invoice_strategy = isd.default_strategy
|
||||
if not self.deposit_percent:
|
||||
self.deposit_percent = isd.default_deposit_percent or 0.0
|
||||
if not term and self.partner_id.property_payment_term_id:
|
||||
term = self.partner_id.property_payment_term_id
|
||||
self.payment_term_id = term or False
|
||||
else:
|
||||
"""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
|
||||
|
||||
# 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
|
||||
|
||||
# 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,
|
||||
)
|
||||
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
|
||||
|
||||
# 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;
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
34
fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml
Normal file
34
fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
56
fusion_plating/fusion_plating_jobs/models/sale_order_line.py
Normal file
56
fusion_plating/fusion_plating_jobs/models/sale_order_line.py
Normal 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
|
||||
@@ -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 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 2 — open the Certificate 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 2 of the CoC.
|
||||
</p>
|
||||
</div>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_qc_controller
|
||||
from . import fp_quality_dashboard
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
},
|
||||
}
|
||||
@@ -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 > 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]))
|
||||
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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([])
|
||||
@@ -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
|
||||
199
fusion_plating/fusion_plating_quality/models/fp_quality_point.py
Normal file
199
fusion_plating/fusion_plating_quality/models/fp_quality_point.py
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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.'),
|
||||
]
|
||||
@@ -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.'),
|
||||
]
|
||||
775
fusion_plating/fusion_plating_quality/models/fp_rma.py
Normal file
775
fusion_plating/fusion_plating_quality/models/fp_rma.py
Normal 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',
|
||||
)
|
||||
156
fusion_plating/fusion_plating_quality/models/fp_rma_links.py
Normal file
156
fusion_plating/fusion_plating_quality/models/fp_rma_links.py
Normal 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')
|
||||
210
fusion_plating/fusion_plating_quality/scripts/battle_test.py
Normal file
210
fusion_plating/fusion_plating_quality/scripts/battle_test.py
Normal 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 ==')
|
||||
150
fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
Normal file
150
fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
Normal 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 ==')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.0–5.5 g/L (Fischerscope reading)</li>'
|
||||
'<li>pH must be 4.4–4.8 — adjust with ammonium hydroxide if needed</li>'
|
||||
'<li>Bath temp 88–93°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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
107
fusion_plating/fusion_plating_quality/scripts/step3_verify.py
Normal file
107
fusion_plating/fusion_plating_quality/scripts/step3_verify.py
Normal 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} ==')
|
||||
@@ -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()
|
||||
@@ -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 ==')
|
||||
103
fusion_plating/fusion_plating_quality/scripts/step6_verify.py
Normal file
103
fusion_plating/fusion_plating_quality/scripts/step6_verify.py
Normal 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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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."/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
321
fusion_plating/fusion_plating_quality/views/fp_rma_views.xml
Normal file
321
fusion_plating/fusion_plating_quality/views/fp_rma_views.xml
Normal 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>
|
||||
@@ -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': """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'):
|
||||
rec.sale_order_id.x_fc_receiving_status = 'received'
|
||||
elif rec.state == 'discrepancy':
|
||||
rec.sale_order_id.x_fc_receiving_status = 'partial'
|
||||
elif rec.state == 'inspecting':
|
||||
rec.sale_order_id.x_fc_receiving_status = 'partial'
|
||||
if not rec.sale_order_id:
|
||||
continue
|
||||
if rec.state == 'closed':
|
||||
rec.sale_order_id.x_fc_receiving_status = 'received'
|
||||
elif rec.state in ('counted', 'staged'):
|
||||
rec.sale_order_id.x_fc_receiving_status = 'partial'
|
||||
# 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
Reference in New Issue
Block a user