feat(plating): close 6 compliance gaps from required-fields audit

Following the workforce-E2E + required-fields audit, ship the first 6
high-priority gates so critical workflow + compliance fields can no
longer be left empty by accident.

**1. Invoice payment terms (account.move)**
- create() now auto-inherits `invoice_payment_term_id` from
  partner.property_payment_term_id when missing
- action_post() raises UserError if still missing — accountant must
  pick one before posting (prevents silent "immediate" due-date)

**2. MO facility (mrp.production)**
- action_confirm() auto-derives `x_fc_facility_id` if unset, in order:
  SO override → res.company.x_fc_default_facility_id → first active
  facility — then HARD GATES: raises UserError if still empty.
  Without facility every downstream record (WO, batch, bath log,
  cert) is missing the "where" half of the audit trail.

**3. WO facility (mrp.workorder)**
- Switched `x_fc_facility_id` from related (workcenter only) to a
  proper compute that falls back to production_id.x_fc_facility_id.
  Stub workcenters auto-created from process node names usually have
  no facility — the MO always does (from #2 above).

**4. Thickness reading calibration_std (fp.thickness.reading)**
- `calibration_std_ref` is now `required=True` with sensible default
  ("NiP/Al STD SET SN 100174568"). Nadcap mandates which calibration
  standard the gauge was checked against — without it the cert
  data has no chain back to a metrology record.

**5. Delivery POD gate (fusion.plating.delivery)**
- action_mark_delivered() raises UserError if no `pod_id`. Driver
  must capture POD on the iPad (recipient signature + photos +
  notes) BEFORE marking delivered. Without POD there's no signed
  receipt to back the invoice or defend a delivery dispute.

**6. Certificate spec_reference gate (fp.certificate)**
- action_issue() raises UserError if no `spec_reference`. The cert
  ATTESTS to a spec — leaving it blank produces a piece of paper
  that AS9100 / Nadcap auditors will (rightfully) reject.

**Simulator updated**: scripts/fp_e2e_workforce.py
- Sets net-30 on the test customer + ensures a default facility
- New PHASE 4c: 5 negative tests (one per new gate), each wrapped
  in a SAVEPOINT so SQL constraint violations don't abort the txn
- Driver now creates POD on iPad BEFORE marking delivered

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

All 7 negative tests now pass:
  ✓ Test 1: WO start without operator → blocked
  ✓ Test 2: WO start on wet WO without bath/tank → blocked
  ✓ Test 3: MO confirm without facility → blocked
  ✓ Test 4: Cert issue without spec_reference → blocked
  ✓ Test 5: Delivery delivered without POD → blocked
  ✓ Test 6: Invoice post without payment terms → blocked
  ✓ Test 7: Thickness reading without cal std → blocked (DB NOT NULL)

Audit script (scripts/fp_required_fields_audit.py) committed too —
it's the diagnostic that surfaced these gaps and can be re-run to
catch new ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 10:06:52 -04:00
parent 4161f04b0f
commit db8b79d22e
12 changed files with 612 additions and 9 deletions

View File

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

View File

@@ -469,6 +469,40 @@ class MrpProduction(models.Model):
# Auto-assign recipe BEFORE super() so work-order generation sees it
self._auto_assign_recipe_from_so()
# Auto-derive facility (where the job runs) so x_fc_facility_id is
# never empty downstream — it's compliance-critical (AS9100 §7.1.4
# "infrastructure"). Order: explicit value > SO override >
# company default > first active facility.
for mo in self:
if mo.x_fc_facility_id:
continue
facility = False
if mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if so and 'x_fc_facility_id' in so._fields:
facility = so.x_fc_facility_id
if not facility:
facility = mo.company_id.x_fc_default_facility_id
if not facility:
facility = self.env['fusion.plating.facility'].search(
[('active', '=', True)], limit=1,
)
if facility:
mo.x_fc_facility_id = facility.id
# Hard gate: MO can't be confirmed without a facility — without
# this, every downstream record (WO, batch, bath log, cert) is
# missing the "where" half of "what was made where by whom".
for mo in self:
if not mo.x_fc_facility_id:
raise UserError(_(
'Cannot confirm MO "%s" — no plating facility set.\n\n'
'Set the facility on the MO, or configure a default '
'in Settings → Companies → Fusion Plating Defaults.'
) % (mo.name or mo.display_name))
res = super().action_confirm()
PortalJob = self.env['fusion.plating.portal.job']
for mo in self:

View File

@@ -51,10 +51,24 @@ class MrpWorkorder(models.Model):
string='Thickness Unit', default='mils',
)
x_fc_dwell_time_minutes = fields.Float(string='Dwell Time (min)')
# Falls back to the MO's facility when the workcenter has none —
# most stub workcenters auto-created from process node names don't
# have facility_id, but the MO always does (enforced at confirm).
x_fc_facility_id = fields.Many2one(
'fusion.plating.facility', string='Facility',
related='workcenter_id.x_fc_facility_id', store=True, readonly=True,
compute='_compute_facility_id', store=True, readonly=False,
help='Plating facility where this WO runs. Falls back to the '
'MO\'s facility when the workcenter has none.',
)
@api.depends('workcenter_id.x_fc_facility_id', 'production_id.x_fc_facility_id')
def _compute_facility_id(self):
for wo in self:
wo.x_fc_facility_id = (
wo.workcenter_id.x_fc_facility_id
or wo.production_id.x_fc_facility_id
or wo.x_fc_facility_id
)
x_fc_workcenter_cost_hour = fields.Float(
string='Station Rate ($/hr)',
related='workcenter_id.costs_hour', readonly=True,

View File

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

View File

@@ -267,6 +267,16 @@ class FpCertificate(models.Model):
for rec in self:
if rec.state != 'draft':
raise UserError(_('Only draft certificates can be issued.'))
# Spec reference is what the cert ATTESTS — without it the
# cert is just a piece of paper. AS9100 / Nadcap require
# naming the spec the work was performed to.
if not rec.spec_reference:
raise UserError(_(
'Cannot issue certificate "%(name)s" — no Spec '
'Reference set.\n\nFill the Spec Reference field '
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
'states which standard the work meets.'
) % {'name': rec.name or rec.display_name})
rec.state = 'issued'
rec.message_post(body=_('Certificate issued.'))

View File

@@ -45,7 +45,13 @@ class FpThicknessReading(models.Model):
string='Product Ref', help='e.g. "2805031 / NiP/Al-alloys 2805030"',
)
calibration_std_ref = fields.Char(
string='Calibration Std', help='e.g. "NiP/Al STD SET SN 100174568"',
string='Calibration Std',
required=True,
default='NiP/Al STD SET SN 100174568',
help='Nadcap mandatory: which calibration standard the gauge '
'was checked against. Defaults to the shop\'s primary '
'standard but should be overridden if a different std '
'was used for this reading.',
)
microscope_image_id = fields.Many2one(
'ir.attachment', string='Microscope Image',

View File

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

View File

@@ -3,15 +3,38 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import models, _
from odoo import api, models, _
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
@api.model_create_multi
def create(self, vals_list):
"""Auto-inherit payment terms from the customer when missing.
Customers usually have a default `property_payment_term_id`
(Net-30, Net-60, COD…). When an invoice is created without
terms, the due date silently defaults to "immediate" — wrong
for almost every B2B customer. Pull the partner's terms in
before super so the invoice is born with the right schedule.
"""
Partner = self.env['res.partner']
for vals in vals_list:
if vals.get('move_type') in ('out_invoice', 'out_refund'):
if not vals.get('invoice_payment_term_id') and vals.get('partner_id'):
partner = Partner.browse(vals['partner_id'])
if partner.property_payment_term_id:
vals['invoice_payment_term_id'] = partner.property_payment_term_id.id
return super().create(vals_list)
def action_post(self):
"""Check account hold before posting invoices."""
"""Block post when:
• customer is on account hold (existing rule), or
• the invoice has no payment term (auto-fill missed it AND
partner had no default — accountant must pick one).
"""
for move in self:
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
if move.partner_id.x_fc_account_hold:
@@ -25,4 +48,11 @@ class AccountMove(models.Model):
'Contact a manager to override.'
) % (move.partner_id.name,
move.partner_id.x_fc_account_hold_reason or 'No reason specified'))
if not move.invoice_payment_term_id:
raise UserError(_(
'Cannot post invoice "%s" — no payment terms set.\n\n'
'Pick payment terms (Net-30, COD, etc.) on the invoice, '
'or set a default on the customer "%s" so future '
'invoices inherit it automatically.'
) % (move.name or move.display_name, move.partner_id.name))
return super().action_post()

View File

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

View File

@@ -3,7 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpDelivery(models.Model):
@@ -169,7 +170,21 @@ class FpDelivery(models.Model):
)
def action_mark_delivered(self):
"""Block "delivered" until a Proof of Delivery exists.
The driver must capture POD (signature, photos, recipient name)
on the iPad at the customer's dock BEFORE marking delivered.
Without POD we have no signed receipt to attach to the
invoice and no defence against a delivery dispute.
"""
for rec in self:
if not rec.pod_id:
raise UserError(_(
'Cannot mark delivery "%(name)s" delivered — no Proof '
'of Delivery (POD) has been captured.\n\n'
'On the iPad: Capture POD → enter recipient name + '
'signature → save. Then mark delivered.'
) % {'name': rec.name or rec.display_name})
rec.write({
'state': 'delivered',
'delivered_at': fields.Datetime.now(),

View File

@@ -114,6 +114,18 @@ customer = env['res.partner'].sudo().create({
'city': 'Toronto', 'zip': 'M5G 1V7',
'country_id': env.ref('base.ca').id,
})
# Net-30 default so invoices created later inherit the right schedule.
net30 = env.ref('account.account_payment_term_30days', raise_if_not_found=False)
if net30:
customer.sudo().property_payment_term_id = net30.id
# Make sure the company has a default facility so MO confirm succeeds.
co = env.company
if not co.x_fc_default_facility_id:
f = env['fusion.plating.facility'].search([('active', '=', True)], limit=1)
if f:
co.sudo().x_fc_default_facility_id = f.id
show('company default facility set', f.name)
step('SANDRA', f'Receives RFQ from {customer.name}')
@@ -336,6 +348,133 @@ if wet_assignments:
'x_fc_tank_id': saved_tank,
})
# ===== Negative tests for the 6 new gates (wrapped in savepoints
# so an SQL-level constraint failure doesn't abort the txn) =====
banner('PHASE 4c — Negative tests for the new compliance gates')
def neg_test(label, fn, expect_keywords):
"""Run fn() inside a savepoint; check the raised error mentions
one of `expect_keywords`. Always rolls back."""
sp_name = f'neg_{abs(hash(label))}'
env.cr.execute(f'SAVEPOINT {sp_name}')
fired = False
msg = ''
try:
fn()
except Exception as e:
msg = str(e)
low = msg.lower()
fired = any(k.lower() in low for k in expect_keywords)
finally:
env.cr.execute(f'ROLLBACK TO SAVEPOINT {sp_name}')
if msg:
show(' blocked with', msg.splitlines()[0][:120])
finding('PASS' if fired else 'FAIL',
f'gate: {label}',
'blocked' if fired else f'NOT blocked (got: {msg[:60]!r})')
# Test 3: MO confirm without facility → expect block
step('SYSTEM', 'Test 3 — MO confirm with no facility → blocked')
def t_mo_facility():
saved_default = env.company.x_fc_default_facility_id
env.company.sudo().x_fc_default_facility_id = False
fac0 = env['fusion.plating.facility'].search([('active', '=', True)])
fac0.sudo().write({'active': False})
try:
m = env['mrp.production'].sudo().create({
'product_id': mo.product_id.id,
'product_qty': 1,
'company_id': env.company.id,
})
m.action_confirm() # should raise — no facility resolvable
finally:
fac0.sudo().write({'active': True})
env.company.sudo().x_fc_default_facility_id = saved_default
neg_test('MO confirm without facility', t_mo_facility,
['facility'])
# Test 4: Cert issue without spec_reference
step('SYSTEM', 'Test 4 — Cert action_issue() without spec_reference → blocked')
def t_cert_spec():
c = env['fp.certificate'].sudo().create({
'partner_id': customer.id,
'production_id': mo.id,
'certificate_type': 'coc',
'spec_reference': False,
})
c.action_issue()
neg_test('cert issue without spec_reference', t_cert_spec,
['Spec', 'spec_reference'])
# Test 5: Delivery mark_delivered without POD
step('SYSTEM', 'Test 5 — Delivery mark_delivered() with no POD → blocked')
def t_dlv_pod():
d = env['fusion.plating.delivery'].sudo().create({
'partner_id': customer.id,
'state': 'en_route',
'company_id': env.company.id,
})
d.action_mark_delivered()
neg_test('delivery delivered without POD', t_dlv_pod,
['POD', 'Proof of Delivery'])
# Test 6: Invoice post without payment terms
step('SYSTEM', 'Test 6 — Invoice post() with no payment terms → blocked')
def t_inv_terms():
saved_term = customer.property_payment_term_id
customer.sudo().property_payment_term_id = False
try:
i = env['account.move'].sudo().create({
'move_type': 'out_invoice',
'partner_id': customer.id,
'invoice_date': fields.Date.today(),
'invoice_line_ids': [(0, 0, {
'name': 'Test plating service',
'quantity': 1,
'price_unit': 100.0,
})],
})
i.invoice_payment_term_id = False
i.action_post()
finally:
customer.sudo().property_payment_term_id = saved_term
neg_test('invoice post without payment terms', t_inv_terms,
['payment term'])
# Test 7: Thickness reading without calibration_std_ref
step('SYSTEM', 'Test 7 — Thickness reading without calibration_std_ref → blocked')
def t_thickness_cal():
env['fp.thickness.reading'].sudo().create({
'production_id': mo.id,
'reading_number': 99,
'nip_mils': 0.05,
'calibration_std_ref': False,
})
neg_test('thickness reading without cal std', t_thickness_cal,
['calibration', 'required', 'not-null', 'null value'])
# =====================================================================
banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)')
# =====================================================================
@@ -514,9 +653,26 @@ if dlv:
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()
# POD must be captured BEFORE marking delivered (new gate)
if dlv.state == 'en_route' and not dlv.pod_id:
step('DAVE', 'Captures POD on iPad — recipient signs + photo')
POD = env['fusion.plating.proof.of.delivery']
pod = POD.with_user(users['dave']).sudo().create({
'delivery_id': dlv.id,
'partner_id': dlv.partner_id.id,
'recipient_name': 'Dock Receiver',
'notes': 'E2E sim — recipient on dock signed for parts',
})
dlv.sudo().pod_id = pod.id
show(' POD captured', f'{pod.name} (id={pod.id})')
if dlv.state == 'en_route': dlv.with_user(users['dave']).sudo().action_mark_delivered()
except Exception as e:
print(f' [info] delivery transitions: {e}')
# ===== Negative test: try to mark another delivery delivered without POD =====
finding('PASS' if dlv.pod_id else 'FAIL',
'POD captured before delivery',
f'pod_id={dlv.pod_id.name if dlv.pod_id else "NONE"}')
finding('PASS' if dlv.state == 'delivered' else 'FAIL',
'delivery final state', dlv.state)
coc_logs = env['fusion.plating.chain.of.custody'].search(

View File

@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""Comprehensive required-fields audit.
For each major model in the quote → invoice workflow:
• Lists fields currently marked `required=True` in the schema
• For the most recent COMPLETED record, shows which compliance-
relevant fields are empty (gap candidates)
• Classifies each gap by severity:
CRITICAL — compliance blocker (aerospace / Nadcap / env.)
IMPORTANT — workflow / operational risk
NICE — would improve reporting
The report is purely diagnostic — it changes nothing in the DB.
"""
env = env # noqa
from collections import defaultdict
def section(title):
print(f'\n{"="*78}\n {title}\n{"="*78}')
def show_field_audit(model_name, record, candidate_fields):
"""For one record, show which of `candidate_fields` are empty.
candidate_fields: list of (field, severity, reason) tuples
"""
if not record:
print(f' (no record found for {model_name})')
return
print(f' Record: {record.display_name} (id={record.id})')
# First show what's currently required in the schema
required_in_schema = [
n for n, f in record._fields.items()
if getattr(f, 'required', False)
]
print(f' Already required in schema: {len(required_in_schema)}')
print(f' Candidate fields needing enforcement:')
for field, severity, reason in candidate_fields:
if field not in record._fields:
continue
val = record[field]
is_empty = (
not val
or (hasattr(val, '_name') and not val.ids)
or val in ('', False, 0, 0.0)
)
sym = {'CRITICAL': '🔴', 'IMPORTANT': '🟡', 'NICE': ''}[severity]
marker = '✗ EMPTY' if is_empty else '✓ filled'
val_str = str(val)[:60] if not is_empty else ''
print(f' {sym} {severity:<9} {field:<32} {marker:<10} {reason}')
print(f' currently: {val_str!r}')
# =====================================================================
section('1. Customer (res.partner) — most recently used customer')
# =====================================================================
partner = env['sale.order'].search([], order='id desc', limit=1).partner_id
show_field_audit('res.partner', partner, [
('email', 'CRITICAL', 'Notifications + portal access — silent fail without it'),
('phone', 'IMPORTANT', 'Operator can call for clarification'),
('street', 'CRITICAL', 'Required on BoL + Invoice + delivery — no shipping without'),
('city', 'CRITICAL', 'Same'),
('zip', 'CRITICAL', 'Same'),
('country_id', 'CRITICAL', 'Determines tax + ITAR / CGP rules'),
('vat', 'IMPORTANT', 'HST/GST registration number — needed on invoice'),
('property_payment_term_id', 'IMPORTANT', 'Net-30 vs Net-60 controls invoice due date'),
('x_fc_account_hold', 'NICE', 'Default False is fine; only set when collections issue'),
('x_fc_send_coc', 'NICE', 'Per-customer CoC delivery preference'),
])
# =====================================================================
section('2. Sale Order (sale.order) — most recent SO')
# =====================================================================
so = env['sale.order'].search([], order='id desc', limit=1)
show_field_audit('sale.order', so, [
('partner_id', 'CRITICAL', 'Already required by Odoo'),
('client_order_ref', 'CRITICAL', 'Customer PO# — every aero customer requires this on every doc'),
('x_fc_po_number', 'CRITICAL', 'Same — FP-specific mirror'),
('x_fc_coating_config_id', 'CRITICAL', 'Drives recipe + price + spec'),
('x_fc_part_catalog_id', 'IMPORTANT', 'Part the order is about — needed for traceability'),
('x_fc_delivery_method', 'IMPORTANT', 'Pickup / drop / courier — drives logistics'),
('x_fc_rfq_attachment_id', 'NICE', 'Original customer RFQ for audit trail'),
('x_fc_po_attachment_id', 'IMPORTANT', 'Customer signed PO PDF'),
('payment_term_id', 'IMPORTANT', 'Net terms — derived from customer if unset'),
('user_id', 'IMPORTANT', 'Salesperson — needed for commission + handoff'),
])
# =====================================================================
section('3. Receiving (fp.receiving) — most recent record')
# =====================================================================
recv = env['fp.receiving'].search([], order='id desc', limit=1)
show_field_audit('fp.receiving', recv, [
('sale_order_id', 'CRITICAL', 'Without this we lose the link to the job'),
('partner_id', 'CRITICAL', 'Customer (related, but can drift)'),
('received_by_id', 'CRITICAL', 'Who counted the parts (audit trail)'),
('received_date', 'CRITICAL', 'When the parts arrived (compliance + start-clock)'),
('expected_qty', 'CRITICAL', 'Without this no qty-match check'),
('received_qty', 'CRITICAL', 'The actual count (compliance — discrepancy log)'),
('carrier_name', 'IMPORTANT', 'Who delivered — chain-of-custody starts here'),
('carrier_tracking', 'IMPORTANT', 'Inbound tracking #'),
('notes', 'NICE', 'Free-form receiver observations'),
])
# =====================================================================
section('4. MRP Production (mrp.production) — most recent MO')
# =====================================================================
mo = env['mrp.production'].search([('state', '=', 'done')], order='id desc', limit=1)
show_field_audit('mrp.production', mo, [
('product_id', 'CRITICAL', 'Already required by Odoo'),
('product_qty', 'CRITICAL', 'Same'),
('x_fc_facility_id', 'CRITICAL', 'Where the job is being made (compliance)'),
('x_fc_recipe_id', 'CRITICAL', 'Which process — without it WOs can\'t be generated'),
('x_fc_assigned_manager_id','IMPORTANT','Manager responsible for the job'),
('x_fc_customer_spec_id','IMPORTANT', 'Customer spec controlling the job (e.g. AMS 2404)'),
('x_fc_portal_job_id', 'IMPORTANT', 'Portal-facing job tracker'),
('origin', 'CRITICAL', 'Source SO — needed for back-link'),
('company_id', 'CRITICAL', 'Multi-company correctness (just fixed)'),
])
# =====================================================================
section('5. Work Orders (mrp.workorder) — wet WO from most recent MO')
# =====================================================================
wet_wo = mo.workorder_ids.filtered(
lambda w: hasattr(w, '_fp_is_wet_process') and w._fp_is_wet_process()
)[:1] if mo else env['mrp.workorder']
show_field_audit('mrp.workorder', wet_wo, [
('x_fc_assigned_user_id', 'CRITICAL', 'NOW ENFORCED via button_start gate'),
('x_fc_bath_id', 'CRITICAL', 'NOW ENFORCED — chemistry traceability'),
('x_fc_tank_id', 'CRITICAL', 'NOW ENFORCED — physical tank audit'),
('x_fc_facility_id', 'CRITICAL', 'Which plant ran it (multi-facility shops)'),
('x_fc_thickness_target', 'IMPORTANT', 'Spec target — drives QC accept/reject criteria'),
('x_fc_dwell_time_minutes','IMPORTANT','Recipe dwell — needed for cycle-time analytics'),
('x_fc_rack_id', 'IMPORTANT', 'Which rack/fixture used (per-rack MTO tracking)'),
('x_fc_started_by_user_id','IMPORTANT','Who actually started it (audit, may differ from assigned)'),
('x_fc_finished_by_user_id','IMPORTANT','Who finished it'),
])
# =====================================================================
section('6. Bath Log (fusion.plating.bath.log)')
# =====================================================================
baths = env['fusion.plating.bath.log'].search([], order='id desc', limit=1)
show_field_audit('fusion.plating.bath.log', baths, [
('bath_id', 'CRITICAL', 'Which bath the readings came from'),
('shift', 'IMPORTANT', 'Day/swing/night — for shift-effect analysis'),
('user_id', 'CRITICAL', 'Operator who took the readings (audit trail)'),
('logged_at', 'CRITICAL', 'When the readings were taken'),
('line_ids', 'CRITICAL', 'The actual chemistry numbers (the whole point)'),
('notes', 'NICE', 'Free-form observations'),
])
# =====================================================================
section('7. Certificate (fp.certificate) — most recent CoC')
# =====================================================================
coc = env['fp.certificate'].search(
[('certificate_type', '=', 'coc')], order='id desc', limit=1)
show_field_audit('fp.certificate', coc, [
('partner_id', 'CRITICAL', 'Customer the cert belongs to'),
('production_id', 'CRITICAL', 'Which MO it certifies'),
('po_number', 'CRITICAL', 'Customer PO — required by aero specs'),
('spec_reference', 'CRITICAL', 'AMS 2404 / MIL-C-26074 etc. — what was met'),
('process_description','IMPORTANT','Human-readable process name'),
('part_number', 'IMPORTANT', 'Part the cert covers'),
('quantity_shipped', 'CRITICAL', 'How many parts certified'),
('thickness_reading_ids','CRITICAL','Fischerscope readings (NOW AUTO-LINKED)'),
('attachment_id', 'CRITICAL', 'The PDF itself (NOW AUTO-RENDERED)'),
('issued_by_id', 'CRITICAL', 'Inspector signature — who certified this'),
('issued_date', 'CRITICAL', 'When issued'),
('state', 'CRITICAL', 'draft/issued/voided — NOT issued = NOT compliant'),
])
# =====================================================================
section('8. Thickness Reading (fp.thickness.reading)')
# =====================================================================
reading = env['fp.thickness.reading'].search([], order='id desc', limit=1)
show_field_audit('fp.thickness.reading', reading, [
('production_id', 'CRITICAL', 'Which MO this reading is from'),
('certificate_id', 'CRITICAL', 'Which cert (auto-linked at MO done)'),
('reading_number', 'CRITICAL', 'Sequence (n=1, n=2, n=3 — Nadcap requires this)'),
('nip_mils', 'CRITICAL', 'The thickness measurement itself'),
('ni_percent', 'IMPORTANT', 'Composition — affects bath chemistry diagnosis'),
('p_percent', 'IMPORTANT', 'Same'),
('position_label', 'CRITICAL', 'WHERE on the part (Nadcap requires location)'),
('equipment_model', 'CRITICAL', 'Which gauge — calibration trail'),
('calibration_std_ref', 'CRITICAL', 'Which calibration standard — Nadcap req'),
('operator_id', 'CRITICAL', 'Who took the reading'),
('reading_datetime', 'CRITICAL', 'When'),
])
# =====================================================================
section('9. Delivery (fusion.plating.delivery)')
# =====================================================================
dlv = env['fusion.plating.delivery'].search(
[('state', '=', 'delivered')], order='id desc', limit=1)
show_field_audit('fusion.plating.delivery', dlv, [
('partner_id', 'CRITICAL', 'Already required'),
('scheduled_date', 'CRITICAL', 'When the customer expects parts (NOW PREFILLED)'),
('assigned_driver_id', 'CRITICAL', 'Who is driving (NOW PREFILLED)'),
('vehicle_id', 'IMPORTANT', 'Which vehicle (insurance + GPS)'),
('delivered_at', 'CRITICAL', 'When delivery was completed'),
('contact_name', 'IMPORTANT', 'Recipient on the receiving dock'),
('contact_phone', 'IMPORTANT', 'Driver can call before arriving'),
('coc_attachment_id', 'CRITICAL', 'CoC PDF that goes with the parts'),
('packing_list_attachment_id','IMPORTANT','Packing slip'),
('delivery_address_id','IMPORTANT', 'Override default partner ship-to'),
('pod_id', 'CRITICAL', 'Proof of delivery — without it, we can\'t bill'),
])
# =====================================================================
section('10. Invoice (account.move) — most recent posted invoice')
# =====================================================================
inv = env['account.move'].search(
[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')],
order='id desc', limit=1)
show_field_audit('account.move', inv, [
('partner_id', 'CRITICAL', 'Already required'),
('invoice_date', 'CRITICAL', 'When invoiced — drives net-terms clock'),
('invoice_date_due', 'CRITICAL', 'When payment due'),
('invoice_payment_term_id','CRITICAL', 'Net-30 etc.'),
('invoice_user_id', 'IMPORTANT', 'Salesperson — for commission'),
('partner_bank_id', 'IMPORTANT', 'Where to wire payment'),
('ref', 'CRITICAL', 'Customer PO# / reference (required by AP teams)'),
('invoice_origin', 'CRITICAL', 'Source SO link'),
('narration', 'NICE', 'Free-form notes'),
])
# =====================================================================
section('11. Workforce — Quality Hold + NCR + CAPA (open + completed)')
# =====================================================================
# Sample Quality Hold if any
qh = env.get('fusion.plating.quality.hold')
if qh is not None:
rec = qh.search([], order='id desc', limit=1)
show_field_audit('fusion.plating.quality.hold', rec, [
('partner_id', 'CRITICAL', 'Customer — without it we can\'t notify'),
('mo_id', 'CRITICAL', 'Which MO'),
('hold_reason', 'CRITICAL', 'Selection — categorize the issue'),
('description', 'CRITICAL', 'Inspector\'s narrative'),
('qty_on_hold', 'CRITICAL', 'How many parts affected'),
('inspector_id', 'CRITICAL', 'Who flagged it'),
('created_at', 'CRITICAL', 'When'),
])
ncr = env.get('fusion.plating.ncr')
if ncr is not None:
rec = ncr.search([], order='id desc', limit=1)
show_field_audit('fusion.plating.ncr', rec, [
('name', 'CRITICAL', 'NCR# / sequence'),
('partner_id', 'CRITICAL', 'Customer affected'),
('production_id', 'CRITICAL', 'Source MO'),
('description', 'CRITICAL', 'What went wrong'),
('severity', 'CRITICAL', 'Critical / major / minor'),
('containment_action', 'CRITICAL', 'Immediate action — Nadcap req'),
('root_cause', 'CRITICAL', 'Why — required to close'),
('corrective_action', 'CRITICAL', 'Fix — required to close'),
('disposition', 'CRITICAL', 'Use-as-is / scrap / rework — decision'),
('raised_by_id', 'CRITICAL', 'Who raised it'),
('raised_date', 'CRITICAL', 'When'),
])
capa = env.get('fusion.plating.capa')
if capa is not None:
rec = capa.search([], order='id desc', limit=1)
show_field_audit('fusion.plating.capa', rec, [
('name', 'CRITICAL', 'CAPA#'),
('owner_id', 'CRITICAL', 'Owner / champion'),
('due_date', 'CRITICAL', 'Deadline'),
('problem_description', 'CRITICAL', 'What\'s the recurring issue'),
('root_cause', 'CRITICAL', 'Why-why analysis — required'),
('corrective_action', 'CRITICAL', 'Fix the existing'),
('preventive_action', 'CRITICAL', 'Prevent recurrence'),
('verification_evidence', 'CRITICAL', 'Proof the fix worked'),
('effectiveness_date', 'IMPORTANT','When effectiveness confirmed'),
])
# =====================================================================
section('12. Compliance: discharge sample + waste manifest + spill')
# =====================================================================
DS = env.get('fusion.plating.discharge.sample')
if DS is not None:
rec = DS.search([], order='id desc', limit=1)
show_field_audit('fusion.plating.discharge.sample', rec, [
('sample_date', 'CRITICAL', 'When the sample was taken (regulatory)'),
('sampled_by_id', 'CRITICAL', 'Who'),
('outfall_id', 'CRITICAL', 'Which discharge point (jurisdictional req)'),
('parameter_id', 'CRITICAL', 'What pollutant'),
('value_measured', 'CRITICAL', 'The reading itself'),
('limit_value', 'CRITICAL', 'The regulatory limit'),
('exceeds_limit', 'CRITICAL', 'Pass/fail — drives mandatory reporting'),
('lab_cert_attachment_id','CRITICAL','Lab cert — required for regulator'),
])
WM = env.get('fusion.plating.waste.manifest')
if WM is not None:
rec = WM.search([], order='id desc', limit=1)
show_field_audit('fusion.plating.waste.manifest', rec, [
('manifest_number', 'CRITICAL', 'Government tracking #'),
('generator_id', 'CRITICAL', 'Who generated the waste (us)'),
('hauler_id', 'CRITICAL', 'Who picked it up (carrier)'),
('disposal_facility_id','CRITICAL','Where it went (landfill / treatment)'),
('waste_code', 'CRITICAL', 'EPA / TDG hazardous code'),
('quantity', 'CRITICAL', 'How much'),
('uom', 'CRITICAL', 'Unit'),
('shipped_date', 'CRITICAL', 'When shipped'),
('received_date', 'CRITICAL', 'When received at disposal — closes the loop'),
])
# =====================================================================
section('SUMMARY — gap counts by severity')
# =====================================================================
print(' See per-model details above. Critical gaps are real')
print(' compliance / workflow blockers; Important are operational')
print(' risks; Nice-to-have are quality-of-life.')
print()
print(' Recommended next-batch fixes (in priority order):')
print(' 1. invoice.ref auto-fill from sale_order.client_order_ref')
print(' (so customer PO# always lands on the invoice)')
print(' 2. fp.receiving.received_by_id default + required on accept')
print(' 3. mrp.production.x_fc_facility_id required (block confirm)')
print(' 4. fp.certificate.spec_reference required to issue')
print(' 5. fp.delivery.pod_id required to mark "delivered"')
print(' 6. fp.thickness.reading.position_label + calibration_std_ref required')
print(' 7. ncr/capa state-transition gates (can\'t close without root_cause)')
print(' 8. discharge.sample.lab_cert_attachment_id required to mark complete')