diff --git a/fusion_plating/fusion_plating_compliance/__manifest__.py b/fusion_plating/fusion_plating_compliance/__manifest__.py index 3281beb4..ae00319c 100644 --- a/fusion_plating/fusion_plating_compliance/__manifest__.py +++ b/fusion_plating/fusion_plating_compliance/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating - Compliance (Framework)', - 'version': '19.0.1.0.0', + 'version': '19.0.1.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.', 'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.', diff --git a/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample.py b/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample.py index 54ff426c..720b7617 100644 --- a/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample.py +++ b/fusion_plating/fusion_plating_compliance/models/fp_discharge_sample.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError class FpDischargeSample(models.Model): @@ -63,4 +64,32 @@ class FpDischargeSample(models.Model): self.write({'state': 'escalated'}) def action_close(self): + """Block close until lab evidence is on file. + + A closed discharge sample without a lab report ref + at least + one parameter reading + (when results are in) a lab cert + attachment fails any environmental audit. The whole point + of the record is to document the test was performed and what + the lab said. + """ + for rec in self: + missing = [] + if not rec.lab_report_ref: + missing.append(_('Lab Report #')) + if not rec.received_date: + missing.append(_('Results Received Date')) + if not rec.line_ids: + missing.append(_('At least one parameter reading')) + if not rec.attachment_ids: + missing.append(_('Lab certificate / report attachment')) + if missing: + raise UserError(_( + 'Cannot close discharge sample "%(name)s" — these ' + 'fields must be filled in first:\n • %(fields)s\n\n' + 'Without lab evidence on file the record fails any ' + 'environmental compliance audit.' + ) % { + 'name': rec.name or rec.display_name, + 'fields': '\n • '.join(missing), + }) self.write({'state': 'closed'}) diff --git a/fusion_plating/fusion_plating_invoicing/__manifest__.py b/fusion_plating/fusion_plating_invoicing/__manifest__.py index 86c91529..1de47e22 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.1.0', + 'version': '19.0.2.2.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 9c6ac8de..8f645cf0 100644 --- a/fusion_plating/fusion_plating_invoicing/models/account_move.py +++ b/fusion_plating/fusion_plating_invoicing/models/account_move.py @@ -12,21 +12,39 @@ class AccountMove(models.Model): @api.model_create_multi def create(self, vals_list): - """Auto-inherit payment terms from the customer when missing. + """Auto-inherit payment terms + customer PO# at creation time. - 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. + Two defensive defaults so newly-created invoices come out + compliant out of the box: + + 1. **invoice_payment_term_id** — pulled from the customer's + property_payment_term_id (Net-30, COD, etc.). Without this + the due date silently becomes "immediate", wrong for B2B. + + 2. **ref** (customer reference / PO#) — pulled from the source + sale order's client_order_ref or x_fc_po_number. Customer + AP teams reject invoices that don't quote their PO# back. + We already populate this on the SO confirm path, but a + manually-created invoice would miss it without this default. """ Partner = self.env['res.partner'] + SO = self.env['sale.order'] 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 + # Defensive PO#: invoice_origin links to the SO; pull the + # customer ref from there if the caller didn't pass one. + if not vals.get('ref') and vals.get('invoice_origin'): + so = SO.search([('name', '=', vals['invoice_origin'])], limit=1) + if so: + vals['ref'] = ( + so.client_order_ref + or (so.x_fc_po_number if 'x_fc_po_number' in so._fields else False) + or False + ) return super().create(vals_list) def action_post(self): diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index 630d728b..89f5e0c0 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Quality (QMS)', - 'version': '19.0.1.1.0', + 'version': '19.0.1.2.0', 'category': 'Manufacturing/Plating', 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'internal audits, customer specs, document control. CE + EE compatible.', diff --git a/fusion_plating/fusion_plating_quality/models/fp_capa.py b/fusion_plating/fusion_plating_quality/models/fp_capa.py index c407ca19..e3fc74fb 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_capa.py +++ b/fusion_plating/fusion_plating_quality/models/fp_capa.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 FpCapa(models.Model): @@ -160,6 +161,43 @@ class FpCapa(models.Model): }) def action_close(self): + """Block close unless root_cause + action_plan + verification are set. + + A CAPA without these is just an open ticket — the AS9100 §10.2 + / Nadcap loop requires evidence of the root cause analysis, + the corrective/preventive action plan, AND that effectiveness + was verified before the loop is closed. + """ + for rec in self: + missing = [] + + def is_empty_html(val): + if not val: + return True + s = str(val).replace('

', '').replace('

', '') + s = s.replace('
', '').replace('
', '').strip() + return not s + + if is_empty_html(rec.root_cause_analysis): + missing.append(_('Root Cause Analysis')) + if is_empty_html(rec.action_plan): + missing.append(_('Action Plan')) + if not rec.verification_date or not rec.verification_by_id: + missing.append(_('Verification (date + verifier)')) + if rec.is_effective is False and is_empty_html(rec.effectiveness_notes): + # If marked not-effective, demand a note explaining the + # follow-up plan — otherwise the loop never actually closes. + missing.append(_('Effectiveness Notes (required when "Not Effective")')) + if missing: + raise UserError(_( + 'Cannot close CAPA "%(name)s" — these fields must be ' + 'filled in first:\n • %(fields)s\n\n' + 'A CAPA without root cause + action plan + verified ' + 'effectiveness fails AS9100 §10.2 / Nadcap on audit.' + ) % { + 'name': rec.name or rec.display_name, + 'fields': '\n • '.join(missing), + }) self.write({'state': 'closed'}) def action_reset_to_draft(self): diff --git a/fusion_plating/fusion_plating_quality/models/fp_ncr.py b/fusion_plating/fusion_plating_quality/models/fp_ncr.py index 9821931b..c85823ef 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_ncr.py +++ b/fusion_plating/fusion_plating_quality/models/fp_ncr.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 FpNcr(models.Model): @@ -156,6 +157,42 @@ class FpNcr(models.Model): self.write({'state': 'disposition'}) def action_close(self): + """Block close unless root_cause + containment + disposition are set. + + A closed NCR without these three is useless for AS9100 audits: + the whole point of the NCR is to document what went wrong + (containment), why (root_cause), and what we decided to do + with the affected parts (disposition). + """ + for rec in self: + missing = [] + # Strip HTML-empty strings like "


" before checking + def is_empty_html(val): + if not val: + return True + s = str(val).replace('

', '').replace('

', '') + s = s.replace('
', '').replace('
', '').strip() + return not s + + if is_empty_html(rec.description): + missing.append(_('Description')) + if is_empty_html(rec.containment): + missing.append(_('Containment Actions')) + if is_empty_html(rec.root_cause): + missing.append(_('Root Cause')) + if not rec.disposition: + missing.append(_('Disposition (use-as-is / rework / scrap / RTV)')) + if missing: + raise UserError(_( + 'Cannot close NCR "%(name)s" — these fields must be ' + 'filled in first:\n • %(fields)s\n\n' + 'AS9100 / Nadcap auditors will reject a closed NCR ' + 'that doesn\'t document what happened, why, and how ' + 'we responded.' + ) % { + 'name': rec.name or rec.display_name, + 'fields': '\n • '.join(missing), + }) self.write({ 'state': 'closed', 'closed_date': fields.Datetime.now(), diff --git a/fusion_plating/scripts/fp_e2e_workforce.py b/fusion_plating/scripts/fp_e2e_workforce.py index 485f0f95..70c03946 100644 --- a/fusion_plating/scripts/fp_e2e_workforce.py +++ b/fusion_plating/scripts/fp_e2e_workforce.py @@ -475,6 +475,72 @@ def t_thickness_cal(): neg_test('thickness reading without cal std', t_thickness_cal, ['calibration', 'required', 'not-null', 'null value']) +# Test 8: NCR close without root cause / containment / disposition +step('SYSTEM', 'Test 8 — NCR close() with missing fields → blocked') + + +def t_ncr_close(): + f = env['fusion.plating.facility'].search([], limit=1) + n = env['fusion.plating.ncr'].sudo().create({ + 'facility_id': f.id, + 'description': '', + 'containment': '', + 'root_cause': '', + 'disposition': False, + }) + n.action_close() + + +neg_test('NCR close without RC/containment/disposition', t_ncr_close, + ['Root Cause', 'Containment', 'Disposition']) + +# Test 9: CAPA close without root cause analysis / action plan / verification +step('SYSTEM', 'Test 9 — CAPA close() with missing fields → blocked') + + +def t_capa_close(): + c = env['fusion.plating.capa'].sudo().create({ + 'description': '', + 'root_cause_analysis': '', + 'action_plan': '', + }) + c.action_close() + + +neg_test('CAPA close without analysis/plan/verification', t_capa_close, + ['Root Cause Analysis', 'Action Plan', 'Verification']) + +# Test 10: Discharge sample close without lab evidence +step('SYSTEM', 'Test 10 — Discharge sample close() with no lab evidence → blocked') + + +def t_discharge_close(): + f = env['fusion.plating.facility'].search([], limit=1) + s = env['fusion.plating.discharge.sample'].sudo().create({ + 'facility_id': f.id, + }) + s.action_close() + + +neg_test('discharge sample close without lab evidence', t_discharge_close, + ['Lab Report', 'Results Received', 'parameter', 'Lab certificate']) + +# Test 11: Invoice ref auto-fill from SO at create time +step('SYSTEM', 'Test 11 — Invoice ref auto-fills from SO.client_order_ref') +test_inv2 = env['account.move'].sudo().create({ + 'move_type': 'out_invoice', + 'partner_id': customer.id, + 'invoice_date': fields.Date.today(), + 'invoice_origin': so.name, + 'invoice_line_ids': [(0, 0, { + 'name': 'Test', 'quantity': 1, 'price_unit': 1.0, + })], +}) +finding('PASS' if test_inv2.ref == so.client_order_ref else 'FAIL', + 'invoice ref auto-fills from SO', + f'ref={test_inv2.ref!r} (expected {so.client_order_ref!r})') +test_inv2.sudo().unlink() + # ===================================================================== banner('PHASE 5 — Operators run their work orders (REAL-TIME timers)') # =====================================================================