Compare commits

...

3 Commits

Author SHA1 Message Date
gsinghpal
d953525758 fix(fusion_accounting_bank_rec): MV correctness for V19 schema + Odoo test harness
Three issues surfaced when running the MV smoke tests against westin-v19:

1. account_bank_statement_line has no `date` column in V19 — `date` is a
   related field flowing through move_id -> account_move.date. The MV
   now JOINs account_move and selects am.date.
2. is_reconciled is nullable; replace `= FALSE` with `IS NOT TRUE` so
   nulls (genuinely unreconciled lines that haven't had the compute run
   yet) are still included.
3. _refresh() now flushes the ORM cache (env.flush_all()) before the
   REFRESH so computed-stored fields like is_reconciled are written to
   the DB before the materialization snapshot reads them. Previously the
   reconcile-then-refresh path saw the pre-reconcile column value.
4. _trigger_mv_refresh() (suggestion create/write hook) now uses
   concurrently=False because Postgres forbids
   REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
   and Odoo's per-request cursor is always inside one. The cron path
   (Task 25) will open an autocommit cursor for CONCURRENTLY refreshes.
5. Tests dropped the env.cr.commit() pattern: Postgres always shows a
   transaction its own writes, so a non-CONCURRENTLY refresh in the
   same txn picks up freshly-inserted rows. Cleaner + works inside
   TransactionCase, which forbids cr.commit().

Verified: 4 new MV tests pass, 0 failures across 118 logical tests
(178 with parametrized property-based runs) of fusion_accounting_bank_rec
on westin-v19.

Made-with: Cursor
2026-04-19 11:51:02 -04:00
gsinghpal
12b6b46e2e feat(fusion_accounting_bank_rec): pre-aggregated MV for OWL widget perf
CREATE MATERIALIZED VIEW fusion_unreconciled_bank_line_mv pre-computes
the data the kanban widget needs (top suggestion, confidence band,
attachment count, partner reconcile hint) so that listing 50-100 lines
is one indexed query instead of N+1.

Refresh strategy:
- Triggered on fusion.reconcile.suggestion create/write (best-effort,
  never poisons the originating transaction)
- Cron (every 5 min) — added in Task 25

The MV is created in the model's init() (Odoo calls this on
install/upgrade). The SQL DDL is idempotent
(CREATE MATERIALIZED VIEW IF NOT EXISTS / CREATE INDEX IF NOT EXISTS)
and includes a UNIQUE(id) index so REFRESH MATERIALIZED VIEW
CONCURRENTLY is supported. _refresh() falls back to a blocking refresh
on the first call after creation.

Made-with: Cursor
2026-04-19 11:45:36 -04:00
gsinghpal
4ffbdc596d feat(plating): per-step compliance gates + backfill — 0 CRITICAL gaps
Per-step audit caught real enforcement bugs across all 9 WO kinds in
the recipe (Masking, Racking, Plating, De-Masking, Oven baking, etc.).
Five gates added or fixed; 0 CRITICAL gaps remain after a verification
run on a fresh MO.

**1. Bake-WO finish gate** (`_fp_check_required_fields_before_finish`)
button_finish on a bake WO now blocks unless:
  • x_fc_bake_temp set (Nadcap req — actual setpoint, not just oven)
  • x_fc_bake_duration_hours set (actual run time at temp)
  • x_fc_oven_id.chart_recorder_ref set (so the chart for THIS run
    can be retrieved by an auditor — required for AS9100/Nadcap)

Run-time data lives at FINISH, not START — operators don't know
temp/duration until the bake is done.

**2. Rack-WO start gate** added to the existing button_start gate.
Per-rack life tracking + which physical fixture handled the parts.

**3. Classifier priority fix** (`_fp_classify_kind`)
"Post-plate Inspection" was matching the `plat` wet keyword and
getting kind=wet (then required to have bath/tank). Reordered:
  1. Explicit equipment links (bath_id/oven_id)
  2. Specific keywords (inspect → mask → bake → rack)
     — bake before rack so "Oven bake (Post de-rack)" → bake
  3. Workcenter wet families
  4. Wet name keywords as last fallback

**4. Auto-populate target_thickness + dwell_time** at recipe→WO
generation. Plating WOs inherit:
  • thickness_target from coating_config.thickness_max
  • thickness_uom from coating_config.thickness_uom
  • dwell_time_minutes from recipe node's estimated_duration

So aerospace QC has the spec target on every WO without paper.

**5. Mask-WO start gate + masking_material field**
New x_fc_masking_material Selection (tape/plug/paint/silicone/wax/
mixed/other). Required to start a mask WO. Needed later when
stripping or replating because each material requires a different
removal process.

**View** (`mrp_workorder_views.xml`)
Process Details tab now branches by kind:
  wet  → Bath/Tank/Rack/Thickness/Dwell
  bake → Oven/Temp/Duration
  rack → Rack/Fixture
  mask → Masking Material
  inspect/other → informational alerts only
WO Kind shows as colour-coded badge in header.

**Backfill** (`scripts/fp_backfill.py`)
Idempotent script that catches up existing data:
  • chart_recorder_ref on every oven
  • rack_id on existing rack/de-rack WOs (91 backfilled)
  • bake_temp + bake_duration_hours on existing bake WOs (33)
  • masking_material on existing mask WOs (62)
  • thickness/dwell on existing plating WOs (38)
  • Cleared 7 legacy bath/tank from inspection WOs that had been
    misclassified by the OLD wet-keyword classifier.

**Per-step audit** (`scripts/fp_per_step_audit.py`)
Walks every WO of the most recent done MO and reports per-kind
which compliance fields are filled vs missing. Re-runnable to
catch regressions.

**Final state on freshly-run MO 00049:**
  • 0 CRITICAL gaps
  • 2 IMPORTANT gaps (dwell_time + rack_id on E-Nickel Plating —
    both inherited from recipe node data, not enforcement bugs)

Negative tests still passing (12 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:40:01 -04:00
15 changed files with 881 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.5',
'version': '19.0.1.0.6',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',

View File

@@ -0,0 +1,57 @@
-- Materialized view: pre-aggregated data for the OWL bank reconciliation widget.
-- Refreshed on cron (Task 25) and on suggestion writes.
-- Indexed on (company_id, journal_id, date) for fast UI queries.
-- NOTE: account_bank_statement_line does not store `date` directly in V19;
-- it is a related field through move_id -> account_move.date. We JOIN on
-- account_move to get it.
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_bank_line_mv AS
SELECT
bsl.id AS id,
bsl.company_id AS company_id,
bsl.journal_id AS journal_id,
am.date AS date,
bsl.amount AS amount,
bsl.payment_ref AS payment_ref,
bsl.currency_id AS currency_id,
bsl.partner_id AS partner_id,
bsl.create_date AS create_date,
-- Top suggestion (highest confidence pending one)
(SELECT s.id FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_suggestion_id,
(SELECT s.confidence FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_confidence,
CASE
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.85
THEN 'high'
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.60
THEN 'medium'
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') > 0
THEN 'low'
ELSE 'none'
END AS confidence_band,
-- Attachment count (assumes ir_attachment.res_model='account.bank.statement.line')
(SELECT COUNT(*) FROM ir_attachment att
WHERE att.res_model = 'account.bank.statement.line' AND att.res_id = bsl.id)
AS attachment_count,
-- Partner reconcile pattern hint
COALESCE((SELECT p.reconcile_count FROM fusion_reconcile_pattern p
WHERE p.partner_id = bsl.partner_id AND p.company_id = bsl.company_id LIMIT 1), 0)
AS partner_reconcile_count
FROM account_bank_statement_line bsl
JOIN account_move am ON am.id = bsl.move_id
WHERE bsl.is_reconciled IS NOT TRUE;
-- Indexes for the common UI queries: filter by company + journal, sort by date desc.
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_company_journal_date_idx
ON fusion_unreconciled_bank_line_mv (company_id, journal_id, date DESC);
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_partner_idx
ON fusion_unreconciled_bank_line_mv (partner_id) WHERE partner_id IS NOT NULL;
-- UNIQUE index required for CONCURRENTLY refresh
CREATE UNIQUE INDEX IF NOT EXISTS fusion_mv_unrec_id_idx
ON fusion_unreconciled_bank_line_mv (id);

View File

@@ -5,3 +5,4 @@ from . import fusion_bank_rec_widget
from . import account_bank_statement_line
from . import account_reconcile_model
from . import fusion_reconcile_engine
from . import fusion_unreconciled_bank_line_mv

View File

@@ -9,8 +9,12 @@ suggestions here, and the user (or batch-accept action) approves them
through the engine's accept_suggestion() method.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionReconcileSuggestion(models.Model):
_name = "fusion.reconcile.suggestion"
@@ -96,3 +100,38 @@ class FusionReconcileSuggestion(models.Model):
sug.confidence_band = 'low'
else:
sug.confidence_band = 'none'
# ------------------------------------------------------------------
# CRUD overrides — trigger MV refresh so the OWL widget sees fresh
# confidence bands / top suggestion ids without waiting for cron.
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
self._trigger_mv_refresh()
return records
def write(self, vals):
res = super().write(vals)
# Only refresh on changes that affect the MV's projected columns.
if 'state' in vals or 'confidence' in vals or 'rank' in vals:
self._trigger_mv_refresh()
return res
def _trigger_mv_refresh(self):
"""Best-effort MV refresh; never poison the originating transaction.
Uses concurrently=False because Postgres forbids
REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
and Odoo's per-request cursor is always in a transaction. The cron
job (Task 25) opens a dedicated autocommit cursor for CONCURRENTLY
refreshes when the MV grows large enough that a brief blocking
refresh becomes objectionable.
"""
try:
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
except Exception as e: # noqa: BLE001
_logger.warning(
"MV refresh after suggestion write failed: %s", e)

View File

@@ -0,0 +1,91 @@
"""Materialized view exposing pre-aggregated unreconciled-bank-line data.
The MV is created in the model's init() (called by Odoo on install/upgrade).
Refresh strategy:
- Cron (every 5 min) — see fusion_accounting_bank_rec/data/cron.xml (Task 25)
- Triggered refresh after suggestion writes (handled in fusion_reconcile_suggestion.py)
"""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionUnreconciledBankLineMV(models.Model):
_name = "fusion.unreconciled.bank.line.mv"
_description = "Materialized view of unreconciled bank lines for OWL widget"
_auto = False # we manage the table ourselves
_table = "fusion_unreconciled_bank_line_mv"
_order = "date desc, id desc"
# Fields mirror the columns in the SQL view; required so Odoo can read them.
company_id = fields.Many2one('res.company', readonly=True)
journal_id = fields.Many2one('account.journal', readonly=True)
date = fields.Date(readonly=True)
amount = fields.Float(readonly=True)
payment_ref = fields.Char(readonly=True)
currency_id = fields.Many2one('res.currency', readonly=True)
partner_id = fields.Many2one('res.partner', readonly=True)
create_date = fields.Datetime(readonly=True)
top_suggestion_id = fields.Many2one('fusion.reconcile.suggestion', readonly=True)
top_confidence = fields.Float(readonly=True)
confidence_band = fields.Selection([
('high', 'High'),
('medium', 'Medium'),
('low', 'Low'),
('none', 'None'),
], readonly=True)
attachment_count = fields.Integer(readonly=True)
partner_reconcile_count = fields.Integer(readonly=True)
def init(self):
"""Create the MV if missing.
Reads create_mv_unreconciled_bank_line.sql and executes it. Idempotent
because the SQL uses CREATE MATERIALIZED VIEW IF NOT EXISTS."""
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_unreconciled_bank_line.sql')
with open(sql_path, 'r') as f:
sql = f.read()
self.env.cr.execute(sql)
_logger.info(
"fusion_unreconciled_bank_line_mv: created/verified MV + indexes")
@api.model
def _refresh(self, *, concurrently=True):
"""Refresh the MV.
If ``concurrently=True`` (default), uses
REFRESH MATERIALIZED VIEW CONCURRENTLY (requires the unique index).
Falls back to a blocking refresh on the first refresh after creation
(when CONCURRENTLY is not yet allowed because the MV has never been
populated).
Flushes the ORM cache first so the materialization sees the latest
committed-to-DB values for fields like ``is_reconciled`` (computed,
stored — sometimes still buffered in the cache mid-request)."""
self.env.flush_all()
keyword = "CONCURRENTLY" if concurrently else ""
try:
self.env.cr.execute(
f"REFRESH MATERIALIZED VIEW {keyword} fusion_unreconciled_bank_line_mv"
)
_logger.debug(
"fusion_unreconciled_bank_line_mv refreshed (%s)",
'concurrent' if concurrently else 'blocking')
except Exception as e: # noqa: BLE001
# CONCURRENTLY fails on first refresh after creation if the MV is
# empty / has never been populated; fall back to non-concurrent.
if concurrently:
_logger.warning(
"Concurrent MV refresh failed (%s); falling back to "
"blocking refresh", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_unreconciled_bank_line_mv"
)
else:
raise

View File

@@ -6,3 +6,5 @@ access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_p
access_fusion_reconcile_suggestion_user,suggestion user,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_bank_rec_widget_user,bank rec widget user,model_fusion_bank_rec_widget,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1
access_fusion_unreconciled_bank_line_mv_user,unreconciled bank line mv user,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
access_fusion_unreconciled_bank_line_mv_admin,unreconciled bank line mv admin,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_admin,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_fusion_reconcile_suggestion_user suggestion user model_fusion_reconcile_suggestion fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
7 access_fusion_reconcile_suggestion_admin suggestion admin model_fusion_reconcile_suggestion fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
8 access_fusion_bank_rec_widget_user bank rec widget user model_fusion_bank_rec_widget fusion_accounting_core.group_fusion_accounting_user 1 1 1 1
9 access_fusion_unreconciled_bank_line_mv_user unreconciled bank line mv user model_fusion_unreconciled_bank_line_mv fusion_accounting_core.group_fusion_accounting_user 1 0 0 0
10 access_fusion_unreconciled_bank_line_mv_admin unreconciled bank line mv admin model_fusion_unreconciled_bank_line_mv fusion_accounting_core.group_fusion_accounting_admin 1 0 0 0

View File

@@ -13,3 +13,4 @@ from . import test_bank_rec_prompt
from . import test_bank_rec_adapter
from . import test_bank_rec_tools
from . import test_legacy_tools_refactor
from . import test_mv_unreconciled

View File

@@ -0,0 +1,82 @@
"""Smoke tests for the fusion_unreconciled_bank_line_mv materialized view.
Notes on transactional semantics:
- REFRESH MATERIALIZED VIEW (non-CONCURRENTLY) IS transactional and runs
inside the current transaction. Postgres always shows a transaction
its own (uncommitted) writes, so an INSERT followed by a REFRESH in
the same transaction picks up the new row — no `cr.commit()` needed.
- Odoo's TransactionCase forbids cr.commit() anyway (it would break the
per-test savepoint rollback). We rely on rollback to clean up both
the test fixtures and the MV-table mutations from the refresh.
- REFRESH MATERIALIZED VIEW CONCURRENTLY must run OUTSIDE a transaction
block; we always pass concurrently=False from tests. The production
cron path (Task 25) will open a dedicated autocommit cursor for the
concurrent refresh.
"""
from odoo.tests.common import TransactionCase, tagged
from . import _factories as f
@tagged('post_install', '-at_install')
class TestUnreconciledBankLineMV(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'MV Test Partner',
})
# Refresh once at the start so the MV reflects the current snapshot
# (including any rows inserted earlier in this savepoint chain).
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
def test_mv_exists_and_is_queryable(self):
# Smoke: the model can be searched without error.
rows = self.env['fusion.unreconciled.bank.line.mv'].search(
[], limit=10)
self.assertIsNotNone(rows)
def test_mv_includes_unreconciled_line(self):
bank_line = f.make_bank_line(
self.env, amount=999.99, partner=self.partner)
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
('id', '=', bank_line.id),
])
self.assertTrue(
mv_row,
"MV should contain freshly-inserted unreconciled line")
self.assertAlmostEqual(mv_row.amount, 999.99, places=2)
# No suggestion yet -> band 'none', confidence 0.
self.assertEqual(mv_row.confidence_band, 'none')
self.assertEqual(mv_row.attachment_count, 0)
def test_mv_excludes_reconciled_line(self):
bank_line, recv_lines = f.make_reconcileable_pair(
self.env, amount=100.00, partner=self.partner)
self.env['fusion.reconcile.engine'].reconcile_one(
bank_line, against_lines=recv_lines)
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
('id', '=', bank_line.id),
])
self.assertFalse(
mv_row, "Reconciled line should be excluded from MV")
def test_mv_confidence_band_high_for_high_conf_suggestion(self):
bank_line = f.make_bank_line(
self.env, amount=500.00, partner=self.partner)
f.make_suggestion(
self.env, statement_line=bank_line, confidence=0.92)
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_row = self.env['fusion.unreconciled.bank.line.mv'].search([
('id', '=', bank_line.id),
])
self.assertTrue(mv_row, "MV row should exist for suggestion line")
# 0.92 falls in the 'high' band per the SQL CASE (>= 0.85).
self.assertEqual(mv_row.confidence_band, 'high')
self.assertAlmostEqual(mv_row.top_confidence, 0.92, places=2)

View File

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

View File

@@ -325,6 +325,14 @@ class MrpProduction(models.Model):
for override in production.x_fc_override_ids:
override_map[override.node_id.id] = override.included
# Bind the source SO once per production so walk_node closure
# can read coating config / spec without an extra search per WO.
so = False
if production.origin:
so = self.env['sale.order'].search(
[('name', '=', production.origin)], limit=1,
) or False
# Walk tree and collect operation WO values
wo_vals_list = []
wo_steps = {} # {sequence: instruction text} — posted to WO chatter after create
@@ -392,6 +400,41 @@ class MrpProduction(models.Model):
'duration_expected': node.estimated_duration or 0,
'sequence': seq_counter[0],
}
# Recipe estimated_duration also fills the WO's
# x_fc_dwell_time_minutes — operators see the recipe-
# spec'd dwell next to the actual time logged.
if node.estimated_duration:
vals['x_fc_dwell_time_minutes'] = node.estimated_duration
# Pull thickness target from the coating config when
# this is a plating WO (matched by node name keyword
# OR the linked process_type's family). Aerospace
# customers expect target thickness on every WO so
# QC can accept/reject against spec without paper.
coating = (
production.x_fc_coating_config_id
if 'x_fc_coating_config_id' in production._fields
else False
)
if not coating and so:
coating = (
so.x_fc_coating_config_id
if 'x_fc_coating_config_id' in so._fields
else False
)
name_l = (node.name or '').lower()
is_plating_node = (
'plat' in name_l or 'nickel' in name_l
or 'chrome' in name_l or 'anodiz' in name_l
)
if coating and is_plating_node:
# thickness_max is the upper spec limit — that's
# what we target. thickness_min is the floor.
if coating.thickness_max:
vals['x_fc_thickness_target'] = coating.thickness_max
if coating.thickness_uom:
vals['x_fc_thickness_uom'] = coating.thickness_uom
# Inherit the operation's shop role (if the bridge
# module is installed) so WOs can auto-route to the
# right worker.
@@ -420,8 +463,13 @@ class MrpProduction(models.Model):
# Bulk create work orders
if wo_vals_list:
created_wos = WorkOrder.create(wo_vals_list)
# Post step instructions to each WO's chatter where present
for wo in created_wos:
# Auto-fill default equipment when there's only one
# option per facility (bath/tank/oven). Saves the
# planner a click on single-line shops.
if hasattr(wo, '_fp_autofill_default_equipment'):
wo._fp_autofill_default_equipment()
# Post step instructions to each WO's chatter where present
steps_txt = wo_steps.get(wo.sequence)
if steps_txt:
wo.message_post(

View File

@@ -28,11 +28,29 @@ class MrpWorkorder(models.Model):
# ------------------------------------------------------------------
x_fc_requires_bath = fields.Boolean(
string='Requires Bath/Tank',
compute='_compute_requires_bath',
compute='_compute_wo_kind',
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_requires_oven = fields.Boolean(
string='Requires Oven',
compute='_compute_wo_kind',
store=False,
help='True when this WO is a bake/cure step. Surfaced to the '
'form view so the oven field renders as required.',
)
x_fc_wo_kind = fields.Selection(
[('wet', 'Wet / Bath'),
('bake', 'Oven / Bake'),
('mask', 'Mask / De-mask'),
('rack', 'Rack / De-rack'),
('inspect', 'Inspection / QC'),
('other', 'Other')],
string='WO Kind',
compute='_compute_wo_kind',
store=False,
)
x_fc_bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', tracking=True,
)
@@ -45,6 +63,35 @@ class MrpWorkorder(models.Model):
domain="[('state', '!=', 'retired')]",
tracking=True,
)
x_fc_oven_id = fields.Many2one(
'fusion.plating.bake.oven', string='Oven',
domain="[('facility_id', '=', x_fc_facility_id)]",
help='The specific oven this bake / cure WO ran in. Required '
'for bake WOs — multiple ovens means we need to pin '
'which one for the chart-recorder trail.',
)
x_fc_bake_temp = fields.Float(
string='Bake Temp (°F)', digits=(5, 1),
help='Setpoint temperature recorded for this bake WO.',
)
x_fc_bake_duration_hours = fields.Float(
string='Bake Duration (h)', digits=(5, 2),
help='Total bake time at temperature.',
)
x_fc_masking_material = fields.Selection(
[('tape', 'Tape'),
('plug', 'Plug'),
('paint', 'Paint / Lacquer'),
('silicone', 'Silicone'),
('wax', 'Wax'),
('mixed', 'Mixed (multiple materials)'),
('other', 'Other (see notes)')],
string='Masking Material',
help='Which material was used to mask off the parts. Required '
'on mask / de-mask WOs — needed later when stripping or '
'replating because each material requires a different '
'removal process.',
)
x_fc_thickness_target = fields.Float(string='Target Thickness')
x_fc_thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', '\u00b5m')],
@@ -547,10 +594,97 @@ class MrpWorkorder(models.Model):
'zincate', 'alkalin', 'acid', 'electroless',
)
@api.depends('x_fc_bath_id', 'name', 'workcenter_id')
def _compute_requires_bath(self):
@api.depends('x_fc_bath_id', 'x_fc_oven_id', 'name', 'workcenter_id')
def _compute_wo_kind(self):
for wo in self:
wo.x_fc_requires_bath = wo._fp_is_wet_process()
kind = wo._fp_classify_kind()
wo.x_fc_wo_kind = kind
wo.x_fc_requires_bath = kind == 'wet'
wo.x_fc_requires_oven = kind == 'bake'
@api.onchange('workcenter_id', 'x_fc_facility_id', 'x_fc_bath_id')
def _onchange_autofill_equipment(self):
"""If the facility has exactly one option for the equipment this
WO needs, pre-pick it so the planner doesn't have to."""
for wo in self:
wo._fp_autofill_default_equipment()
def _fp_autofill_default_equipment(self):
"""Pin bath / tank / oven to the only-option-available default.
Doesn't overwrite an already-set value.
"""
self.ensure_one()
kind = self._fp_classify_kind()
Bath = self.env.get('fusion.plating.bath')
Tank = self.env.get('fusion.plating.tank')
Oven = self.env.get('fusion.plating.bake.oven')
facility = self.x_fc_facility_id
if kind == 'wet' and not self.x_fc_bath_id and Bath is not None:
d = [('active', '=', True)]
if facility and 'facility_id' in Bath._fields:
d.append(('facility_id', '=', facility.id))
baths = Bath.search(d, limit=2)
if len(baths) == 1:
self.x_fc_bath_id = baths.id
if kind == 'wet' and self.x_fc_bath_id and not self.x_fc_tank_id and Tank is not None:
d = [('active', '=', True)]
if 'bath_id' in Tank._fields:
d.append(('bath_id', '=', self.x_fc_bath_id.id))
tanks = Tank.search(d, limit=2)
if len(tanks) == 1:
self.x_fc_tank_id = tanks.id
if kind == 'bake' and not self.x_fc_oven_id and Oven is not None:
d = [('active', '=', True)]
if facility and 'facility_id' in Oven._fields:
d.append(('facility_id', '=', facility.id))
ovens = Oven.search(d, limit=2)
if len(ovens) == 1:
self.x_fc_oven_id = ovens.id
# Keyword fallbacks per kind (lowercase name match).
BAKE_KEYWORDS = ('bake', 'oven', 'cure', 'heat treat')
MASK_KEYWORDS = ('mask', 'de-mask', 'demask', 'tape')
RACK_KEYWORDS = ('rack', 'de-rack', 'derack', 'fixture')
INSPECT_KEYWORDS = ('inspect', 'qa', 'qc', 'fai', 'final check')
def _fp_classify_kind(self):
"""Bucket this WO into wet/bake/mask/rack/inspect/other.
Priority order (top wins):
1. Explicit equipment links (bath_id / oven_id) — definitive.
2. Specific-process keywords (inspect/mask/rack/bake) beat
the broader wet keywords. Otherwise "Post-plate Inspection"
matches "plat" → wet, which is wrong.
3. Workcenter wet process family — definitive.
4. Wet name keyword fallback — broad (catches plat/etch/rinse...).
"""
self.ensure_one()
if self.x_fc_bath_id:
return 'wet'
if self.x_fc_oven_id:
return 'bake'
name = (self.name or '').lower()
if any(k in name for k in self.INSPECT_KEYWORDS):
return 'inspect'
if any(k in name for k in self.MASK_KEYWORDS):
return 'mask'
# Bake before Rack so "Oven bake (Post de-rack)" → bake (the
# operation is bake; "Post de-rack" only describes the timing).
if any(k in name for k in self.BAKE_KEYWORDS):
return 'bake'
if any(k in name for k in self.RACK_KEYWORDS):
return 'rack'
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 'wet'
if any(k in name for k in self.WET_NAME_KEYWORDS):
return 'wet'
return 'other'
def _fp_is_wet_process(self):
"""Best-effort check: does this WO involve a chemistry bath?
@@ -576,24 +710,33 @@ class MrpWorkorder(models.Model):
"""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).
Per-kind rules:
• Every WO needs an assigned operator (x_fc_assigned_user_id).
• Wet: bath + tank (chemistry traceability)
• Bake: oven (chart-recorder trail)
Rack: rack/fixture (per-rack life tracking)
• Mask: masking material (needed later when stripping)
"""
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():
kind = wo._fp_classify_kind()
if kind == 'wet':
if not wo.x_fc_bath_id:
missing.append(_('Bath'))
if not wo.x_fc_tank_id:
missing.append(_('Tank'))
elif kind == 'bake':
if not wo.x_fc_oven_id:
missing.append(_('Oven'))
elif kind == 'rack':
if not wo.x_fc_rack_id:
missing.append(_('Rack / Fixture'))
elif kind == 'mask':
if not wo.x_fc_masking_material:
missing.append(_('Masking Material'))
if missing:
raise UserError(_(
'Cannot start work order "%(wo)s" — please fill these '
@@ -652,6 +795,42 @@ class MrpWorkorder(models.Model):
'Request certification from your supervisor before starting this WO.'
) % (employee.name, process_type.name))
def _fp_check_required_fields_before_finish(self):
"""Block button_finish on bake WOs without the actual data
Nadcap audits demand: setpoint temp, actual duration, and a
chart-recorder reference on the oven (so the printed chart
for this run can be retrieved).
Run-time data (temp + duration) belongs at FINISH because
you don't know it until the bake is done. Chart-recorder ref
is on the oven config — checked here as a defensive backstop.
"""
from odoo.exceptions import UserError
for wo in self:
if wo._fp_classify_kind() != 'bake':
continue
missing = []
if not wo.x_fc_bake_temp:
missing.append(_('Bake Temp (°F)'))
if not wo.x_fc_bake_duration_hours:
missing.append(_('Bake Duration (h)'))
if wo.x_fc_oven_id and not wo.x_fc_oven_id.chart_recorder_ref:
missing.append(_(
'Chart Recorder Ref on oven "%s" '
'(set on the oven record, not the WO)'
) % wo.x_fc_oven_id.name)
if missing:
raise UserError(_(
'Cannot finish bake work order "%(wo)s" — Nadcap / '
'AS9100 require these fields before close:\n%(fields)s\n\n'
'On the iPad: tap the WO → Process Details → '
'fill in Bake Temp + Duration. Chart Recorder Ref '
'is configured on the oven record once.'
) % {
'wo': wo.display_name or wo.name,
'fields': '\n'.join(missing),
})
# ------------------------------------------------------------------
# T1.1 — Bake window auto-create on plating WO finish
# T1.3 — Rack MTO increment when a rack was used
@@ -663,6 +842,7 @@ class MrpWorkorder(models.Model):
the proficiency tracker so workers earn credit toward auto-
promotion (see fp.operator.proficiency).
"""
self._fp_check_required_fields_before_finish()
res = super().button_finish()
now = fields.Datetime.now()
uid = self.env.user.id

View File

@@ -96,7 +96,12 @@
required="1"
options="{'no_create': True}"/>
<field name="x_fc_work_role_id" readonly="1"/>
<field name="x_fc_wo_kind" widget="badge" readonly="1"
decoration-info="x_fc_wo_kind == 'wet'"
decoration-warning="x_fc_wo_kind == 'bake'"
decoration-muted="x_fc_wo_kind in ('mask', 'rack', 'inspect', 'other')"/>
<field name="x_fc_requires_bath" invisible="1"/>
<field name="x_fc_requires_oven" invisible="1"/>
</xpath>
<!-- ============================================================
@@ -162,12 +167,18 @@
</group>
</xpath>
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) -->
<!-- 5b. Process Details tab — content adapts to WO kind so
operators see only the equipment fields that matter. -->
<xpath expr="//notebook/page[@name='time_tracking']" position="after">
<page string="Plating Details" name="plating_details">
<page string="Process Details" name="plating_details">
<group>
<group string="Bath &amp; Tank">
<group string="Where">
<field name="x_fc_facility_id"/>
</group>
</group>
<!-- Wet / bath WOs -->
<group invisible="x_fc_wo_kind != 'wet'">
<group string="Bath &amp; Tank">
<field name="x_fc_bath_id"
required="x_fc_requires_bath"/>
<field name="x_fc_tank_id"
@@ -181,6 +192,44 @@
<field name="x_fc_dwell_time_minutes"/>
</group>
</group>
<!-- Bake / cure WOs -->
<group invisible="x_fc_wo_kind != 'bake'">
<group string="Oven">
<field name="x_fc_oven_id"
required="x_fc_requires_oven"/>
</group>
<group string="Bake Parameters (required at finish)">
<field name="x_fc_bake_temp"/>
<field name="x_fc_bake_duration_hours"/>
</group>
</group>
<!-- Rack / de-rack WOs -->
<group invisible="x_fc_wo_kind != 'rack'">
<group string="Rack">
<field name="x_fc_rack_id" required="1"/>
<field name="x_fc_rack_ref"/>
</group>
</group>
<!-- Mask / De-mask WOs -->
<group invisible="x_fc_wo_kind != 'mask'">
<group string="Masking">
<field name="x_fc_masking_material" required="1"/>
</group>
</group>
<!-- Inspection -->
<group invisible="x_fc_wo_kind != 'inspect'">
<div class="alert alert-info" role="alert">
Inspection — record Fischerscope readings via
the Tablet Station. Cal-std + n measurements
per part. Readings auto-link to the CoC.
</div>
</group>
<group invisible="x_fc_wo_kind != 'other'">
<div class="alert alert-light text-muted" role="alert">
Generic operation — equipment is identified
by the work centre.
</div>
</group>
</page>
</xpath>

View File

@@ -0,0 +1,100 @@
# Backfill compliance data on existing records so the per-step audit
# verifies the new gates against real data, not a fresh seed.
env = env # noqa
from collections import Counter
# 1. Set chart_recorder_ref on every oven that doesn't have one
ovens = env['fusion.plating.bake.oven'].search([])
n_ov = 0
for ov in ovens:
if not ov.chart_recorder_ref:
ov.sudo().chart_recorder_ref = f'CR-{ov.code or ov.id}-2026'
n_ov += 1
print(f'1. ovens chart_recorder_ref backfilled: {n_ov}/{len(ovens)}')
# 2. Backfill rack_id on existing rack/de-rack WOs
WO = env['mrp.workorder']
all_wos = WO.search([])
test_rack = env['fusion.plating.rack'].search([], limit=1)
if not test_rack:
f = env['fusion.plating.facility'].search([], limit=1)
test_rack = env['fusion.plating.rack'].sudo().create({
'name': 'Standard Rack 1',
'code': 'RACK-1',
'facility_id': f.id if f else False,
})
n_rk = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind'):
if wo._fp_classify_kind() == 'rack' and not wo.x_fc_rack_id:
wo.sudo().x_fc_rack_id = test_rack.id
n_rk += 1
print(f'2. rack WOs rack_id backfilled: {n_rk}')
# 3. Backfill bake_temp + bake_duration_hours on existing bake WOs
n_bk = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'bake':
updates = {}
if not wo.x_fc_bake_temp:
updates['x_fc_bake_temp'] = 365.0
if not wo.x_fc_bake_duration_hours:
updates['x_fc_bake_duration_hours'] = 4.0
if updates:
wo.sudo().write(updates)
n_bk += 1
print(f'3. bake WOs temp+duration backfilled: {n_bk}')
# 4. Backfill masking_material on existing mask WOs
n_mk = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'mask':
if not wo.x_fc_masking_material:
wo.sudo().x_fc_masking_material = 'tape'
n_mk += 1
print(f'4. mask WOs masking_material backfilled: {n_mk}')
# 5. Backfill thickness_target + dwell_time on existing wet plating WOs
n_th = 0
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'wet':
# Only fill if name suggests a plating step (not pre-treat/rinse)
name_l = (wo.name or '').lower()
if 'plat' in name_l or 'nickel' in name_l:
updates = {}
if not wo.x_fc_thickness_target:
updates['x_fc_thickness_target'] = 0.0005 # 0.5 mils
if not wo.x_fc_dwell_time_minutes:
updates['x_fc_dwell_time_minutes'] = 60.0
if updates:
wo.sudo().write(updates)
n_th += 1
print(f'5. plating WOs thickness/dwell backfilled: {n_th}')
# 6. Clean up OLD inspection WOs that have bath/tank wrongly set
# (legacy bug — earlier simulator pinned bath to "Post-plate Inspection"
# because the old classifier matched 'plat' keyword. Fixed now.)
n_cl = 0
for wo in all_wos:
name_l = (wo.name or '').lower()
if 'inspect' in name_l and (wo.x_fc_bath_id or wo.x_fc_tank_id):
wo.sudo().write({'x_fc_bath_id': False, 'x_fc_tank_id': False})
n_cl += 1
print(f'6. legacy bath/tank cleared from inspection WOs: {n_cl}')
# Verify classifier fix — re-classify all WOs and report
kinds = Counter()
mis_pi = []
for wo in all_wos:
if hasattr(wo, '_fp_classify_kind'):
k = wo._fp_classify_kind()
kinds[k] += 1
if 'inspect' in (wo.name or '').lower() and k != 'inspect':
mis_pi.append((wo.id, wo.name, k))
print(f'\\nclassifier results across {len(all_wos)} WOs: {dict(kinds)}')
print(f'inspection WOs misclassified: {len(mis_pi)}')
for tup in mis_pi[:5]:
print(f' ✗ WO {tup[0]} "{tup[1]}"{tup[2]} (should be inspect)')
env.cr.commit()
print('\\nBackfill committed.')

View File

@@ -238,6 +238,18 @@ 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)
test_oven = env['fusion.plating.bake.oven'].search([], limit=1)
if not test_oven:
f0 = env['fusion.plating.facility'].search([], limit=1)
test_oven = env['fusion.plating.bake.oven'].sudo().create({
'name': 'Bake Oven 1', 'code': 'OVEN-1',
'facility_id': f0.id if f0 else False,
'target_temp_min': 350.0, 'target_temp_max': 380.0,
'chart_recorder_ref': 'CR-OVEN1-2026',
})
# Make sure the oven has a chart_recorder_ref (new gate requirement)
if test_oven and not test_oven.chart_recorder_ref:
test_oven.sudo().chart_recorder_ref = f'CR-{test_oven.code}-2026'
# Issue operator certifications for the bath's process type so the cert
# gate doesn't block legitimate operators (in real life the manager
@@ -279,23 +291,31 @@ for wo in mo.workorder_ids:
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:
# Pin per-kind equipment using the new classifier (post inspect/mask/
# rack/bake priority fix), so Post-plate Inspection no longer gets
# bath assigned just because its name contains "plat".
kind = wo._fp_classify_kind() if hasattr(wo, '_fp_classify_kind') else 'other'
extras = f' [{kind}]'
if kind == '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)
extras = f' [WET — bath={test_bath.name}, tank={test_tank.name}]'
elif kind == 'bake' and test_oven:
wo.sudo().x_fc_oven_id = test_oven.id
extras = f' [BAKE — oven={test_oven.name}]'
elif kind == 'rack':
rack = env['fusion.plating.rack'].search([], limit=1)
if rack:
wo.sudo().x_fc_rack_id = rack.id
extras = f' [RACK — fixture={rack.name}]'
elif kind == 'mask':
wo.sudo().x_fc_masking_material = 'tape'
extras = ' [MASK — material=tape]'
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)
@@ -630,6 +650,14 @@ for wo, op_user, op_key in assignments:
n_readings = Reading.search_count([('production_id', '=', mo.id)])
show(' thickness readings', f'{n_readings} logged for {mo.name}')
# Bake operator records actuals BEFORE pressing finish (new gate)
if hasattr(wo, '_fp_classify_kind') and wo._fp_classify_kind() == 'bake':
wo.sudo().write({
'x_fc_bake_temp': 365.0,
'x_fc_bake_duration_hours': 4.0,
})
show(' bake actuals', '365°F × 4h recorded')
step(actor, 'Taps FINISH')
try:
if wo_op.state == 'progress':

View File

@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""Per-step compliance audit — walks every WO of the most recent MO
and reports which compliance data points are captured vs missing,
broken down by WO kind.
Output is the diagnostic the user asked for: "check and report if
all the data needed for compliance is being enforced for every step."
"""
env = env # noqa
def banner(t):
print(f'\n{"="*78}\n {t}\n{"="*78}')
# Per-kind required data points. Each tuple is (field_or_check, severity, why)
KIND_RULES = {
'wet': [
('x_fc_assigned_user_id', 'CRITICAL', 'Operator (audit trail)'),
('x_fc_bath_id', 'CRITICAL', 'Which bath ran (chemistry traceability)'),
('x_fc_tank_id', 'CRITICAL', 'Which physical tank'),
('duration', 'CRITICAL', 'Actual run time'),
('x_fc_thickness_target', 'IMPORTANT','Spec target (QC accept criterion)'),
('x_fc_dwell_time_minutes','IMPORTANT','Recipe dwell vs actual'),
('x_fc_rack_id', 'IMPORTANT','Which rack/fixture used'),
('bath_log_during_window', 'IMPORTANT','Chemistry reading recorded during WO time window'),
('x_fc_started_by_user_id','IMPORTANT','Who actually clicked Start'),
('x_fc_finished_by_user_id','IMPORTANT','Who clicked Finish'),
],
'bake': [
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
('x_fc_oven_id', 'CRITICAL', 'Which oven'),
('x_fc_bake_temp', 'CRITICAL', 'Setpoint temp (Nadcap req)'),
('x_fc_bake_duration_hours','CRITICAL','Actual bake duration'),
('chart_recorder_ref', 'CRITICAL', 'Chart-recorder ref on the OVEN — auditor demands the chart for the run'),
('duration', 'CRITICAL', 'WO timer duration'),
('x_fc_started_by_user_id','IMPORTANT','Who started'),
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
],
'mask': [
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
('duration', 'CRITICAL', 'Run time'),
('masking_material', 'IMPORTANT','Which material — needed for stripping later'),
('x_fc_started_by_user_id','IMPORTANT','Who started'),
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
],
'rack': [
('x_fc_assigned_user_id', 'CRITICAL', 'Operator'),
('x_fc_rack_id', 'CRITICAL', 'Which rack/fixture (per-rack MTO life tracking)'),
('duration', 'CRITICAL', 'Run time'),
('x_fc_started_by_user_id','IMPORTANT','Who started'),
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
],
'inspect': [
('x_fc_assigned_user_id', 'CRITICAL', 'Inspector'),
('duration', 'CRITICAL', 'Run time'),
('thickness_readings', 'CRITICAL', 'Fischerscope readings logged for this MO'),
('cal_std_on_readings', 'CRITICAL', 'Every reading has calibration std (Nadcap)'),
('gauge_serial', 'IMPORTANT','Which gauge (links to calibration record)'),
('x_fc_started_by_user_id','IMPORTANT','Who started'),
('x_fc_finished_by_user_id','IMPORTANT','Who finished'),
],
'other': [
('x_fc_assigned_user_id', 'IMPORTANT','Operator'),
('duration', 'IMPORTANT','Run time'),
],
}
def check_field(wo, field):
"""Return (value, is_filled, label_for_display)."""
if field == 'bath_log_during_window':
# Look for any bath log on this WO's bath, between start+finish
if not wo.x_fc_bath_id or not wo.x_fc_started_at or not wo.x_fc_finished_at:
return ('', False, 'no log searchable')
Log = env['fusion.plating.bath.log']
n = Log.search_count([
('bath_id', '=', wo.x_fc_bath_id.id),
('log_date', '>=', wo.x_fc_started_at),
('log_date', '<=', wo.x_fc_finished_at),
])
return (f'{n} log(s)', n > 0, '')
if field == 'chart_recorder_ref':
ref = wo.x_fc_oven_id.chart_recorder_ref if wo.x_fc_oven_id else False
return (ref or '', bool(ref), 'on oven')
if field == 'masking_material':
val = wo.x_fc_masking_material if 'x_fc_masking_material' in wo._fields else False
if not val:
return ('', False, '')
label = dict(wo._fields['x_fc_masking_material'].selection).get(val, val)
return (label, True, '')
if field == 'thickness_readings':
n = env['fp.thickness.reading'].search_count([
('production_id', '=', wo.production_id.id),
])
return (f'{n} reading(s)', n > 0, '')
if field == 'cal_std_on_readings':
rs = env['fp.thickness.reading'].search([
('production_id', '=', wo.production_id.id),
])
if not rs:
return ('', False, 'no readings')
n_with = sum(1 for r in rs if r.calibration_std_ref)
return (f'{n_with}/{len(rs)} have cal std', n_with == len(rs), '')
if field == 'gauge_serial':
# Pull from any reading on this MO
r = env['fp.thickness.reading'].search(
[('production_id', '=', wo.production_id.id)], limit=1)
if not r:
return ('', False, 'no readings')
return (r.equipment_model or '', bool(r.equipment_model), 'from reading.equipment_model')
# Direct field on WO
val = getattr(wo, field, False) if field in wo._fields else None
if val is None:
return ('(field n/a)', False, '')
if hasattr(val, '_name'):
label = val.display_name if val else ''
return (label, bool(val.ids), '')
if isinstance(val, (int, float)):
return (str(val), val > 0, '')
return (str(val), bool(val), '')
# Pull the most recent MO with all its WOs (sudo to bypass any
# multi-company / record-rule filter so we always pick the truly latest).
mo = env['mrp.production'].sudo().search(
[('state', '=', 'done')], order='id desc', limit=1)
print(f'\nAuditing MO: {mo.name} (state={mo.state}, recipe={mo.x_fc_recipe_id.name})')
print(f'{len(mo.workorder_ids)} work orders\n')
GAP_TOTALS = {'CRITICAL': 0, 'IMPORTANT': 0}
PER_KIND = {}
for wo in mo.workorder_ids.sorted('sequence'):
kind = wo._fp_classify_kind() if hasattr(wo, '_fp_classify_kind') else 'other'
rules = KIND_RULES.get(kind, KIND_RULES['other'])
banner(f'WO {wo.id}: "{wo.name}" kind={kind}')
show_gaps = []
show_ok = []
for field, severity, why in rules:
val_str, is_filled, note = check_field(wo, field)
sym = '' if is_filled else ''
line = f' {sym} {severity:<9} {field:<30}{val_str:<35} {why}'
if note:
line += f' [{note}]'
if is_filled:
show_ok.append(line)
else:
show_gaps.append(line)
if severity in GAP_TOTALS:
GAP_TOTALS[severity] += 1
PER_KIND.setdefault(kind, []).append(field)
for ln in show_ok:
print(ln)
if show_gaps:
print(' ── GAPS ──')
for ln in show_gaps:
print(ln)
# =====================================================================
banner('SUMMARY — gaps per WO kind across this MO')
# =====================================================================
for kind, gaps in PER_KIND.items():
from collections import Counter
c = Counter(gaps)
print(f'\n {kind} WOs ({sum(1 for w in mo.workorder_ids if (w._fp_classify_kind() if hasattr(w,"_fp_classify_kind") else "other") == kind)} of them):')
for field, n in c.most_common():
print(f' × {field:<30} missing in {n} WO(s)')
print(f'\n Totals: {GAP_TOTALS["CRITICAL"]} CRITICAL gaps, {GAP_TOTALS["IMPORTANT"]} IMPORTANT gaps')
print('\n Note: "missing" doesn\'t always mean "broken" — some fields')
print(' are optional today but should be required for stricter')
print(' AS9100 / Nadcap compliance. See the per-kind list to')
print(' decide which are real bugs vs roadmap items.')