Compare commits

..

8 Commits

Author SHA1 Message Date
gsinghpal
4161f04b0f feat(plating): hard-required fields on WO start — operator + bath + tank
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
User audit caught: in the workforce E2E run we had no idea which bath /
which tank ran the job. For aerospace traceability that's a deal-
breaker. Add a validation gate on mrp.workorder.button_start so
operators can't tap START without the data the shop floor MUST capture.

**Three new pieces on mrp.workorder:**

1. `_fp_is_wet_process()` — best-effort "does this WO involve a
   chemistry bath?" check. Three signals in priority order:
   a. A bath is already linked → definitely wet
   b. The workcenter's FP work-centre supports a wet process family
      (plating, pre/post-treatment, strip, passivation)
   c. WO name contains a wet-process keyword (plat, nickel, chrome,
      anodiz, zinc, etch, clean, rinse, strip, passivat, electroless…)
   The keyword fallback is needed because most existing recipes have
   no process_type_id set on their operation nodes.

2. `_fp_check_required_fields_before_start()` — runs before the
   existing certification check. Rules:
   • Every WO needs an assigned operator (x_fc_assigned_user_id).
     Without it, productivity records can't be attributed and the
     proficiency tracker has no employee to credit.
   • Wet WOs additionally need x_fc_bath_id + x_fc_tank_id. So we
     know exactly which chemistry bath ran the job and which physical
     tank it sat in.
   Raises a clear UserError listing the missing fields if any.

3. `x_fc_requires_bath` (compute, non-stored) — surfaces the wet check
   to the form view so bath + tank fields render with `required=`.

**View changes:**
- `x_fc_assigned_user_id` is now `required="1"` on the form
- `x_fc_bath_id` + `x_fc_tank_id` use `required="x_fc_requires_bath"`
  → red asterisk only when the WO is actually wet

**Simulator updates** (scripts/fp_e2e_workforce.py):
- Hannah now explicitly assigns bath + tank to wet WOs during planning,
  AND pre-issues operator certifications for the bath's process type
  (real shop manager workflow).
- Two negative tests added that PROVE the gates fire:
  • Test 1: strip the operator → button_start raises "missing Assigned Operator"
  • Test 2: strip bath/tank on a wet WO → button_start raises "missing Bath/Tank"

**Final E2E:** 42 PASS / 2 WARN / 0 FAIL out of 44 checks.
Both remaining WARNs (bake-window auto-create, first-piece gate) are
expected behaviour — those are coating-driven and the test coating
intentionally doesn't trigger them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:47:31 -04:00
gsinghpal
fe003567a9 docs(fusion_accounting): Phase 1 bank reconciliation implementation plan
51 tasks across 17 groups covering the full Phase 1 build:

Group 1 (5 tasks): Foundation — branch, sub-module skeleton, shared
fields on _core, LLMProvider contract for local LLM readiness

Group 2 (8 tasks): Reconcile engine — TDD-layered build of
matching_strategies, exchange_diff, memo_tokenizer, precedent_lookup,
pattern_extractor, confidence_scoring 4-pass pipeline, the AbstractModel
engine with 6-method API, and Hypothesis property-based tests

Group 3 (4 tasks): Models — fusion.reconcile.pattern,
fusion.reconcile.precedent, fusion.reconcile.suggestion, widget transient,
and inherits on Community account.bank.statement.line + account.reconcile.model

Group 4-5 (6 tasks): Integration tests with SQL fixtures from real Westin
reconciles + AI prompts + adapter fill-ins + AI tools refactor

Group 6-7 (3 tasks): Materialized view, cron schedules, and 10-endpoint
JSON-RPC controller with auth guards

Group 8-10 (10 tasks): Frontend — SCSS tokens, service, kanban controllers,
all 18 Enterprise-mirror OWL components, and 5 fusion-only components
(ai_suggestion folder, batch_action_bar, attachment_strip,
partner_history_panel, reconcile_model_picker)

Group 11-13 (5 tasks): Wizards (auto-reconcile + bulk), migration wizard
inheritance with bootstrap of 16,500 historical reconciliations + audit
report PDF + round-trip test, coexistence menu/group + tests

Group 14-16 (3 tasks): 5 OWL tour tests, performance benchmarks against
P95 targets, local LLM compatibility test against LM Studio

Group 17 (4 tasks): Closeout — meta-module manifest update, sub-module
docs, end-to-end smoke test, completion tag

TDD discipline throughout: every code task is red test → impl → green
→ commit. Property-based tests for amount invariants. Migration round-
trip test asserts byte-identical reconciliation state pre/post Enterprise
uninstall. All testing on local OrbStack VM only (environment-safety
rule applies).

Made-with: Cursor
2026-04-19 09:45:25 -04:00
gsinghpal
bbbd222b89 feat(plating): close 2 workflow gaps surfaced by workforce E2E simulation
Built a comprehensive simulator (scripts/fp_e2e_workforce.py) that
role-plays 10 employees driving an order quote → invoice using real
operator timers (button_start / button_finish with elapsed time.sleep).

Initial run: 31 PASS / 2 WARN / 0 FAIL exposed two gaps that would
hurt a real shop:

**Gap 1 — Thickness readings never reached the CoC**
The Fischerscope readings inspectors take during post-plate inspection
had no path to the CoC. The cert came out empty, useless for AS9100
or aerospace audits.

Fixes:
- New tablet endpoint `/fp/shopfloor/log_thickness_reading` so the
  inspector can record one reading at a time during the inspection WO
  (auto-numbers, defaults the operator, supports microscope image).
- mrp_production._fp_mark_done_post_actions now bulk-links any
  orphan thickness readings (those with production_id=mo.id but no
  certificate_id) to the freshly-created CoC. So inspectors can log
  during inspection AND the cert PDF picks them up automatically.

**Gap 2 — Operator queue leaked other people's work + simulator missed it**
fusion.plating.operator.queue.build_for_user pulled EVERY ready /
in-progress WO regardless of assignment. Tom would see John's masking
WO in his "Up Next" list — bad for aerospace traceability where you
want strict per-operator accountability.

Fix: build_for_user now filters MRP WOs by
`(x_fc_assigned_user_id == user_id OR x_fc_assigned_user_id == False)`.
Operators see their own assigned tasks first, plus any unassigned
tasks anyone can grab. Other operators' assigned WOs no longer leak
through.

Also caught: simulator was using wrong field name on the queue model.
Fixed and added a "queue isolation" check that verifies no operator
sees another operator's assigned WOs.

After fixes: **39 PASS / 2 WARN / 0 FAIL** (out of 41 checks).
Remaining WARNs are both expected behaviour:
  - bake-window auto-create: this coating doesn't require_bake_relief
    (the recipe has an inline Oven step instead)
  - first-piece gate: same — coating-driven, only fires when needed

Areas validated end-to-end:
- quote → SO with PO# carried into client_order_ref
- SO confirm → MO + portal job auto-created
- receiving qty prefill + accept
- 9 WOs generated from recipe + assigned to specific operators
- All 9 WOs ran with real elapsed timers + 17 productivity records
  across 4 distinct operators
- MO done triggers CoC auto-issue with 5 thickness readings linked,
  319 KB rich PDF, customer-slug filename
- Delivery auto-created with prefilled date + driver + CoC link
- Delivery delivered, 2 chain-of-custody entries
- Invoice posted (NOT auto-paid)
- All 5 customer notifications fired (so_confirmed +
  parts_received + mo_complete + shipped + invoice_posted) with
  correct attachments
- Portal job → complete, SO workflow_stage → invoicing
- Chemistry log persisted, operator proficiency tracked

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:30:56 -04:00
gsinghpal
2d64f7efab docs(fusion_accounting): Phase 1 bank reconciliation design
Drafts the design for fusion_accounting_bank_rec — a native bank
reconciliation widget that replaces Odoo Enterprise account_accountant
in V19 OWL architecture, with a clean-room reconcile engine reading and
writing Community account.partial.reconcile rows.

Key design decisions captured:
- CORE scope (~5.5-6 weeks): manual + auto reconcile, write-offs,
  partial, multi-currency, chatter, model picker
- Strict mirror of all 18 Enterprise OWL units (zero functional loss)
  plus 5 fusion-only additions for AI/history visibility
- Hybrid AI badge layout: inline strip with one-click Accept plus
  expandable ranked-alternatives panel
- Behavioural learning via fusion.reconcile.pattern (per-partner) and
  fusion.reconcile.precedent (per-decision memory) with bootstrap from
  the 16,500 historical reconciliations
- Local LLM ready via OpenAI-compatible adapter base_url config and
  per-feature provider routing — works against LM Studio, Ollama, vLLM
- Statistical-mode-without-API-key as a first-class path
- Coexistence with Enterprise: Enterprise wins by default, fusion
  menu hides until uninstall, then auto-appears
- Migration wizard step bootstraps pattern memory and produces an
  audit report PDF proving every reconciliation preserved
- TDD on engine algorithms with Hypothesis property-based tests for
  amount invariants; migration round-trip integration test

Builds on Phase 0 (commit c450bb2, range pre-phase-0..phase-0-complete).

Made-with: Cursor
2026-04-19 09:27:52 -04:00
gsinghpal
fa82ce17dd feat(reports): sequence-sort the Print dropdown so FP reports are #1
Odoo 19's `ir.actions.actions._get_bindings` returns the print-menu
bindings via `ORDER BY a.id` (insertion order) and only sequence-sorts
the `action`-type bindings — `report`-type bindings are returned in
raw SQL order. Result: FP reports installed after Odoo's stock ones
appear at the BOTTOM of the dropdown, even when they're the
customer-facing primary report (e.g. Timesheets above Quotation on
sale.order).

Two changes in fusion_plating_reports/models/ir_actions_report.py:

1. **Add `sequence` (Integer, default 100) to ir.actions.report** —
   gives every report a sortable knob.

2. **Override `ir.actions.actions._get_bindings`** to also sort the
   `report` slice by `(sequence, name.lower())`. super() returns the
   cached frozendict; we rebuild with the sorted reports.

Then set sequences in fp_hide_default_reports.xml (lower = top):

| Model           | seq 10 (#1)              | seq 15 (#2)              | seq 20+               |
|-----------------|--------------------------|--------------------------|-----------------------|
| sale.order      | FP Quotation Portrait    | FP Quotation Landscape   | FP Job Traveller (20) |
| account.move    | FP Invoice Portrait      | FP Invoice Landscape     |                       |
| stock.picking   | FP Packing Slip Portrait | FP Packing Slip Landscape|                       |
| mrp.production  | FP Job Traveller Portrait| FP Job Traveller Landscape| FP WO Margin (20)   |
| account.payment | FP Receipt Portrait      | FP Receipt Landscape     |                       |
| fp.delivery     | FP BoL Portrait          | FP BoL Landscape         |                       |
| portal.job      | FP CoC Portrait          | FP CoC Landscape         |                       |
| fp.certificate  | FP CoC English           | FP CoC Français          |                       |

Odoo defaults stay at sequence 100 (default) → always at bottom.

Verified on entech: sale.order print menu now shows
Quotation Portrait → Quotation Landscape → Job Traveller × 2 →
PRO-FORMA → Timesheets. Same pattern across all touched models.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:05:29 -04:00
gsinghpal
9a1ee4b369 feat(reports): hide Odoo's default PDFs where FP ships a branded one
Users were seeing both Odoo's stock PDFs and FP's branded equivalents
in the Print dropdown side-by-side, and accidentally sending the wrong
(unbranded, missing PO# / job ref / plating fields) PDF to customers.

Add fp_hide_default_reports.xml that drops the Print-menu binding on:

| Model           | Hidden                                                      | FP replacement                  |
|-----------------|-------------------------------------------------------------|---------------------------------|
| sale.order      | sale.action_report_saleorder                                | action_report_fp_sale_*         |
| sale.order      | sale_pdf_quote_builder.action_report_saleorder_raw          | action_report_fp_sale_*         |
| account.move    | account.account_invoices                                    | action_report_fp_invoice_*      |
| account.move    | account.account_invoices_without_payment                    | action_report_fp_invoice_*      |
| stock.picking   | stock.action_report_delivery                                | action_report_fp_packing_slip_* |
| mrp.production  | mrp.action_report_production_order                          | action_report_fp_job_traveller_*|
| account.payment | account.action_report_payment_receipt                       | action_report_fp_receipt_*      |

Mechanism: set binding_model_id=False + binding_type=action — removes
from the Print dropdown but leaves the report record + template intact.
Fully reversible from Settings → Technical → Reports if anyone needs
the stock PDF back.

Intentionally NOT touched:
- sale.action_report_pro_forma_invoice (no FP pro-forma yet)
- account.action_account_original_vendor_bill (vendor bills, internal)
- stock.action_report_picking / picking_packages / return_label_report
  (internal warehouse ops, not customer-facing)
- mrp.action_report_finished_product / mrp.label_manufacture_template
  (production labels — ZPL, not customer-facing)
- sale_timesheet.* (timesheet integration)

Added sale_pdf_quote_builder to depends so the data file always finds
that record when applied (it ships in entech's repackaged enterprise
bundle and was already installed there).

Verified on entech: re-running the print-menu audit shows zero stock
Odoo customer-facing PDFs left where FP has an equivalent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:57:38 -04:00
gsinghpal
5994cec11b fix(plating): chatter action toolbar invisible in dark mode
The floating message-action toolbar (reaction / reply / star / link
icons) appearing on hover renders white-icons-on-white-background in
dark mode — Odoo's own dark.scss sets the icon hover color to white
but never gives the toolbar itself a dark background. Result: the
icons vanish entirely in dark mode.

Add fp_chatter_dark.scss that branches at compile time on
$o-webclient-color-scheme == dark (Odoo 19 compiles every SCSS file
into both web.assets_backend with `bright` AND web.assets_web_dark
with `dark`) and gives the toolbar:

- Solid dark background (#2b2f33 fallback, var(--o-component-bgcolor))
- Subtle 1px white-alpha border + drop shadow so it floats nicely
- Icon color rgba(255,255,255,.78) at full opacity (not 35%)
- Brighter hover state with a subtle bg highlight

Light bundle output is empty (the @if branch doesn't fire), so the
light theme is untouched.

Verified: dark bundle includes our rule with #2b2f33 marker present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:45:47 -04:00
gsinghpal
eed4dc8a78 fix(plating): chatter HTML rendering + workflow stage banner UX
Two fixes from a single SO walkthrough screenshot:

**1. "Current stage" banner**
- Was placed `inside sheet` so it rendered at the BOTTOM of the form
  where users miss it. Moved to `before form/header` (same xpath
  pattern as the Account Hold banner) — now it's the first thing
  visible above the SO header.
- Was still showing "Shipped — awaiting invoice" after the invoice
  was posted because `_compute_workflow_stage` only advanced to
  `complete` when shipped + ALL paid; an unpaid posted invoice left
  the SO stuck on `shipped`. Added an `invoicing` branch: shipped +
  has_posted_invoice → invoicing. Banner invisible-list now also
  includes `invoicing` and `paid`, so the banner only shows for
  in-progress steps.

**2. Chatter messages rendering raw HTML tags as text**
Odoo 19 escapes any string passed to `message_post(body=...)`
unless wrapped in `markupsafe.Markup`. We had ~10 places posting
HTML (`<a href>`, `<b>`, `<br/>`, `<code>`, `<pre>`) that all
showed up as `&lt;a href=...&gt;` literal text in the chatter.

Wrapped each one with `Markup(_(...))` so the tags render. Files
touched:

- fusion_plating_bridge_mrp/models/sale_order.py
  (auto-MO failure code block, "Draft MO created" link,
   "Job assigned to <b>" message)
- fusion_plating_bridge_mrp/models/mrp_production.py
  ("Recipe steps" pre/br block on each WO)
- fusion_plating_bridge_mrp/models/fp_proficiency.py
  (operator promotion announcement)
- fusion_plating_configurator/models/fp_quote_configurator.py
  (SO link, 3D model attached, drawing attached, save to catalog)
- fusion_plating_configurator/models/fp_part_catalog.py
  (3D/drawing change tracking + propagation to linked quotes)
- fusion_plating_portal/models/fp_quote_request.py
  (RFQ → SO link)
- fusion_plating_quality/models/fp_quality_hold.py
  (hold status change)
- fusion_plating_shopfloor/controllers/manager_controller.py
  (worker / tank / manager-takeover assignments)

Verified on entech: SO S00038 stage now reads `invoicing` (banner
hidden), and a freshly posted message shows `<a href>` and `<b>`
as actual link + bold instead of escaped text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:36:00 -04:00
32 changed files with 6019 additions and 59 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.5.0.0',
'version': '19.0.5.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -102,6 +102,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'web.assets_backend': [
'fusion_plating/static/src/scss/fusion_plating.scss',
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
'fusion_plating/static/src/js/recipe_tree_editor.js',
],

View File

@@ -0,0 +1,48 @@
// =====================================================================
// Fusion Plating — Chatter dark-mode patch
//
// In dark mode the floating message-action toolbar (reaction / reply /
// star / link icons) renders white-on-white because Odoo sets the
// hover icon color to `white` but doesn't give the toolbar itself a
// dark background. Result: icons invisible, users can't see what
// they're hovering.
//
// Branch at compile time (Odoo 19 compiles every SCSS file into the
// `web.assets_backend` bundle with $o-webclient-color-scheme: bright,
// AND into `web.assets_web_dark` with $o-webclient-color-scheme: dark).
// Light bundle gets nothing (zero output); dark bundle gets the patch.
// =====================================================================
$o-webclient-color-scheme: bright !default;
@if $o-webclient-color-scheme == dark {
.o-mail-Message-actions {
// Solid dark background so light/white icons stand out
background-color: var(--o-component-bgcolor, #2b2f33) !important;
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 6px;
padding: 2px 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
// Make sure every icon (reaction, reply, star, link, more) has
// enough contrast against the dark popup. Defaults sit at 35%
// opacity which barely shows.
button, .btn, .o-mail-ActionList-button {
color: rgba(255, 255, 255, 0.78) !important;
> i, > .oi, > .fa {
color: rgba(255, 255, 255, 0.82) !important;
opacity: 1 !important;
}
&:hover, &:focus, &:focus-visible, &.show {
background-color: rgba(255, 255, 255, 0.10) !important;
color: #fff !important;
> i, > .oi, > .fa {
color: #fff !important;
}
}
}
}
}

View File

@@ -5,7 +5,7 @@
{
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.6.1.0',
'version': '19.0.6.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """

View File

@@ -13,6 +13,8 @@ Shop Roles automatically. The operator never has to fill in a form;
their growing skill set just unlocks itself.
"""
from markupsafe import Markup
from odoo import _, api, fields, models
@@ -160,13 +162,14 @@ class FpOperatorProficiency(models.Model):
'x_fc_work_role_ids': [(4, role.id)],
})
employee.message_post(
body=_(
body=Markup(_(
'🎉 <b>%(name)s promoted</b> — qualified for '
'<b>%(role)s</b> after %(count)s successful '
'completions.',
name=employee.name,
role=role.name,
count=rec.completed_count,
),
'completions.'
)) % {
'name': employee.name,
'role': role.name,
'count': rec.completed_count,
},
subtype_xmlid='mail.mt_note',
)

View File

@@ -5,6 +5,8 @@
import logging
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
@@ -423,7 +425,7 @@ class MrpProduction(models.Model):
steps_txt = wo_steps.get(wo.sequence)
if steps_txt:
wo.message_post(
body=_('<b>Recipe steps:</b><br/><pre>%s</pre>') % steps_txt,
body=Markup(_('<b>Recipe steps:</b><br/><pre>%s</pre>')) % steps_txt,
subtype_xmlid='mail.mt_note',
)
production.message_post(
@@ -598,6 +600,19 @@ class MrpProduction(models.Model):
if not coc_cert:
coc_cert = Certificate.create({**base_vals, 'certificate_type': 'coc'})
# Pull in any thickness readings the inspector logged
# against this MO so they show up on the CoC PDF.
# Aerospace/Nadcap customers require these — without them
# the cert is just a piece of paper.
ThicknessReading = self.env.get('fp.thickness.reading')
if coc_cert and ThicknessReading is not None:
orphan_readings = ThicknessReading.search([
('production_id', '=', mo.id),
('certificate_id', '=', False),
])
if orphan_readings:
orphan_readings.write({'certificate_id': coc_cert.id})
# Skip thickness cert when CoC also wanted — the CoC
# template already embeds thickness readings, so creating
# a separate thickness cert just produces a duplicate PDF.

View File

@@ -26,6 +26,13 @@ class MrpWorkorder(models.Model):
# ------------------------------------------------------------------
# Plating-specific fields
# ------------------------------------------------------------------
x_fc_requires_bath = fields.Boolean(
string='Requires Bath/Tank',
compute='_compute_requires_bath',
store=False,
help='True when this WO involves a chemistry bath. Surfaced to '
'the form view so bath/tank fields render as required.',
)
x_fc_bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', tracking=True,
)
@@ -512,10 +519,82 @@ class MrpWorkorder(models.Model):
# ------------------------------------------------------------------
# T2.2 — Certification gate on WO start
# T2.3 — Required-field gate (bath/tank for wet WOs, assigned operator)
# ------------------------------------------------------------------
WET_FAMILIES = (
'plating', 'pre_treatment', 'post_treatment',
'strip', 'passivation',
)
# Keyword fallback used when the workcenter / process-type metadata
# is missing — covers most shop floor naming conventions. Lowercased.
WET_NAME_KEYWORDS = (
'plat', 'nickel', 'chrome', 'anodiz', 'zinc',
'etch', 'clean', 'rinse', 'strip', 'passivat',
'zincate', 'alkalin', 'acid', 'electroless',
)
@api.depends('x_fc_bath_id', 'name', 'workcenter_id')
def _compute_requires_bath(self):
for wo in self:
wo.x_fc_requires_bath = wo._fp_is_wet_process()
def _fp_is_wet_process(self):
"""Best-effort check: does this WO involve a chemistry bath?
Three signals, in priority order:
1. A bath is already linked → definitely wet
2. The workcenter's FP work-centre supports a wet process family
3. The WO's name contains a wet-process keyword
"""
self.ensure_one()
if self.x_fc_bath_id:
return True
wc = self.workcenter_id
fpwc = getattr(wc, 'x_fc_fp_work_center_id', False)
if fpwc:
families = set(fpwc.supported_process_ids.mapped('process_family'))
if families & set(self.WET_FAMILIES):
return True
name = (self.name or '').lower()
return any(k in name for k in self.WET_NAME_KEYWORDS)
def _fp_check_required_fields_before_start(self):
"""Block button_start if the WO is missing data the shop must
record for traceability + compliance.
Rules:
• Every WO needs an assigned operator (x_fc_assigned_user_id) —
without it, productivity records can't be attributed and
proficiency tracking goes nowhere.
• Wet (bath) WOs additionally need x_fc_bath_id + x_fc_tank_id —
for chemistry traceability and physical-location audit
(which exact tank ran the job).
"""
from odoo.exceptions import UserError
for wo in self:
missing = []
if not wo.x_fc_assigned_user_id:
missing.append(_('Assigned Operator'))
if wo._fp_is_wet_process():
if not wo.x_fc_bath_id:
missing.append(_('Bath'))
if not wo.x_fc_tank_id:
missing.append(_('Tank'))
if missing:
raise UserError(_(
'Cannot start work order "%(wo)s" — please fill these '
'required fields first:\n%(fields)s\n\n'
'Open the work order form and have the planner set them.'
) % {
'wo': wo.display_name or wo.name,
'fields': '\n'.join(missing),
})
def button_start(self):
"""Block start unless the current user's linked employee holds
an active certification for this WO's process type."""
an active certification for this WO's process type AND every
required field for traceability is filled in."""
self._fp_check_required_fields_before_start()
self._fp_check_operator_certification()
res = super().button_start()
# Capture audit AFTER the super call so we don't stamp WOs that

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import api, fields, models, _
@@ -86,8 +88,8 @@ class SaleOrder(models.Model):
# Don't block SO confirm — log + continue. The manager
# can still create the MO manually.
so.message_post(
body=_('Auto-MO creation failed: <code>%s</code>. '
'Create the MO manually from MRP.') % exc,
body=Markup(_('Auto-MO creation failed: <code>%s</code>. '
'Create the MO manually from MRP.')) % exc,
)
return res
@@ -145,11 +147,11 @@ class SaleOrder(models.Model):
if recipe and 'x_fc_recipe_id' in Production._fields:
mo_vals['x_fc_recipe_id'] = recipe.id
mo = Production.create(mo_vals)
self.message_post(body=_(
self.message_post(body=Markup(_(
'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
'auto-created. Accept the parts and click <b>Assign to Me</b> to '
'release it to the floor.'
) % (mo.id, mo.name))
)) % (mo.id, mo.name))
@api.depends(
'state', 'invoice_status',
@@ -182,17 +184,22 @@ class SaleOrder(models.Model):
))
# Paid vs invoiced
if so.invoice_status == 'invoiced' and so.invoice_ids:
latest = so.invoice_ids.filtered(lambda i: i.state == 'posted')
all_paid = latest and all(
i.payment_state in ('paid', 'in_payment') for i in latest
)
if shipped and all_paid:
so.x_fc_workflow_stage = 'complete'
continue
if all_paid and not shipped:
so.x_fc_workflow_stage = 'paid'
continue
posted_invoices = so.invoice_ids.filtered(lambda i: i.state == 'posted')
has_posted_invoice = bool(posted_invoices)
all_paid = has_posted_invoice and all(
i.payment_state in ('paid', 'in_payment') for i in posted_invoices
)
if shipped and all_paid:
so.x_fc_workflow_stage = 'complete'
continue
if all_paid and not shipped:
so.x_fc_workflow_stage = 'paid'
continue
# Once an invoice is posted (regardless of payment), the SO has
# moved past 'shipped' — the action is on accounting, not us.
if shipped and has_posted_invoice:
so.x_fc_workflow_stage = 'invoicing'
continue
if shipped:
so.x_fc_workflow_stage = 'shipped'
@@ -263,7 +270,7 @@ class SaleOrder(models.Model):
if 'x_fc_assigned_manager_id' in mo._fields and not mo.x_fc_assigned_manager_id:
mo.x_fc_assigned_manager_id = user.id
self.message_post(
body=_('Job assigned to <b>%s</b>. %d MO(s) released to the floor.')
body=Markup(_('Job assigned to <b>%s</b>. %d MO(s) released to the floor.'))
% (user.name, len(mos)),
)
return True

View File

@@ -93,8 +93,10 @@
<field name="x_fc_priority" widget="priority"/>
<field name="x_fc_assigned_user_id"
string="Assigned To"
required="1"
options="{'no_create': True}"/>
<field name="x_fc_work_role_id" readonly="1"/>
<field name="x_fc_requires_bath" invisible="1"/>
</xpath>
<!-- ============================================================
@@ -166,8 +168,10 @@
<group>
<group string="Bath &amp; Tank">
<field name="x_fc_facility_id"/>
<field name="x_fc_bath_id"/>
<field name="x_fc_tank_id"/>
<field name="x_fc_bath_id"
required="x_fc_requires_bath"/>
<field name="x_fc_tank_id"
required="x_fc_requires_bath"/>
<field name="x_fc_rack_id"/>
<field name="x_fc_rack_ref"/>
</group>

View File

@@ -92,12 +92,15 @@
help="Close the open delivery record(s) and fire auto-invoice per strategy."/>
</xpath>
<!-- Show the workflow stage on the sheet so users always
know what step they're on (readonly banner). -->
<xpath expr="//sheet" position="inside">
<!-- Workflow stage banner — sits ABOVE the form header so it's
the first thing users see, matches the Account Hold banner.
Hidden for terminal states (invoicing/paid/complete/cancelled)
and the initial draft so it only shows when there's an
active in-progress step. -->
<xpath expr="//form/header" position="before">
<div class="alert alert-info mb-2"
style="border-radius: 6px;"
invisible="x_fc_workflow_stage in ('draft', 'complete', 'cancelled')">
invisible="x_fc_workflow_stage in ('draft', 'invoicing', 'paid', 'complete', 'cancelled')">
<i class="fa fa-compass me-2"/>
<strong>Current stage:</strong>
<field name="x_fc_workflow_stage" readonly="1" nolabel="1" class="ms-1"/>

View File

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

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import api, fields, models, _
@@ -235,11 +237,11 @@ class FpPartCatalog(models.Model):
old = snap['model']
new = rec.model_attachment_id
if not old and new:
messages.append(_('<b>3D model attached:</b> %s') % new.name)
messages.append(Markup(_('<b>3D model attached:</b> %s')) % new.name)
elif old and not new:
messages.append(_('<b>3D model removed:</b> %s') % old.name)
messages.append(Markup(_('<b>3D model removed:</b> %s')) % old.name)
elif old and new and old.id != new.id:
messages.append(_('<b>3D model changed:</b> %s%s') % (old.name, new.name))
messages.append(Markup(_('<b>3D model changed:</b> %s%s')) % (old.name, new.name))
# Drawing changes (added or removed)
if track_drawings:
@@ -250,15 +252,15 @@ class FpPartCatalog(models.Model):
for att_id in added:
att = self.env['ir.attachment'].browse(att_id)
if att.exists():
messages.append(_('<b>Drawing attached:</b> %s') % att.name)
messages.append(Markup(_('<b>Drawing attached:</b> %s')) % att.name)
for att_id in removed:
att = self.env['ir.attachment'].browse(att_id)
# Browse even if deleted — may still have name if not purged
name = att.exists() and att.name or f'#{att_id}'
messages.append(_('<b>Drawing removed:</b> %s') % name)
messages.append(Markup(_('<b>Drawing removed:</b> %s')) % name)
if messages:
body = '<br/>'.join(messages)
body = Markup('<br/>').join(messages)
# Post to part catalog chatter
rec.message_post(
body=body,
@@ -271,7 +273,7 @@ class FpPartCatalog(models.Model):
])
for cfg in configurators:
cfg.message_post(
body=_('Part <b>%s</b>: %s') % (rec.name, body),
body=Markup(_('Part <b>%s</b>: %s')) % (rec.name, body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)

View File

@@ -5,6 +5,8 @@
import math
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
@@ -549,7 +551,7 @@ class FpQuoteConfigurator(models.Model):
'won_date': fields.Date.today(),
})
self.message_post(
body=_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.') % (so.id, so.name),
body=Markup(_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.')) % (so.id, so.name),
)
return {
'type': 'ir.actions.act_window',
@@ -623,7 +625,7 @@ class FpQuoteConfigurator(models.Model):
# Post to chatter so user sees confirmation (only if record is saved)
if self.id and not isinstance(self.id, models.NewId):
self.sudo().message_post(
body=_('3D model attached: <b>%s</b> — surface area: %.4f %s') % (
body=Markup(_('3D model attached: <b>%s</b> — surface area: %.4f %s')) % (
fname, self.surface_area, self.surface_area_uom or ''),
message_type='notification',
subtype_xmlid='mail.mt_note',
@@ -666,7 +668,7 @@ class FpQuoteConfigurator(models.Model):
# Post to chatter so user sees confirmation (only if record is saved)
if self.id and not isinstance(self.id, models.NewId):
self.sudo().message_post(
body=_('Drawing attached: <b>%s</b> (linked to part %s)') % (
body=Markup(_('Drawing attached: <b>%s</b> (linked to part %s)')) % (
fname, part.name),
message_type='notification',
subtype_xmlid='mail.mt_note',
@@ -838,7 +840,7 @@ class FpQuoteConfigurator(models.Model):
'complexity': self.complexity,
})
self.message_post(
body=_('Geometry and material saved back to part catalog <b>%s</b>.') % self.part_catalog_id.name,
body=Markup(_('Geometry and material saved back to part catalog <b>%s</b>.')) % self.part_catalog_id.name,
message_type='notification',
subtype_xmlid='mail.mt_note',
)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Customer Portal',
'version': '19.0.2.0.0',
'version': '19.0.2.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
'CoC downloads, invoice access.',

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import _, api, fields, models
@@ -242,11 +244,9 @@ class FpQuoteRequest(models.Model):
# Link back
self.write({'state': 'accepted'})
self.message_post(body=_(
'Sale Order <a href="/odoo/sales/%(so_id)s">%(so_name)s</a> created.',
so_id=so.id,
so_name=so.name,
))
self.message_post(body=Markup(_(
'Sale Order <a href="/odoo/sales/%(so_id)s">%(so_name)s</a> created.'
)) % {'so_id': so.id, 'so_name': so.name})
return {
'type': 'ir.actions.act_window',

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.1.0.0',
'version': '19.0.1.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import api, fields, models
@@ -178,7 +180,7 @@ class FpQualityHold(models.Model):
def _post_state_message(self, label):
for rec in self:
rec.message_post(
body=f"Hold status changed to <b>{label}</b>.",
body=Markup("Hold status changed to <b>%s</b>.") % label,
message_type='comment',
subtype_xmlid='mail.mt_note',
)

View File

@@ -3,11 +3,12 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.4.7.0',
'version': '19.0.4.9.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [
'sale',
'sale_pdf_quote_builder',
'account',
'stock',
'mrp',
@@ -45,6 +46,10 @@
'report/report_fp_bol.xml',
'report/report_fp_invoice.xml',
'report/report_fp_receipt.xml',
# Hide Odoo's default reports from the Print menu wherever FP
# ships an equivalent (loaded last so it overrides any earlier
# binding declarations from base modules).
'data/fp_hide_default_reports.xml',
],
'installable': True,
'application': False,

View File

@@ -0,0 +1,167 @@
<?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.
Hide Odoo's default PDF reports from the Print dropdown wherever
Fusion Plating ships a branded equivalent. This prevents users from
accidentally sending the wrong (unbranded, missing-fields) PDF to
customers when both options are visible side by side.
Mechanism: setting `binding_model_id` to False (and `binding_type`
to 'action') removes the report from the model's Print dropdown but
leaves the underlying report record + template intact. An admin can
re-enable any of these from Settings → Technical → Actions → Reports
if needed (no schema change, fully reversible).
Reports we intentionally leave alone:
- sale.action_report_pro_forma_invoice (no FP pro-forma yet)
- account.action_account_original_vendor_bill
- stock.action_report_picking_packages (internal warehouse ops)
- stock.action_report_picking (internal warehouse ops)
- stock.return_label_report (internal returns)
- mrp.action_report_finished_product (production label, ZPL)
- mrp.label_manufacture_template (ZPL label)
- sale_timesheet.* (timesheet integration)
-->
<odoo noupdate="0">
<!-- ================================================================
sale.order — hide Odoo's PDF Quote + raw Quotation
FP ships fp_sale (portrait + landscape) with full plating layout
================================================================ -->
<record id="sale.action_report_saleorder" model="ir.actions.report">
<field name="binding_model_id" eval="False"/>
<field name="binding_type">action</field>
</record>
<record id="sale_pdf_quote_builder.action_report_saleorder_raw" model="ir.actions.report">
<field name="binding_model_id" eval="False"/>
<field name="binding_type">action</field>
</record>
<!-- ================================================================
account.move — hide Odoo's stock invoice PDFs
FP ships fp_invoice (portrait + landscape) with PO#, plating job
refs, deposit / progress / net-terms strategies built in
================================================================ -->
<record id="account.account_invoices" model="ir.actions.report">
<field name="binding_model_id" eval="False"/>
<field name="binding_type">action</field>
</record>
<record id="account.account_invoices_without_payment" model="ir.actions.report">
<field name="binding_model_id" eval="False"/>
<field name="binding_type">action</field>
</record>
<!-- ================================================================
stock.picking — hide Odoo's Delivery Slip
FP ships fp_packing_slip + fp_bol covering the customer-facing
shipping documents
================================================================ -->
<record id="stock.action_report_delivery" model="ir.actions.report">
<field name="binding_model_id" eval="False"/>
<field name="binding_type">action</field>
</record>
<!-- ================================================================
mrp.production — hide Odoo's Production Order PDF
FP ships fp_job_traveller as the shop-floor router / traveller
================================================================ -->
<record id="mrp.action_report_production_order" model="ir.actions.report">
<field name="binding_model_id" eval="False"/>
<field name="binding_type">action</field>
</record>
<!-- ================================================================
account.payment — hide Odoo's Payment Receipt
FP ships fp_receipt with PO# and plating job context
================================================================ -->
<record id="account.action_report_payment_receipt" model="ir.actions.report">
<field name="binding_model_id" eval="False"/>
<field name="binding_type">action</field>
</record>
<!-- ================================================================
Print-menu sequencing — pin FP reports to the TOP of each
dropdown so customer-facing reports appear before internal
Odoo defaults (timesheets, picking ops, finished-product
labels, etc.) which now sit at sequence 100 by default.
Convention: Portrait = primary (10) → Landscape = secondary (15)
================================================================ -->
<!-- sale.order: Quotation/Sales Order is the primary -->
<record id="fusion_plating_reports.action_report_fp_sale_portrait" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_fp_sale_landscape" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
<record id="fusion_plating_reports.action_report_fp_job_traveller_so_portrait" model="ir.actions.report">
<field name="sequence" eval="20"/>
</record>
<record id="fusion_plating_reports.action_report_fp_job_traveller_so_landscape" model="ir.actions.report">
<field name="sequence" eval="25"/>
</record>
<!-- account.move: Invoice — Plating is the primary -->
<record id="fusion_plating_reports.action_report_fp_invoice_portrait" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_fp_invoice_landscape" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
<!-- stock.picking: Packing Slip is the primary -->
<record id="fusion_plating_reports.action_report_fp_packing_slip_portrait" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_fp_packing_slip_landscape" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
<!-- mrp.production: Job Traveller is the primary -->
<record id="fusion_plating_reports.action_report_fp_job_traveller_mo_portrait" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_fp_job_traveller_mo_landscape" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
<record id="fusion_plating_reports.action_report_wo_margin" model="ir.actions.report">
<field name="sequence" eval="20"/>
</record>
<!-- account.payment: Receipt — primary -->
<record id="fusion_plating_reports.action_report_fp_receipt_portrait" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_fp_receipt_landscape" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
<!-- fusion.plating.delivery: Bill of Lading -->
<record id="fusion_plating_reports.action_report_fp_bol_portrait" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_fp_bol_landscape" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
<!-- fp.certificate: English-first by default -->
<record id="fusion_plating_reports.action_report_coc_en" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_coc_fr" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
<!-- portal job CoC -->
<record id="fusion_plating_reports.action_report_coc_portrait" model="ir.actions.report">
<field name="sequence" eval="10"/>
</record>
<record id="fusion_plating_reports.action_report_coc" model="ir.actions.report">
<field name="sequence" eval="15"/>
</record>
</odoo>

View File

@@ -3,4 +3,5 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import ir_actions_report
from . import report_wo_margin

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Patch ir.actions.report so the Print dropdown can be ordered.
Odoo 19 fetches print-menu bindings via `ir.actions.actions._get_bindings`
which returns reports in `ORDER BY a.id` (insertion order). Only the
`action` bindings get a sequence sort applied — `report` bindings are
returned in the raw SQL order. Result: third-party FP reports installed
after Odoo's stock ones always appear at the BOTTOM of the dropdown,
even when they're the customer-facing primary report.
Two changes:
1. Add a `sequence` Integer field to ir.actions.report.
2. Override `_get_bindings` to also sort report bindings by sequence
(then by name as a tie-breaker), matching the behaviour Odoo
already applies to action bindings.
Lower sequence = appears higher in the Print dropdown.
"""
from odoo import api, fields, models
from odoo.tools import frozendict
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
sequence = fields.Integer(
default=100,
help='Order in which this report appears in the Print menu '
'(lower = higher in the list). Default 100 leaves room '
'for both higher and lower priorities.',
)
class IrActionsActions(models.Model):
_inherit = 'ir.actions.actions'
@api.model
def _get_bindings(self, model_name):
# super() returns a cached frozendict via @tools.ormcache; we
# re-sort the 'report' slice (Odoo already sorts 'action').
result = super()._get_bindings(model_name)
if not result.get('report'):
return result
sorted_reports = tuple(sorted(
result['report'],
key=lambda vals: (
vals.get('sequence', 100),
(vals.get('name') or '').lower(),
),
))
# frozendict is immutable — rebuild from a plain dict.
new_result = dict(result)
new_result['report'] = sorted_reports
return frozendict(new_result)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.14.0.0',
'version': '19.0.14.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -5,6 +5,9 @@
"""JSON-RPC endpoints for the Manager Dashboard (client action)."""
import logging
from markupsafe import Markup
from odoo import http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request
@@ -294,7 +297,7 @@ class FpManagerDashboardController(http.Controller):
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_assigned_user_id = int(user_id) if user_id else False
wo.message_post(
body=f'Worker assigned: <b>{wo.x_fc_assigned_user_id.name or "Unassigned"}</b>',
body=Markup('Worker assigned: <b>%s</b>') % (wo.x_fc_assigned_user_id.name or 'Unassigned'),
)
return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''}
@@ -308,7 +311,7 @@ class FpManagerDashboardController(http.Controller):
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_tank_id = int(tank_id) if tank_id else False
wo.message_post(
body=f'Tank assigned: <b>{wo.x_fc_tank_id.name or "Unassigned"}</b>',
body=Markup('Tank assigned: <b>%s</b>') % (wo.x_fc_tank_id.name or 'Unassigned'),
)
return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''}
@@ -324,6 +327,6 @@ class FpManagerDashboardController(http.Controller):
previous = wo.x_fc_assigned_user_id.name or ''
wo.x_fc_assigned_user_id = user.id
wo.message_post(
body=f'Manager takeover: <b>{user.name}</b> replaces {previous}.',
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (user.name, previous),
)
return {'ok': True, 'user_name': user.name}

View File

@@ -256,6 +256,75 @@ class FpShopfloorController(http.Controller):
'duration': wo.duration,
}
# ----------------------------------------------------------------------
# Thickness reading — Fischerscope log entry from inspection station
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/log_thickness_reading', type='jsonrpc', auth='user')
def log_thickness_reading(self, production_id, nip_mils=None,
ni_percent=None, p_percent=None,
position_label=None, reading_number=None,
equipment_model=None, calibration_std_ref=None,
microscope_image=None,
microscope_image_filename=None):
"""Record a single Fischerscope reading against an MO.
Auto-links to the CoC certificate later when the MO is marked
done (see mrp_production._fp_mark_done_post_actions). Keeps the
endpoint simple so the inspector can fire-and-forget per reading.
"""
Reading = request.env.get('fp.thickness.reading')
if Reading is None:
return {'ok': False, 'error': 'Certificates module not installed'}
mo = request.env['mrp.production'].browse(int(production_id))
if not mo.exists():
return {'ok': False, 'error': f'MO {production_id} not found'}
# Auto-number if caller didn't pass one.
if not reading_number:
existing = Reading.search_count([('production_id', '=', mo.id)])
reading_number = existing + 1
vals = {
'production_id': mo.id,
'reading_number': int(reading_number),
'nip_mils': float(nip_mils or 0.0),
'ni_percent': float(ni_percent or 0.0),
'p_percent': float(p_percent or 0.0),
'position_label': position_label or '',
'operator_id': request.env.user.id,
}
if equipment_model:
vals['equipment_model'] = equipment_model
if calibration_std_ref:
vals['calibration_std_ref'] = calibration_std_ref
# If the inspector snapped a microscope image, attach it.
if microscope_image:
import base64 as _b64
att = request.env['ir.attachment'].create({
'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
'datas': microscope_image,
'res_model': 'fp.thickness.reading',
'mimetype': 'image/jpeg',
})
vals['microscope_image_id'] = att.id
# Auto-link to existing CoC if one already exists for this MO.
Cert = request.env.get('fp.certificate')
if Cert is not None:
existing_cert = Cert.search([
('production_id', '=', mo.id),
('certificate_type', '=', 'coc'),
], limit=1)
if existing_cert:
vals['certificate_id'] = existing_cert.id
reading = Reading.create(vals)
return {
'ok': True,
'reading_id': reading.id,
'reading_number': reading.reading_number,
}
# ----------------------------------------------------------------------
# Quality hold — partial qty split
# ----------------------------------------------------------------------

View File

@@ -81,11 +81,22 @@ class FpOperatorQueue(models.TransientModel):
})
# ----- MRP work orders (if fusion_plating_bridge_mrp installed) -----
# Show two buckets, in this order:
# 1) WOs explicitly assigned to this operator (their named tasks)
# 2) WOs with NO assignment (open for any operator to grab)
# Skip WOs assigned to OTHER operators — strict per-aerospace
# accountability (no one should "borrow" someone else's job).
MrpWO = self.env.get('mrp.workorder')
if MrpWO is not None:
wo_domain = [('state', 'in', ('ready', 'progress'))]
base = [('state', 'in', ('ready', 'progress'))]
if facility_id:
wo_domain.append(('workcenter_id.x_fc_facility_id', '=', facility_id))
base.append(('workcenter_id.x_fc_facility_id', '=', facility_id))
assignment_filter = (
'|',
('x_fc_assigned_user_id', '=', user_id),
('x_fc_assigned_user_id', '=', False),
) if 'x_fc_assigned_user_id' in MrpWO._fields else ()
wo_domain = list(assignment_filter) + base
work_orders = MrpWO.search(wo_domain, order='sequence, date_start')
for wo in work_orders:
rows.append({

View File

@@ -0,0 +1,24 @@
env = env # noqa
# List all ir.actions.report bindings on the models we care about
MODELS = ['sale.order', 'account.move', 'stock.picking', 'mrp.production',
'fusion.plating.delivery', 'account.payment', 'fusion.plating.portal.job',
'fp.certificate']
print(f'{"model":<32} {"xmlid":<55} {"name":<40}')
print('-' * 130)
for m in MODELS:
model = env['ir.model'].search([('model', '=', m)], limit=1)
if not model:
continue
reports = env['ir.actions.report'].search([
('binding_model_id', '=', model.id),
('binding_type', '=', 'report'),
])
for r in reports:
# Get the xmlid
xmlids = env['ir.model.data'].search([
('model', '=', 'ir.actions.report'), ('res_id', '=', r.id)
])
xmlid = ', '.join(f'{x.module}.{x.name}' for x in xmlids) or '(no xmlid)'
is_fp = 'fusion_plating' in xmlid
marker = '✓ FP' if is_fp else ' '
print(f' {marker} {m:<28} {xmlid:<55} {r.name[:40]}')

View File

@@ -0,0 +1,11 @@
env = env # noqa
recipe = env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe'), ('name', '=', 'ENP-ALUM-BASIC')], limit=1)
print(f'Recipe: {recipe.name}')
def walk(node, indent=0):
pt = node.process_type_id.process_family if node.process_type_id else '(none)'
wc = node.work_center_id.name if node.work_center_id else '(none)'
print(f'{" "*indent}- [{node.node_type:9}] {node.name!r:35} pt_family={pt!r:18} wc={wc}')
for c in node.child_ids.sorted('sequence'):
walk(c, indent+1)
walk(recipe)

View File

@@ -0,0 +1,31 @@
env = env # noqa
# Force generation of both bundles
for bundle_name in ('web.assets_backend', 'web.assets_web_dark'):
bundle = env['ir.qweb']._get_asset_bundle(bundle_name)
css = bundle.css() # this materializes the attachment
print(f'{bundle_name}: triggered, css() type={type(css).__name__}')
env.cr.commit()
# Now find them
attachs = env['ir.attachment'].sudo().search(
[('url', 'like', '/web/assets/%')],
order='id desc',
)
print(f'\\n{len(attachs)} asset attachments after force-compile:')
for a in attachs:
raw_size = len(a.raw or b'')
print(f' [{a.id}] {a.name} ({raw_size} bytes)')
# Check the dark one for our marker
dark = attachs.filtered(lambda a: 'web.assets_web_dark' in (a.name or ''))
if dark:
text = (dark[0].raw or b'').decode('utf-8', errors='ignore')
print(f'\\ndark bundle markers:')
print(f' o-mail-Message-actions: {text.count("o-mail-Message-actions")} occurrences')
print(f' #2b2f33 marker : {text.count("#2b2f33")} occurrences')
print(f' rgba(255, 255, 255, 0.10) marker: {text.count("rgba(255, 255, 255, 0.10)")} occurrences')
if '#2b2f33' in text:
idx = text.find('#2b2f33')
print(f'\\ncontext around our color:')
print(text[max(0, idx-300):idx+300])

View File

@@ -0,0 +1,686 @@
# -*- coding: utf-8 -*-
"""Comprehensive E2E simulator — workforce edition.
Role-plays each employee touching a job from quote → invoice. For
each work order:
• The assigned operator clocks in (button_start)
• Real time elapses (time.sleep)
• Chemistry / quality data is logged where relevant
• The operator clocks out (button_finish)
Then audits:
• Per-WO duration captured (mrp.workorder.duration)
• mrp.workcenter.productivity records exist with operator user
• Chemistry log entries on bath
• Certificate state, attachment, thickness readings
• Chain-of-custody entries on delivery
• Notification log with attachment names
• Portal job final state + SO workflow_stage
Findings printed at the end as PASS/FAIL/WARN — each FAIL/WARN is a
gap that needs fixing before this can ship to a real shop floor.
"""
from datetime import datetime
import time
import base64
env = env # noqa injected by odoo shell
from odoo import fields # noqa
def banner(label):
print(f'\n{"="*76}\n {label}\n{"="*76}')
def step(actor, action):
print(f' → [{actor:<14}] {action}')
def show(label, value):
print(f' {label:<32} {value}')
FINDINGS = []
def finding(level, area, msg):
"""level: PASS | WARN | FAIL"""
FINDINGS.append((level, area, msg))
sym = {'PASS': '', 'WARN': '', 'FAIL': ''}[level]
print(f' {sym} {level:<5} [{area}] {msg}')
stamp = datetime.now().strftime('%y%m%d-%H%M%S')
# =====================================================================
banner(f'PHASE 0 — Set up cast of employees ({stamp})')
# =====================================================================
# Reuse existing users when present so we don't bloat the DB on reruns.
# Each persona gets a real res.users so with_user() exercises permission
# checks the way an operator would experience them on the iPad.
PERSONAS = {
'sandra': ('Sandra Kim', 'Sales rep / estimator'),
'carlos': ('Carlos Reyes', 'Receiving clerk'),
'hannah': ('Hannah Patel', 'Production planner / manager'),
'john': ('John Murphy', 'Masking operator'),
'maria': ('Maria Lopez', 'Rack / handler'),
'tom': ('Tom Wright', 'Plater'),
'ana': ('Ana Silva', 'De-mask / clean'),
'frank': ('Frank Bauer', 'QC / inspector'),
'dave': ('Dave Chen', 'Driver'),
'linda': ('Linda Brown', 'Accounting'),
}
users = {}
mgr_group = env.ref('fusion_plating.group_fusion_plating_manager', raise_if_not_found=False)
op_group = env.ref('fusion_plating.group_fusion_plating_operator', raise_if_not_found=False)
internal_group = env.ref('base.group_user')
for key, (name, desc) in PERSONAS.items():
login = f'fp_{key}'
u = env['res.users'].search([('login', '=', login)], limit=1)
if not u:
u = env['res.users'].sudo().create({
'name': name,
'login': login,
'email': f'{login}@enplating.example',
'group_ids': [(6, 0, [internal_group.id])],
})
# Put managers in the manager group, operators in the operator group
extra = mgr_group if key in ('hannah',) else op_group
if extra and extra not in u.group_ids:
u.sudo().write({'group_ids': [(4, extra.id)]})
users[key] = u
# Make sure each has an hr.employee record (proficiency tracking
# writes to employee records).
emp = env['hr.employee'].search([('user_id', '=', u.id)], limit=1)
if not emp:
emp = env['hr.employee'].sudo().create({
'name': name,
'user_id': u.id,
})
show(f'{key:<8}', f'{u.name} ({desc}) — uid={u.id}, emp={emp.id}')
# =====================================================================
banner('PHASE 1 — Sandra builds a quote (estimator)')
# =====================================================================
customer = env['res.partner'].sudo().create({
'name': f'Beacon Aerospace {stamp}',
'company_type': 'company',
'email': f'orders-{stamp}@beacon.example',
'phone': '+1-416-555-0199',
'street': '500 University Ave',
'city': 'Toronto', 'zip': 'M5G 1V7',
'country_id': env.ref('base.ca').id,
})
step('SANDRA', f'Receives RFQ from {customer.name}')
rfq = env['fusion.plating.quote.request'].with_user(users['sandra']).sudo().create({
'partner_id': customer.id,
'contact_name': 'Procurement',
'contact_email': customer.email,
'company_name': customer.name,
'part_description': '<p>40 housings, AMS 2404, 50µin ENP, rush.</p>',
'quantity': 40,
'state': 'new',
})
show('RFQ', f'{rfq.name}')
step('SANDRA', 'Builds configurator quote with PO# and override price')
coating = env['fp.coating.config'].search([], limit=1)
part_cat = env['fp.part.catalog'].search([], limit=1)
po_number = f'PO-BCN-{stamp}'
quote = env['fp.quote.configurator'].with_user(users['sandra']).sudo().create({
'partner_id': customer.id,
'part_catalog_id': part_cat.id,
'coating_config_id': coating.id,
'quantity': 40,
'po_number_preliminary': po_number,
'estimator_override_price': 3200.00,
'rush_order': True,
})
result = quote.with_user(users['sandra']).sudo().action_create_quotation()
so = env['sale.order'].browse(result.get('res_id'))
show('SO', f'{so.name} ({so.amount_total:,.2f})')
finding('PASS' if so.client_order_ref == po_number else 'FAIL',
'quote→SO PO#', f'client_order_ref="{so.client_order_ref}"')
# =====================================================================
banner('PHASE 2 — Customer accepts → SO confirm → auto-MO + portal job')
# =====================================================================
step('CUSTOMER', 'Accepts quote — Sandra confirms SO')
so.with_user(users['sandra']).sudo().action_confirm()
finding('PASS' if so.state == 'sale' else 'FAIL', 'SO confirm', f'state={so.state}')
mo = env['mrp.production'].search([('origin', '=', so.name)], limit=1)
finding('PASS' if mo else 'FAIL', 'auto-MO', mo.name if mo else 'MISSING')
if mo and mo.state == 'draft':
mo.with_user(users['hannah']).sudo().action_confirm()
finding('PASS' if mo and mo.state == 'confirmed' else 'WARN',
'MO confirm', f'state={mo.state if mo else "n/a"}')
job = mo.x_fc_portal_job_id if mo else False
finding('PASS' if job else 'FAIL', 'portal job', job.name if job else 'MISSING')
# =====================================================================
banner('PHASE 3 — Carlos receives parts')
# =====================================================================
step('CARLOS', 'Logs receiving — 40 housings in 2 boxes from FedEx')
recv = env['fp.receiving'].with_user(users['carlos']).sudo().create({
'partner_id': customer.id,
'sale_order_id': so.id,
'received_date': fields.Datetime.now(),
'expected_qty': 40,
'carrier_name': 'FedEx',
'carrier_tracking': f'FX{stamp}',
'line_ids': [(0, 0, {
'description': '40 stainless aero housings',
'expected_qty': 40,
'received_qty': 40,
})],
})
finding('PASS' if recv.received_qty == 40 else 'FAIL',
'receiving prefill', f'expected={recv.expected_qty} received={recv.received_qty}')
step('CARLOS', 'Inspects → accepts')
recv.with_user(users['carlos']).sudo().action_start_inspection()
recv.with_user(users['carlos']).sudo().action_accept()
finding('PASS' if recv.state == 'accepted' else 'FAIL',
'receiving accept', f'state={recv.state}')
# =====================================================================
banner('PHASE 4 — Hannah plans the job')
# =====================================================================
step('HANNAH', 'Assigns recipe + generates work orders')
recipe = env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1)
mo_h = mo.with_user(users['hannah']).sudo()
if not mo_h.x_fc_recipe_id:
mo_h.x_fc_recipe_id = recipe.id
mo_h._generate_workorders_from_recipe()
n_wos = len(mo.workorder_ids)
finding('PASS' if n_wos > 0 else 'FAIL', 'WOs generated', f'{n_wos} work orders from {recipe.name}')
# Map operations to operators by station/role hints
WO_OPERATORS = {
'masking': 'john',
'racking': 'maria',
'ready': 'maria',
'plating': 'tom',
'enickel': 'tom',
'nickel': 'tom',
'demask': 'ana',
'de-mask': 'ana',
'clean': 'ana',
'rinse': 'ana',
'inspect': 'frank',
'qc': 'frank',
}
step('HANNAH', 'Assigns each WO to a specific operator')
# Pick a bath + a tank for any WO that needs wet-process traceability
test_bath = env['fusion.plating.bath'].search([], limit=1)
test_tank = env['fusion.plating.tank'].search([], limit=1)
# Issue operator certifications for the bath's process type so the cert
# gate doesn't block legitimate operators (in real life the manager
# tracks training + issues certs; for a clean E2E we pre-issue).
Cert = env.get('fp.operator.certification')
if Cert is not None and test_bath and test_bath.process_type_id:
pt = test_bath.process_type_id
for op_key in ('john', 'maria', 'tom', 'ana', 'frank'):
emp = env['hr.employee'].search(
[('user_id', '=', users[op_key].id)], limit=1)
if not emp:
continue
existing = Cert.sudo().search([
('employee_id', '=', emp.id),
('process_type_id', '=', pt.id),
('revoked', '=', False),
], limit=1)
if not existing:
Cert.sudo().create({
'employee_id': emp.id,
'process_type_id': pt.id,
'issued_by_id': users['hannah'].id,
'notes': 'Auto-issued for E2E workforce simulation',
})
show(' certifications', f'pre-issued for {pt.name} → 5 operators')
show(' test bath', f'{test_bath.name}' if test_bath else '(none — wet-WO assignment will fail)')
show(' test tank', f'{test_tank.name}' if test_tank else '(none — wet-WO assignment will fail)')
assignments = []
wet_assignments = []
for wo in mo.workorder_ids:
name_l = (wo.name or '').lower()
operator_key = None
for kw, k in WO_OPERATORS.items():
if kw in name_l:
operator_key = k
break
operator_key = operator_key or 'john'
op_user = users[operator_key]
wo.sudo().x_fc_assigned_user_id = op_user.id
# If this is a wet-process WO (E-Nickel Plating, etch, rinse, etc.)
# Hannah must also pin the exact bath + tank for traceability.
is_wet = wo._fp_is_wet_process() if hasattr(wo, '_fp_is_wet_process') else False
bath_assigned = tank_assigned = False
if is_wet and test_bath and test_tank:
wo.sudo().write({
'x_fc_bath_id': test_bath.id,
'x_fc_tank_id': test_tank.id,
})
bath_assigned = True
tank_assigned = True
wet_assignments.append(wo)
assignments.append((wo, op_user, operator_key))
extras = ''
if is_wet:
extras = f' [WET — bath={test_bath.name if bath_assigned else "MISSING"}, tank={test_tank.name if tank_assigned else "MISSING"}]'
show(f' WO {wo.id}', f'"{wo.name}"{op_user.name}{extras}')
assigned_count = sum(1 for w, _, _ in assignments if w.x_fc_assigned_user_id)
finding('PASS' if assigned_count == n_wos else 'FAIL',
'WO assignment', f'{assigned_count}/{n_wos} have x_fc_assigned_user_id')
wet_with_bath = sum(1 for w in wet_assignments if w.x_fc_bath_id and w.x_fc_tank_id)
finding('PASS' if (not wet_assignments) or (wet_with_bath == len(wet_assignments)) else 'FAIL',
'wet-WO bath+tank set',
f'{wet_with_bath}/{len(wet_assignments)} wet WOs have both bath + tank')
# ===== Negative tests: validation MUST block bad starts =====
banner('PHASE 4b — Negative tests: validation gates fire correctly')
# Test 1: try to start a WO with operator stripped → expect UserError
step('SYSTEM', 'Test 1 — un-assigning operator and trying to start')
test_wo = mo.workorder_ids[0]
saved_op = test_wo.x_fc_assigned_user_id.id
test_wo.sudo().x_fc_assigned_user_id = False
gate_fired = False
try:
test_wo.sudo().button_start()
except Exception as e:
gate_fired = 'Assigned Operator' in str(e) or 'required' in str(e).lower()
show(' blocked with', str(e).splitlines()[0][:120])
finding('PASS' if gate_fired else 'FAIL',
'gate: missing operator',
'blocked' if gate_fired else 'NOT blocked — validation broken')
test_wo.sudo().x_fc_assigned_user_id = saved_op
# Test 2: try to start a WET WO without bath/tank → expect UserError
if wet_assignments:
step('SYSTEM', 'Test 2 — wet WO with bath/tank stripped')
wet_wo = wet_assignments[0]
saved_bath = wet_wo.x_fc_bath_id.id
saved_tank = wet_wo.x_fc_tank_id.id
wet_wo.sudo().write({'x_fc_bath_id': False, 'x_fc_tank_id': False})
gate_fired = False
try:
wet_wo.sudo().button_start()
except Exception as e:
msg = str(e)
gate_fired = ('Bath' in msg and 'Tank' in msg) or 'required' in msg.lower()
show(' blocked with', msg.splitlines()[0][:120])
finding('PASS' if gate_fired else 'FAIL',
'gate: missing bath/tank on wet WO',
'blocked' if gate_fired else 'NOT blocked — validation broken')
wet_wo.sudo().write({
'x_fc_bath_id': saved_bath,
'x_fc_tank_id': saved_tank,
})
# =====================================================================
banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)')
# =====================================================================
# Pick a bath for the plating step so chemistry logging has somewhere
# to land.
bath = env['fusion.plating.bath'].search([], limit=1)
if bath:
show('test bath', f'{bath.name} (id={bath.id})')
batch = None # will hold the rack batch if batch model is present
FpBatch = env.get('fusion.plating.batch')
if FpBatch is not None and recipe:
step('HANNAH', 'Creates a rack batch for the plating step')
batch_vals = {'production_id': mo.id, 'part_count': 40}
if bath:
batch_vals['bath_id'] = bath.id
facility = env['fusion.plating.facility'].search([], limit=1)
if facility:
batch_vals['facility_id'] = facility.id
try:
batch = FpBatch.with_user(users['hannah']).sudo().create(batch_vals)
show('batch', f'{batch.name}')
except Exception as e:
finding('WARN', 'batch create', str(e))
batch = None
WO_DURATIONS_BEFORE = {wo.id: wo.duration for wo in mo.workorder_ids}
for wo, op_user, op_key in assignments:
actor = PERSONAS[op_key][0].split()[0].upper()
step(actor, f'Picks up "{wo.name}" on iPad — taps START')
wo_op = wo.with_user(op_user).sudo()
started_state = wo_op.state
try:
if wo_op.state in ('pending', 'waiting', 'ready'):
wo_op.button_start()
except Exception as e:
finding('WARN', f'WO start ({op_key})', f'{wo.name}: {e}')
continue
show(f' state', f'{started_state}{wo_op.state}')
# Real-time work — sleep 2s for non-plating, 4s for plating
work_seconds = 4 if 'plating' in (wo.name or '').lower() else 2
show(f' working...', f'{work_seconds}s elapsed')
time.sleep(work_seconds)
# Tom logs chemistry mid-bath
if 'plating' in (wo.name or '').lower() and bath and op_key == 'tom':
step(actor, 'Logs bath chemistry while plating')
params = env['fusion.plating.bath.parameter'].search([], limit=2)
if params:
log = env['fusion.plating.bath.log'].with_user(op_user).sudo().create({
'bath_id': bath.id,
'shift': 'day',
'notes': 'Mid-bath check during E2E run',
'line_ids': [
(0, 0, {'parameter_id': p.id, 'value': 5.5})
for p in params
],
})
show(' chemistry log', f'{log.id} ({len(log.line_ids)} readings)')
else:
finding('WARN', 'chemistry', 'no fusion.plating.bath.parameter records — log skipped')
# Frank logs Fischerscope thickness readings during inspection
if 'inspect' in (wo.name or '').lower() and op_key == 'frank':
step(actor, 'Records 5 Fischerscope thickness readings')
Reading = env.get('fp.thickness.reading')
if Reading is not None:
for n, (pos, nip) in enumerate([
('Top edge', 0.0512),
('Mid surface', 0.0498),
('Bottom rim', 0.0521),
('Inner bore', 0.0489),
('Outer flange', 0.0507),
], 1):
Reading.with_user(op_user).sudo().create({
'production_id': mo.id,
'reading_number': n,
'nip_mils': nip,
'ni_percent': 90.5,
'p_percent': 9.5,
'position_label': pos,
'operator_id': op_user.id,
})
n_readings = Reading.search_count([('production_id', '=', mo.id)])
show(' thickness readings', f'{n_readings} logged for {mo.name}')
step(actor, 'Taps FINISH')
try:
if wo_op.state == 'progress':
wo_op.button_finish()
except Exception as e:
finding('WARN', f'WO finish ({op_key})', f'{wo.name}: {e}')
continue
show(f' state', wo_op.state)
show(f' duration', f'{wo.duration:.2f} min')
# Tally results per WO
nonzero = sum(1 for wo in mo.workorder_ids if wo.duration > 0)
finding('PASS' if nonzero == n_wos else 'WARN',
'time tracking', f'{nonzero}/{n_wos} WOs have duration > 0')
# Check Odoo's underlying productivity records
prod_recs = env['mrp.workcenter.productivity'].sudo().search([
('workorder_id', 'in', mo.workorder_ids.ids),
])
finding('PASS' if len(prod_recs) > 0 else 'WARN',
'productivity records', f'{len(prod_recs)} mrp.workcenter.productivity rows logged')
# Per-operator productivity
distinct_operators_logged = len(set(prod_recs.mapped('user_id')))
finding('PASS' if distinct_operators_logged > 1 else 'WARN',
'per-operator productivity',
f'{distinct_operators_logged} distinct operators recorded')
# =====================================================================
banner('PHASE 6 — Hannah closes the MO')
# =====================================================================
step('HANNAH', 'Marks MO done')
try:
mo_h.button_mark_done()
except Exception as e:
print(f' [info] mark_done: {e} — falling back')
try:
mo_h.qty_producing = mo.product_qty
mo_h._action_done()
except Exception as e2:
print(f' [info] _action_done: {e2}')
finding('PASS' if mo.state == 'done' else 'FAIL', 'MO done', f'state={mo.state}')
# =====================================================================
banner('PHASE 7 — Frank inspects + CoC')
# =====================================================================
certs = env['fp.certificate'].search([('production_id', '=', mo.id)])
coc = certs.filtered(lambda c: c.certificate_type == 'coc')[:1]
finding('PASS' if coc else 'FAIL', 'CoC auto-create', coc.name if coc else 'MISSING')
if coc:
finding('PASS' if coc.state == 'issued' else 'WARN',
'CoC issued', f'state={coc.state}')
finding('PASS' if coc.attachment_id else 'FAIL',
'CoC PDF attached', coc.attachment_id.name if coc.attachment_id else 'MISSING')
if coc.attachment_id:
kb = len(base64.b64decode(coc.attachment_id.datas)) / 1024
finding('PASS' if kb >= 100 else 'FAIL',
'CoC PDF rich (>=100KB)', f'{kb:.1f} KB')
# Thickness readings on cert
if 'thickness_reading_ids' in coc._fields:
n_readings = len(coc.thickness_reading_ids)
finding('PASS' if n_readings > 0 else 'WARN',
'thickness readings', f'{n_readings} reading rows')
step('FRANK', 'Reviews + signs CoC (already auto-issued)')
# =====================================================================
banner('PHASE 8 — Dave drives the delivery')
# =====================================================================
dlv = env['fusion.plating.delivery'].search(
[('partner_id', '=', customer.id)], order='id desc', limit=1)
finding('PASS' if dlv else 'FAIL', 'delivery auto-create', dlv.name if dlv else 'MISSING')
if dlv:
finding('PASS' if dlv.scheduled_date else 'WARN',
'delivery scheduled prefill', str(dlv.scheduled_date or 'empty'))
finding('PASS' if dlv.assigned_driver_id else 'WARN',
'delivery driver prefill',
dlv.assigned_driver_id.name if dlv.assigned_driver_id else 'empty')
finding('PASS' if dlv.coc_attachment_id else 'WARN',
'CoC linked to delivery',
dlv.coc_attachment_id.name if dlv.coc_attachment_id else 'missing')
step('DAVE', 'Schedules → start route → mark delivered')
try:
if dlv.state == 'draft': dlv.with_user(users['dave']).sudo().action_schedule()
if dlv.state == 'scheduled': dlv.with_user(users['dave']).sudo().action_start_route()
if dlv.state == 'en_route': dlv.with_user(users['dave']).sudo().action_mark_delivered()
except Exception as e:
print(f' [info] delivery transitions: {e}')
finding('PASS' if dlv.state == 'delivered' else 'FAIL',
'delivery final state', dlv.state)
coc_logs = env['fusion.plating.chain.of.custody'].search(
[('delivery_id', '=', dlv.id)])
finding('PASS' if len(coc_logs) >= 2 else 'WARN',
'chain of custody', f'{len(coc_logs)} entries')
# =====================================================================
banner('PHASE 9 — Linda creates + posts invoice')
# =====================================================================
step('LINDA', 'Creates invoice from SO')
try:
inv_act = so.with_user(users['linda']).sudo()._create_invoices()
inv = inv_act if hasattr(inv_act, '_name') else env['account.move'].browse(
inv_act.get('res_id') if isinstance(inv_act, dict) else inv_act)
except Exception as e:
print(f' [info] _create_invoices: {e}')
inv = env['account.move'].search([('invoice_origin', '=', so.name)], limit=1)
if inv:
inv.invoice_date = fields.Date.today()
try:
inv.with_user(users['linda']).sudo().action_post()
except Exception as e:
finding('FAIL', 'invoice post', str(e))
finding('PASS' if inv.state == 'posted' else 'FAIL',
'invoice posted', f'state={inv.state}, payment_state={inv.payment_state}')
# =====================================================================
banner('PHASE 10 — Compliance + notification audit')
# =====================================================================
# Notification log
logs = env['fp.notification.log'].search(
[('sale_order_id', '=', so.id)], order='create_date')
events = logs.mapped('trigger_event')
EXPECTED_EVENTS = {'so_confirmed', 'parts_received', 'mo_complete',
'shipped', 'invoice_posted'}
seen = set(events)
missing = EXPECTED_EVENTS - seen
finding('PASS' if not missing else 'FAIL',
'notifications fired',
f'sent={sorted(seen)}; missing={sorted(missing) if missing else "none"}')
# Each notification has the right attachment?
for ev_log in logs:
needed = {
'so_confirmed': 'Quotation',
'shipped': 'CoC',
'invoice_posted': 'Invoice',
}
expected_in_attachments = needed.get(ev_log.trigger_event)
if expected_in_attachments:
att_names = ev_log.attachment_names or ''
ok = expected_in_attachments.lower() in att_names.lower()
finding('PASS' if ok else 'WARN',
f'{ev_log.trigger_event} attachment',
f'expected "{expected_in_attachments}" in: {att_names!r}')
# Workflow stage
finding('PASS' if so.x_fc_workflow_stage in ('complete', 'invoicing', 'paid') else 'WARN',
'final SO workflow stage', so.x_fc_workflow_stage)
# Portal job state
job_now = env['fusion.plating.portal.job'].browse(job.id) if job else None
if job_now:
finding('PASS' if job_now.state in ('shipped', 'complete') else 'WARN',
'final portal job state', job_now.state)
# Bath chemistry logged?
bath_logs_during = env['fusion.plating.bath.log'].search(
[('bath_id', '=', bath.id), ('id', '>=', max([0] + prod_recs.ids))],
limit=10) if bath else env['fusion.plating.bath.log']
recent_bath_log = env['fusion.plating.bath.log'].search([], order='id desc', limit=1)
finding('PASS' if recent_bath_log and recent_bath_log.create_date else 'WARN',
'chemistry log persisted', f'most-recent log id={recent_bath_log.id if recent_bath_log else "none"}')
# Bake window auto-created after plating? Bake-window links via lot_ref (portal job name)
BakeWin = env.get('fusion.plating.bake.window')
if BakeWin is not None and job:
bw = BakeWin.search([('lot_ref', '=', job.name)])
finding('PASS' if bw else 'WARN',
'bake window auto-created',
f'{len(bw)} record(s) for {job.name}')
# First-piece gate auto-created?
FPG = env.get('fusion.plating.first.piece.gate')
if FPG is not None:
# FPG model may not have production_id either; try common link fields
fpg = FPG.search([]) # take any recent
fpg_for_mo = fpg.filtered(
lambda g: getattr(g, 'production_id', False) and g.production_id.id == mo.id
) if 'production_id' in FPG._fields else fpg.browse([])
finding('PASS' if fpg_for_mo else 'WARN',
'first-piece gate',
f'{len(fpg_for_mo)} for MO (coating-driven; OK if 0)')
# Each operator can see their OWN assigned WOs via the tablet
# (queue is a TransientModel; tablet calls build_for_user on load)
# Reset MO to make some WOs ready/progress for queue test BEFORE this is run
# would be needed — but the queue should still work for any in-progress WOs
# elsewhere in the system that match the user.
OpQueue = env.get('fusion.plating.operator.queue')
if OpQueue is not None:
# Create a second test MO so there's a WO in 'ready' state to queue
test_mo = env['mrp.production'].search(
[('state', 'in', ('confirmed', 'progress'))], limit=1)
if test_mo and test_mo.workorder_ids:
# Force-assign a ready WO to John so we have something to surface
ready_wo = test_mo.workorder_ids.filtered(lambda w: w.state in ('ready', 'progress'))[:1]
if ready_wo:
ready_wo.sudo().x_fc_assigned_user_id = users['john'].id
for op_key, op_user in [('john', users['john']), ('tom', users['tom']),
('frank', users['frank'])]:
rows = OpQueue.with_user(op_user).sudo().build_for_user(user_id=op_user.id)
finding('PASS' if rows else 'WARN',
f'tablet queue for {op_key}',
f'{len(rows)} queue rows visible to {op_user.name}')
# Verify NONE of the rows are someone else's assigned WO
if rows:
wo_rows = rows.filtered(lambda r: r.source_model == 'mrp.workorder')
wrong = []
for r in wo_rows:
wo = env['mrp.workorder'].browse(r.source_id)
if wo.exists() and wo.x_fc_assigned_user_id and wo.x_fc_assigned_user_id != op_user:
wrong.append(wo.name)
finding('PASS' if not wrong else 'FAIL',
f'queue isolation for {op_key}',
f'leaked rows assigned to others: {wrong}' if wrong else 'no leak')
# Worker proficiency advanced for completed roles?
prof_records = env['fp.operator.proficiency'].search([
('employee_id', 'in',
env['hr.employee'].search([('user_id', 'in', list(u.id for u in users.values()))]).ids),
]) if env.get('fp.operator.proficiency') is not None else None
if prof_records is not None:
finding('PASS' if len(prof_records) > 0 else 'WARN',
'operator proficiency tracked',
f'{len(prof_records)} (employee,role) proficiency rows')
# =====================================================================
banner('SUMMARY')
# =====================================================================
passed = sum(1 for l, _, _ in FINDINGS if l == 'PASS')
warns = sum(1 for l, _, _ in FINDINGS if l == 'WARN')
fails = sum(1 for l, _, _ in FINDINGS if l == 'FAIL')
print(f' {passed} PASS / {warns} WARN / {fails} FAIL (out of {len(FINDINGS)} checks)')
print(f' customer: {customer.name}')
print(f' SO : {so.name}')
print(f' MO : {mo.name}{mo.state}')
print(f' WOs : {n_wos}, total time = {sum(mo.workorder_ids.mapped("duration")):.2f} min')
print(f' CoC : {coc.name if coc else "(none)"}')
print(f' delivery : {dlv.name if dlv else "(none)"}{dlv.state if dlv else "n/a"}')
print(f' invoice : {inv.name if inv else "(none)"}')
print(f' portal : {job.name if job else "(none)"} → final {job_now.state if job_now else "n/a"}')
if warns or fails:
print(f'\n ── GAPS / FAILS ──')
for level, area, msg in FINDINGS:
if level in ('WARN', 'FAIL'):
print(f' {level} [{area}] {msg}')
env.cr.commit()
print('\n → committed.\n')

View File

@@ -0,0 +1,24 @@
env = env # noqa
# Use the SAME path the web client uses (the cog menu) — _get_bindings.
# This honours the new sequence-based sort we just added.
MODELS = ['sale.order', 'account.move', 'stock.picking', 'mrp.production',
'fusion.plating.delivery', 'account.payment', 'fusion.plating.portal.job',
'fp.certificate']
Actions = env['ir.actions.actions']
Actions.clear_caches() if hasattr(Actions, 'clear_caches') else env.registry.clear_cache()
for m in MODELS:
bindings = Actions._get_bindings(m)
reports = bindings.get('report', ())
if not reports:
continue
print(f'\\n=== {m} (top→bottom in Print menu) ===')
for i, r in enumerate(reports, 1):
# Get xmlid
xmlids = env['ir.model.data'].search([
('model', '=', 'ir.actions.report'), ('res_id', '=', r['id'])
])
xmlid = ', '.join(f'{x.module}.{x.name}' for x in xmlids) or '(no xmlid)'
is_fp = 'fusion_plating' in xmlid
marker = '' if is_fp else ' '
seq = r.get('sequence', 100)
print(f' {marker} {i:>2}. seq={seq:<4} {r["name"]}')

View File

@@ -0,0 +1,32 @@
env = env # noqa
# Pick the SO we last tested
so = env['sale.order'].search([('name', '=', 'S00038')], limit=1)
if not so:
print('S00038 not found, picking last sale.order')
so = env['sale.order'].search([], order='id desc', limit=1)
print(f'SO: {so.name}')
print(f' state: {so.state}')
print(f' invoice_status: {so.invoice_status}')
print(f' invoice_ids: {[(i.name, i.state, i.payment_state) for i in so.invoice_ids]}')
print(f' workflow_stage: {so.x_fc_workflow_stage}')
print(f' → BANNER VISIBLE? {so.x_fc_workflow_stage not in ("draft","invoicing","paid","complete","cancelled")}')
# Post a fresh test message that exercises the new Markup path
mo = env['mrp.production'].search([('origin', '=', so.name)], limit=1)
if mo:
from markupsafe import Markup
so.message_post(body=Markup(
'TEST: Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
'should render as a clickable link with <b>bold text</b>.'
) % (mo.id, mo.name))
print(f'\\nposted test message on {so.name} referencing {mo.name}')
# Check the latest 2 messages on the SO
msgs = env['mail.message'].search([
('model', '=', 'sale.order'), ('res_id', '=', so.id),
], order='id desc', limit=3)
print(f'\\nLast {len(msgs)} chatter messages on {so.name}:')
for m in msgs:
body = (m.body or '')[:200]
print(f' [{m.id}] {body!r}')
env.cr.commit()