diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index a413733a..da80fde9 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py index b72f09ca..37a6d935 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -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: diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py index 9a2180fd..0c6987db 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py @@ -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, diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index fadb8948..2383d813 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index ab9a6dac..674777d6 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -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.')) diff --git a/fusion_plating/fusion_plating_certificates/models/fp_thickness_reading.py b/fusion_plating/fusion_plating_certificates/models/fp_thickness_reading.py index ee43b393..786309ec 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_thickness_reading.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_thickness_reading.py @@ -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', diff --git a/fusion_plating/fusion_plating_invoicing/__manifest__.py b/fusion_plating/fusion_plating_invoicing/__manifest__.py index f2cd2e5e..86c91529 100644 --- a/fusion_plating/fusion_plating_invoicing/__manifest__.py +++ b/fusion_plating/fusion_plating_invoicing/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_invoicing/models/account_move.py b/fusion_plating/fusion_plating_invoicing/models/account_move.py index e3624563..9c6ac8de 100644 --- a/fusion_plating/fusion_plating_invoicing/models/account_move.py +++ b/fusion_plating/fusion_plating_invoicing/models/account_move.py @@ -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() diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py index ea849498..dda7eaa4 100644 --- a/fusion_plating/fusion_plating_logistics/__manifest__.py +++ b/fusion_plating/fusion_plating_logistics/__manifest__.py @@ -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 ' diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py index d3aca717..02aade20 100644 --- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py @@ -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(), diff --git a/fusion_plating/scripts/fp_e2e_workforce.py b/fusion_plating/scripts/fp_e2e_workforce.py index cbbe3ede..485f0f95 100644 --- a/fusion_plating/scripts/fp_e2e_workforce.py +++ b/fusion_plating/scripts/fp_e2e_workforce.py @@ -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( diff --git a/fusion_plating/scripts/fp_required_fields_audit.py b/fusion_plating/scripts/fp_required_fields_audit.py new file mode 100644 index 00000000..47044b70 --- /dev/null +++ b/fusion_plating/scripts/fp_required_fields_audit.py @@ -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')