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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user