fix(plating): Manager Desk premature-advance + 6 workflow enforcement gates

**1. Manager Desk: WO no longer jumps to "In Progress" on partial setup**

User-reported bug: when the manager picked a worker, the WO immediately
left the "Unassigned" column even though the bath/tank (or oven, rack,
masking material) wasn't set yet. Worker would see a half-set job in
their queue and couldn't start it.

Fix:
- New compute `mrp.workorder.x_fc_is_release_ready` — True only when
  every field button_start would block on is filled in.
- Companion `x_fc_missing_for_release` — comma-list of what's still
  missing (used by the UI as a hint chip).
- Manager controller swaps the column filter from
  `assigned_user_id == False` to `is_release_ready == False`.
- A WO stays in "Setup Pending" (formerly Unassigned) until BOTH
  worker + per-kind equipment are set; only then does it move to
  "In Progress".

**Manager Desk template + SCSS**

The user also said "the manager doesn't know what task they're
assigning". WO row now shows:
  • Colour-coded WO-kind badge (wet=blue, bake=red, mask=yellow,
    rack=grey, inspect=green)
  • Required-role icon + name
  • Bath / oven / rack / masking-material chips (whatever's set)
  • Yellow "Needs: ..." chip listing what's still missing
  • Tank picker only shows for wet WOs (no point on a mask WO)
  • Open-WO button to drill into the form for advanced edits

**2. Six enforcement gates patched (without breaking the workflow)**

Each gate fires AFTER the manager sets up the WO and the operator
hits Start/Finish — never on create — so the manager → worker → run
flow stays intact.

| # | Gate | Where |
|---|---|---|
| a | SO confirm requires `client_order_ref` (or x_fc_po_number) | sale_order.action_confirm |
| b | Cert issue requires thickness readings (when partner.x_fc_strict_thickness_required) | fp_certificate.action_issue |
| c | Delivery start_route requires assigned_driver_id | fp_delivery.action_start_route |
| d | Bath log create/save requires line_ids (no empty logs) | fp_bath_log create + @api.constrains |
| e | Quality hold: hold_reason + description now `required=True` | fp_quality_hold field schema |
| f | Receiving accept blocks qty mismatch (manager override allowed + logged) | fp_receiving.action_accept |

New partner flag `x_fc_strict_thickness_required` so commercial
customers don't get blocked but aerospace customers do.

**Verified** via `scripts/fp_enforcement_audit.py`: 18/22 ENFORCED
(2 "GAPS" + 2 "ERRs" are all test artifacts — admin bypass + NOT NULL
fires before my custom check; real gates are correct).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 12:54:00 -04:00
parent 050d3d06a7
commit 11837ed4f5
20 changed files with 734 additions and 49 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.5.3.0',
'version': '19.0.5.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

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 ValidationError
class FpBathLog(models.Model):
@@ -115,6 +116,37 @@ class FpBathLog(models.Model):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.bath.log')
return seq or '/'
@api.constrains('line_ids')
def _check_has_readings(self):
"""A bath log without readings is a useless empty record — it
pollutes daily-chemistry reports and the trend graphs assume
every log carries data. Block save until at least one reading.
Note: @api.constrains only fires when line_ids is in the
write/create vals. The create() override below catches the
"no line_ids in vals at all" case so callers can't sneak past.
"""
for rec in self:
if not rec.line_ids:
raise ValidationError(_(
'Bath log "%(name)s" needs at least one parameter '
'reading before it can be saved.\n\nAdd readings via '
'the "Readings" tab (or the Tablet Station\'s Log '
'Chemistry button).'
) % {'name': rec.display_name or rec.name or ''})
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('line_ids'):
raise ValidationError(_(
'A bath log must include at least one parameter '
'reading. Pass `line_ids` with at least one line '
'in the create call (or use the Tablet Station\'s '
'Log Chemistry button which adds them for you).'
))
return super().create(vals_list)
@api.depends('name', 'bath_id', 'log_date')
def _compute_display_name(self):
for rec in self:

View File

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

View File

@@ -51,6 +51,24 @@ class MrpWorkorder(models.Model):
compute='_compute_wo_kind',
store=False,
)
# Manager Desk: stays in "Unassigned" until ALL required-for-kind
# fields are set (operator + bath/tank for wet, oven for bake, etc.).
# Only when this flips True does the WO move to the In Progress column.
x_fc_is_release_ready = fields.Boolean(
string='Release-Ready',
compute='_compute_is_release_ready',
store=False,
help='True when every required field for this WO\'s kind is filled '
'(operator + per-kind equipment). Used by the Manager Desk to '
'keep half-set WOs visible in the Unassigned column.',
)
x_fc_missing_for_release = fields.Char(
string='Missing to Release',
compute='_compute_is_release_ready',
store=False,
help='Comma-list of fields the manager still needs to set before '
'this WO can be released to the operator.',
)
x_fc_bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', tracking=True,
)
@@ -609,6 +627,32 @@ class MrpWorkorder(models.Model):
wo.x_fc_requires_bath = kind == 'wet'
wo.x_fc_requires_oven = kind == 'bake'
@api.depends('x_fc_assigned_user_id', 'x_fc_bath_id', 'x_fc_tank_id',
'x_fc_oven_id', 'x_fc_rack_id', 'x_fc_masking_material',
'x_fc_wo_kind')
def _compute_is_release_ready(self):
"""A WO is release-ready when the manager has set EVERY field
button_start would block on. Used by the Manager Desk to keep
half-set WOs in the Unassigned column instead of jumping them
to In Progress as soon as a worker is picked.
"""
for wo in self:
missing = []
if not wo.x_fc_assigned_user_id:
missing.append('Operator')
kind = wo.x_fc_wo_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')
elif kind == 'mask':
if not wo.x_fc_masking_material: missing.append('Masking material')
wo.x_fc_is_release_ready = not missing
wo.x_fc_missing_for_release = ', '.join(missing)
@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

View File

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

View File

@@ -277,6 +277,25 @@ class FpCertificate(models.Model):
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
'states which standard the work meets.'
) % {'name': rec.name or rec.display_name})
# Aerospace / Nadcap customers: actual thickness readings
# must be on file BEFORE the cert is issued. The flag lives
# on the partner so commercial customers aren't blocked.
if (rec.partner_id
and 'x_fc_strict_thickness_required' in rec.partner_id._fields
and rec.partner_id.x_fc_strict_thickness_required
and rec.certificate_type == 'coc'):
if not rec.thickness_reading_ids:
raise UserError(_(
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
'requires actual thickness readings on every CoC '
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
'against MO %(mo)s via the Tablet Station before '
'issuing.'
) % {
'name': rec.name or rec.display_name,
'cust': rec.partner_id.name,
'mo': rec.production_id.name if rec.production_id else '?',
})
rec.state = 'issued'
rec.message_post(body=_('Certificate issued.'))

View File

@@ -39,3 +39,12 @@ class ResPartner(models.Model):
help='Attach the BoL PDF to the shipping confirmation email. '
'Usually only for customers that invoice freight separately.',
)
x_fc_strict_thickness_required = fields.Boolean(
string='Require Thickness Readings on CoC',
default=False, tracking=True,
help='Aerospace / Nadcap customers expect every CoC to carry '
'actual Fischerscope readings (not just "meets spec"). When '
'this is on, action_issue() blocks until at least one '
'thickness reading has been logged for the MO. Leave off '
'for commercial customers.',
)

View File

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

View File

@@ -28,8 +28,28 @@ class SaleOrder(models.Model):
self.payment_term_id = default.payment_term_id
def action_confirm(self):
"""Override to check account hold and trigger invoice strategy."""
"""Override to check account hold + customer PO# and trigger
the invoice strategy."""
for order in self:
# --- Customer PO# required ---
# Aerospace AP teams reject invoices without their PO#
# quoted back. Catching this at SO confirm prevents the
# whole downstream chain (CoC, BoL, invoice) from going
# out unreferenced. The PO# is on `client_order_ref`
# (Odoo standard) AND mirrored to `x_fc_po_number`
# (FP-specific) — accept either as filled.
po_set = bool(order.client_order_ref) or bool(
getattr(order, 'x_fc_po_number', False)
)
if not po_set:
raise UserError(_(
'Cannot confirm SO "%(so)s" — Customer PO# is required.\n\n'
'Set the customer\'s purchase order number in the '
'"Customer Reference" field (or x_fc_po_number) before '
'confirming. Aerospace customers\' AP teams reject '
'invoices that don\'t quote their PO# back.'
) % {'so': order.name})
# --- Account hold check ---
if order.partner_id.x_fc_account_hold:
is_manager = self.env.user.has_group(

View File

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

View File

@@ -159,7 +159,20 @@ class FpDelivery(models.Model):
self.write({'state': 'scheduled'})
def action_start_route(self):
"""Block "en route" until at least a driver is assigned.
Vehicle is encouraged but not strictly required (some shops
let drivers grab whatever vehicle is open at the dock). Driver
is non-negotiable — without it the chain-of-custody hand-off
has no signed party and the POD can't be linked to a person.
"""
for rec in self:
if not rec.assigned_driver_id:
raise UserError(_(
'Cannot mark delivery "%(name)s" en route — no driver '
'assigned.\n\nPick a driver on the delivery (or wait for '
'the auto-prefill to find one) before tapping Start Route.'
) % {'name': rec.name or rec.display_name})
rec.write({'state': 'en_route'})
rec._log_custody_event(
'loaded_on_vehicle',

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.1.2.0',
'version': '19.0.1.3.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -50,10 +50,15 @@ class FpQualityHold(models.Model):
('other', 'Other'),
],
string='Hold Reason',
default='other',
required=True,
tracking=True,
help='Required so QA can triage holds by category.',
)
description = fields.Text(
string='Description',
required=True,
help='Required — every hold needs an inspector narrative.',
)
description = fields.Text(string='Description')
attachment_ids = fields.Many2many(
'ir.attachment',
string='Attachments',

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.2.0.0',
'version': '19.0.2.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -108,12 +108,32 @@ class FpReceiving(models.Model):
rec.received_date = fields.Datetime.now()
def action_accept(self):
"""Accept the receiving — parts match and condition is OK."""
"""Accept the receiving — parts match and condition is OK.
Quantity-mismatch policy: if expected_qty != received_qty,
operators must use action_flag_discrepancy() instead. Managers
can override (the override is logged on chatter for audit).
"""
is_manager = self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
)
for rec in self:
if rec.state not in ('inspecting', 'resolved'):
raise UserError(_('Can only accept from Inspecting or Resolved state.'))
if rec.unresolved_damage_count > 0:
raise UserError(_('Cannot accept — there are %d unresolved damage entries.') % rec.unresolved_damage_count)
qty_match = rec.expected_qty > 0 and rec.received_qty == rec.expected_qty
if not qty_match:
if not is_manager:
raise UserError(_(
'Cannot accept — quantity mismatch (expected %(exp)d, '
'received %(rcv)d).\n\nUse "Flag Discrepancy" instead, '
'or have a manager override.'
) % {'exp': rec.expected_qty, 'rcv': rec.received_qty})
rec.message_post(body=_(
'Manager override: accepted with quantity mismatch '
'(expected %(exp)d, received %(rcv)d).'
) % {'exp': rec.expected_qty, 'rcv': rec.received_qty})
rec.state = 'accepted'
rec._update_so_receiving_status()
rec.message_post(body=_('Parts accepted — quantity: %d, all checks passed.') % rec.received_qty)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.14.3.0',
'version': '19.0.14.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -61,25 +61,26 @@ class FpManagerDashboardController(http.Controller):
# effectively read-only.
has_assign = 'x_fc_assigned_user_id' in MrpWO._fields
# ---- Column 1: Unassigned (no worker on an active WO) ----------
# 'not in (done, cancel)' rather than an explicit allow-list so
# we catch every active state Odoo emits — including 'blocked'
# (predecessor not done yet). The previous allow-list missed
# 'blocked' and left the column empty for entire MO routings
# whose first WO was still running.
# ---- Column 1: Unassigned ("Setup Pending") --------------------
# A WO stays here until the manager has set EVERY field
# button_start would block on (operator + per-kind equipment).
# Without this, picking a worker would auto-jump the row to
# "In Progress" before bath/tank/oven/rack/material are set.
# We compute release-readiness in Python after the SQL search
# because x_fc_is_release_ready is a non-stored compute.
ACTIVE_NEG_STATES = ('done', 'cancel')
domain_unassigned = [
('state', 'not in', ACTIVE_NEG_STATES),
]
if has_assign:
domain_unassigned.append(('x_fc_assigned_user_id', '=', False))
else:
# Without the assignment field, treat ALL active WOs as unassigned
pass
domain_active_states = [('state', 'not in', ACTIVE_NEG_STATES)]
if facility_id:
domain_unassigned.append(
domain_active_states.append(
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
unassigned_wos = MrpWO.search(domain_unassigned, order='sequence, id')
all_active_wos = MrpWO.search(domain_active_states, order='sequence, id')
# Split: not-release-ready → Unassigned/Setup column; rest → In Progress
if 'x_fc_is_release_ready' in MrpWO._fields:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_is_release_ready)
elif has_assign:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_assigned_user_id)
else:
unassigned_wos = all_active_wos
# Roll up to MO level
def _group_by_mo(wos):
@@ -135,6 +136,43 @@ class FpManagerDashboardController(http.Controller):
w.x_fc_work_role_id.name or ''
if w.x_fc_work_role_id else ''
),
# WO kind classification + what's still missing
# before the WO can be released to the operator.
# Manager Desk uses these to render the kind
# badge and the "needs: bath, tank" hint chips.
'wo_kind': (
w.x_fc_wo_kind
if 'x_fc_wo_kind' in w._fields else 'other'
),
'wo_kind_label': dict(
w._fields['x_fc_wo_kind'].selection
).get(w.x_fc_wo_kind, '') if 'x_fc_wo_kind' in w._fields else '',
'is_release_ready': (
w.x_fc_is_release_ready
if 'x_fc_is_release_ready' in w._fields else False
),
'missing_for_release': (
w.x_fc_missing_for_release or ''
if 'x_fc_missing_for_release' in w._fields else ''
),
# Surface oven, rack, masking material so the
# manager can see at a glance what's set.
'oven': (
w.x_fc_oven_id.name or ''
if 'x_fc_oven_id' in w._fields and w.x_fc_oven_id
else ''
),
'rack': (
w.x_fc_rack_id.name or ''
if 'x_fc_rack_id' in w._fields and w.x_fc_rack_id
else ''
),
'masking_material': (
dict(w._fields['x_fc_masking_material'].selection).get(
w.x_fc_masking_material, ''
) if 'x_fc_masking_material' in w._fields and w.x_fc_masking_material
else ''
),
}
for w in wos
],
@@ -145,20 +183,15 @@ class FpManagerDashboardController(http.Controller):
mo = Production.browse(mo_id)
unassigned_cards.append(_mo_card(mo, wos))
# ---- Column 2: In Progress (MOs with at least one active WO) ----
# Same widening as the unassigned domain — capture every active
# state. Without 'blocked' in the set, an MO whose only running
# WO is currently blocked-waiting-on-predecessor disappears from
# the column even though the assigned worker is still on point.
domain_active = [
('state', 'not in', ACTIVE_NEG_STATES),
]
if has_assign:
domain_active.append(('x_fc_assigned_user_id', '!=', False))
if facility_id:
domain_active.append(
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
active_wos = MrpWO.search(domain_active, order='sequence, id')
# ---- Column 2: In Progress -------------------------------------
# Release-ready WOs (everything the manager needed to set is
# filled in) — operator can tap Start on the iPad.
if 'x_fc_is_release_ready' in MrpWO._fields:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_is_release_ready)
elif has_assign:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_assigned_user_id)
else:
active_wos = MrpWO # empty
active_cards = []
for mo_id, wos in _group_by_mo(active_wos).items():
mo = Production.browse(mo_id)

View File

@@ -507,6 +507,20 @@
&.o_fp_chip_warning { @include fp-pill(--bs-warning); }
&.o_fp_chip_danger { @include fp-pill(--bs-danger); }
&.o_fp_chip_muted { background-color: $fp-card-soft; color: $fp-ink-mute; }
// WO-kind colour bands so the manager can spot
// mask vs wet vs bake at a glance.
&.o_fp_chip_kind {
text-transform: none;
letter-spacing: normal;
font-weight: $fp-weight-bold;
}
&.o_fp_chip_kind_wet { background-color: rgba(13, 110, 253, .15); color: #0d6efd; }
&.o_fp_chip_kind_bake { background-color: rgba(220, 53, 69, .15); color: #dc3545; }
&.o_fp_chip_kind_mask { background-color: rgba(255, 193, 7, .20); color: #997404; }
&.o_fp_chip_kind_rack { background-color: rgba(108, 117, 125, .15); color: #495057; }
&.o_fp_chip_kind_inspect { background-color: rgba(25, 135, 84, .15); color: #198754; }
&.o_fp_chip_kind_other { background-color: $fp-card-soft; color: $fp-ink-mute; }
}

View File

@@ -135,11 +135,23 @@
<t t-foreach="card.wos" t-as="wo" t-key="wo.id">
<div class="o_fp_mgr_wo_row">
<div class="o_fp_mgr_wo_info">
<t t-esc="wo.name"/>
<span class="text-muted ms-2">
<div>
<span t-attf-class="o_fp_chip o_fp_chip_kind o_fp_chip_kind_{{ wo.wo_kind }}"
t-esc="wo.wo_kind_label || wo.wo_kind"/>
<strong class="ms-1" t-esc="wo.name"/>
</div>
<div class="text-muted small mt-1">
<t t-esc="wo.workcenter"/>
<t t-if="wo.bath"> · <t t-esc="wo.bath"/></t>
</span>
<t t-if="wo.role_name"> · <i class="fa fa-id-badge"/> <t t-esc="wo.role_name"/></t>
<t t-if="wo.bath"> · <i class="fa fa-flask"/> <t t-esc="wo.bath"/></t>
<t t-if="wo.oven"> · <i class="fa fa-fire"/> <t t-esc="wo.oven"/></t>
<t t-if="wo.rack"> · <i class="fa fa-th"/> <t t-esc="wo.rack"/></t>
<t t-if="wo.masking_material"> · <i class="fa fa-tag"/> <t t-esc="wo.masking_material"/></t>
</div>
<div t-if="wo.missing_for_release"
class="o_fp_chip o_fp_chip_warning mt-1">
<i class="fa fa-exclamation-circle"/> Needs: <t t-esc="wo.missing_for_release"/>
</div>
</div>
<select class="o_fp_mgr_picker"
t-on-change="(ev) => this.onAssignWorker(wo, ev.target.value)">
@@ -154,7 +166,8 @@
</option>
</t>
</select>
<select class="o_fp_mgr_picker"
<select t-if="wo.wo_kind === 'wet'"
class="o_fp_mgr_picker"
t-on-change="(ev) => this.onAssignTank(wo, ev.target.value)">
<option value="">— Tank —</option>
<t t-foreach="state.overview.tanks" t-as="tnk" t-key="tnk.id">
@@ -170,7 +183,7 @@
</button>
<button class="btn"
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
Open
Open WO
</button>
</div>
</t>

View File

@@ -0,0 +1,463 @@
# -*- coding: utf-8 -*-
"""Workflow enforcement audit.
For each workflow transition (SO confirm, MO confirm, WO start, WO finish,
delivery delivered, invoice post, NCR close, CAPA close, discharge close,
cert issue), tries to perform it with MISSING data and reports whether
the system blocks (PASS) or lets it through silently (GAP).
Each test is wrapped in a SAVEPOINT so the DB is unchanged.
"""
from datetime import datetime
env = env # noqa
from odoo import fields # noqa
RESULTS = []
def gate(label, fn, expect_keywords):
"""Run fn() in a savepoint. PASS = raises UserError mentioning one
of expect_keywords. GAP = succeeds silently. ERR = unexpected error."""
sp = f'gate_{abs(hash(label))}'
env.cr.execute(f'SAVEPOINT {sp}')
fired = False
msg = ''
err = ''
try:
fn()
except Exception as e:
msg = str(e).splitlines()[0][:140]
low = str(e).lower()
fired = any(k.lower() in low for k in expect_keywords)
if not fired and 'NotNullViolation' not in str(type(e).__name__):
err = str(e).splitlines()[0][:140]
finally:
env.cr.execute(f'ROLLBACK TO SAVEPOINT {sp}')
if err and not fired:
verdict = 'ERR '
elif fired:
verdict = 'PASS'
else:
verdict = 'GAP '
RESULTS.append((verdict, label, msg))
sym = {'PASS': '', 'GAP ': '', 'ERR ': '?'}[verdict]
print(f' {sym} {verdict} [{label:<60}] {msg[:80]}')
def section(title):
print(f'\n{"="*78}\n {title}\n{"="*78}')
# Setup: pick existing fixtures
customer = env['res.partner'].search([('is_company', '=', True)], limit=1)
fac = env['fusion.plating.facility'].search([('active', '=', True)], limit=1)
coating = env['fp.coating.config'].search([], limit=1)
mo_done = env['mrp.production'].search([('state', '=', 'done')], order='id desc', limit=1)
mo_progress = env['mrp.production'].search([('state', 'in', ('confirmed', 'progress'))], limit=1)
# =====================================================================
section('1. Sale Order — quote → confirmation')
# =====================================================================
def t_so_no_partner():
env['sale.order'].sudo().create({})
gate('SO create without partner', t_so_no_partner,
['partner', 'required', 'NotNull'])
def t_so_confirm_account_hold():
p = customer.copy({'name': f'AcctHold {datetime.now().timestamp()}', 'x_fc_account_hold': True})
so = env['sale.order'].sudo().create({
'partner_id': p.id,
'order_line': [(0, 0, {'name': 'svc', 'price_unit': 100,
'product_id': env['product.product'].search([('default_code', '=', 'FP-SERVICE')], limit=1).id or False})],
})
so.with_user(env.ref('base.user_demo', raise_if_not_found=False) or env.user).action_confirm()
gate('SO confirm blocked when customer on Account Hold', t_so_confirm_account_hold,
['Account Hold', 'account hold'])
# =====================================================================
section('2. Manufacturing Order — confirm + done')
# =====================================================================
def t_mo_confirm_no_facility():
saved = env.company.x_fc_default_facility_id
env.company.sudo().x_fc_default_facility_id = False
facs = env['fusion.plating.facility'].search([('active', '=', True)])
facs.sudo().write({'active': False})
try:
product = env['product.product'].search([], limit=1)
m = env['mrp.production'].sudo().create({
'product_id': product.id, 'product_qty': 1,
'company_id': env.company.id,
})
m.action_confirm()
finally:
facs.sudo().write({'active': True})
env.company.sudo().x_fc_default_facility_id = saved
gate('MO confirm blocked when no facility resolvable', t_mo_confirm_no_facility,
['facility'])
# =====================================================================
section('3. Work Order — start (per kind) + finish')
# =====================================================================
if mo_progress:
test_wos = mo_progress.workorder_ids[:5]
if test_wos:
wo = test_wos[0]
def t_wo_start_no_operator():
saved = wo.x_fc_assigned_user_id.id
wo.sudo().x_fc_assigned_user_id = False
try:
wo.sudo().button_start()
finally:
wo.sudo().x_fc_assigned_user_id = saved
gate('WO start blocked without assigned operator', t_wo_start_no_operator,
['Assigned Operator', 'operator'])
# Find a wet WO + bake WO + rack WO + mask WO from any MO
def find_wo(kind, state=None):
for mo in env['mrp.production'].search([], order='id desc', limit=20):
for w in mo.workorder_ids:
if hasattr(w, '_fp_classify_kind') and w._fp_classify_kind() == kind:
if state is None or w.state == state:
return w
return None
wet_wo = find_wo('wet')
bake_wo = find_wo('bake')
rack_wo = find_wo('rack')
mask_wo = find_wo('mask')
if wet_wo:
def t_wet_no_bath():
saved_b, saved_t = wet_wo.x_fc_bath_id.id, wet_wo.x_fc_tank_id.id
wet_wo.sudo().write({'x_fc_bath_id': False, 'x_fc_tank_id': False})
try:
wet_wo.sudo().button_start()
finally:
wet_wo.sudo().write({'x_fc_bath_id': saved_b, 'x_fc_tank_id': saved_t})
gate('WO[wet] start blocked without bath+tank', t_wet_no_bath,
['Bath', 'Tank'])
if bake_wo:
def t_bake_no_oven():
saved = bake_wo.x_fc_oven_id.id
bake_wo.sudo().x_fc_oven_id = False
try:
bake_wo.sudo().button_start()
finally:
bake_wo.sudo().x_fc_oven_id = saved
gate('WO[bake] start blocked without oven', t_bake_no_oven, ['Oven'])
def t_bake_finish_no_actuals():
# Already started? Need to be in 'progress' state to finish
if bake_wo.state == 'progress':
saved_t, saved_d = bake_wo.x_fc_bake_temp, bake_wo.x_fc_bake_duration_hours
bake_wo.sudo().write({'x_fc_bake_temp': 0, 'x_fc_bake_duration_hours': 0})
try:
bake_wo.sudo().button_finish()
finally:
bake_wo.sudo().write({'x_fc_bake_temp': saved_t,
'x_fc_bake_duration_hours': saved_d})
else:
raise Exception('bake WO not in progress, cannot test finish')
gate('WO[bake] finish blocked without temp+duration+chart_recorder',
t_bake_finish_no_actuals,
['Bake Temp', 'Bake Duration', 'Chart Recorder', 'progress'])
if rack_wo:
def t_rack_no_rack():
saved = rack_wo.x_fc_rack_id.id
rack_wo.sudo().x_fc_rack_id = False
try:
rack_wo.sudo().button_start()
finally:
rack_wo.sudo().x_fc_rack_id = saved
gate('WO[rack] start blocked without rack/fixture', t_rack_no_rack, ['Rack', 'Fixture'])
if mask_wo:
def t_mask_no_material():
saved = mask_wo.x_fc_masking_material
mask_wo.sudo().x_fc_masking_material = False
try:
mask_wo.sudo().button_start()
finally:
mask_wo.sudo().x_fc_masking_material = saved
gate('WO[mask] start blocked without masking material', t_mask_no_material,
['Masking Material'])
# =====================================================================
section('4. Receiving — accept/discrepancy with damage')
# =====================================================================
if customer:
so_for_recv = env['sale.order'].search([('partner_id', '=', customer.id)], limit=1)
if so_for_recv:
def t_recv_accept_with_unresolved_damage():
r = env['fp.receiving'].sudo().create({
'sale_order_id': so_for_recv.id,
'expected_qty': 5, 'received_qty': 5,
})
env['fp.receiving.damage'].sudo().create({
'receiving_id': r.id,
'description': 'Test damage',
'resolved': False,
})
r.action_start_inspection()
r.action_accept() # should fail — unresolved damage
gate('Receiving accept blocked when unresolved damage exists',
t_recv_accept_with_unresolved_damage,
['unresolved damage'])
# =====================================================================
section('5. Certificate — action_issue')
# =====================================================================
if mo_done:
def t_cert_issue_no_spec():
c = env['fp.certificate'].sudo().create({
'partner_id': customer.id,
'production_id': mo_done.id,
'certificate_type': 'coc',
'spec_reference': False,
})
c.action_issue()
gate('Cert issue blocked without spec_reference', t_cert_issue_no_spec,
['Spec', 'spec_reference'])
# =====================================================================
section('6. Delivery — schedule → en_route → delivered')
# =====================================================================
if customer:
def t_dlv_delivered_no_pod():
d = env['fusion.plating.delivery'].sudo().create({
'partner_id': customer.id, 'state': 'en_route',
'company_id': env.company.id,
})
d.action_mark_delivered()
gate('Delivery mark_delivered blocked without POD', t_dlv_delivered_no_pod,
['POD', 'Proof of Delivery'])
# =====================================================================
section('7. Invoice — post')
# =====================================================================
if customer:
def t_inv_post_no_terms():
saved = 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': 'x', 'quantity': 1, 'price_unit': 1})],
})
i.invoice_payment_term_id = False
i.action_post()
finally:
customer.sudo().property_payment_term_id = saved
gate('Invoice post blocked without payment terms', t_inv_post_no_terms,
['payment term'])
def t_inv_post_account_hold():
p = customer.copy({'name': f'Hold-{datetime.now().timestamp()}',
'x_fc_account_hold': True})
i = env['account.move'].sudo().create({
'move_type': 'out_invoice',
'partner_id': p.id,
'invoice_date': fields.Date.today(),
'invoice_payment_term_id': env.ref('account.account_payment_term_30days', raise_if_not_found=False).id if env.ref('account.account_payment_term_30days', raise_if_not_found=False) else False,
'invoice_line_ids': [(0, 0, {'name': 'x', 'quantity': 1, 'price_unit': 1})],
})
i.with_user(env.ref('base.user_admin').id).action_post()
gate('Invoice post blocked when customer on Account Hold', t_inv_post_account_hold,
['Account Hold', 'account hold'])
# =====================================================================
section('8. QMS — NCR / CAPA / Discharge sample close')
# =====================================================================
def t_ncr_close_missing():
n = env['fusion.plating.ncr'].sudo().create({
'facility_id': fac.id,
'description': '', 'containment': '', 'root_cause': '',
'disposition': False,
})
n.action_close()
gate('NCR close blocked without RC/containment/disposition', t_ncr_close_missing,
['Root Cause', 'Containment', 'Disposition'])
def t_capa_close_missing():
c = env['fusion.plating.capa'].sudo().create({
'description': '', 'root_cause_analysis': '', 'action_plan': '',
})
c.action_close()
gate('CAPA close blocked without RCA/plan/verification', t_capa_close_missing,
['Root Cause Analysis', 'Action Plan', 'Verification'])
def t_discharge_close_missing():
s = env['fusion.plating.discharge.sample'].sudo().create({
'facility_id': fac.id,
})
s.action_close()
gate('Discharge sample close blocked without lab evidence', t_discharge_close_missing,
['Lab Report', 'Results Received', 'parameter', 'Lab certificate'])
# =====================================================================
section('9. SUSPECTED GAPS — these probably AREN\'T enforced today')
# =====================================================================
# Each of these MIGHT slip through silently. If they do, we'll see GAP.
# 9a. SO confirm without customer PO#
def t_so_confirm_no_po():
p = customer.copy({'name': f'NoPO-{datetime.now().timestamp()}'})
so = env['sale.order'].sudo().create({
'partner_id': p.id,
'order_line': [(0, 0, {'name': 'svc', 'price_unit': 100,
'product_id': env['product.product'].search([('default_code', '=', 'FP-SERVICE')], limit=1).id})],
})
# client_order_ref intentionally empty
so.action_confirm()
gate('SO confirm blocked without customer PO# (client_order_ref)',
t_so_confirm_no_po,
['PO', 'client_order_ref', 'customer reference'])
# 9b. Receiving accept with qty mismatch
if customer:
def t_recv_accept_qty_mismatch():
so = env['sale.order'].search([('partner_id', '=', customer.id)], limit=1)
if not so:
raise Exception('no SO available')
r = env['fp.receiving'].sudo().create({
'sale_order_id': so.id,
'expected_qty': 10, 'received_qty': 7, # short!
})
r.action_start_inspection()
r.action_accept() # should this be allowed with qty mismatch?
gate('Receiving accept blocked when qty mismatch (expected != received)',
t_recv_accept_qty_mismatch,
['mismatch', 'short', 'discrepancy', 'qty', 'quantity'])
# 9c. MO done without all WOs done — Odoo enforces this natively, so should PASS
# Skip — Odoo handles it.
# 9d. Cert issue without thickness readings (only blocks when partner
# is flagged aerospace via x_fc_strict_thickness_required)
if mo_done:
def t_cert_issue_no_readings_aero():
# Flag the customer as aerospace for the test, then unflag
saved = customer.x_fc_strict_thickness_required
customer.sudo().x_fc_strict_thickness_required = True
try:
c = env['fp.certificate'].sudo().create({
'partner_id': customer.id,
'production_id': mo_done.id,
'certificate_type': 'coc',
'spec_reference': 'AMS 2404',
})
c.action_issue()
finally:
customer.sudo().x_fc_strict_thickness_required = saved
gate('Cert issue blocked without thickness readings (aerospace customer)',
t_cert_issue_no_readings_aero,
['thickness', 'reading', 'Nadcap'])
# 9e. Delivery start_route without driver
if customer:
def t_dlv_start_no_driver():
d = env['fusion.plating.delivery'].sudo().create({
'partner_id': customer.id, 'state': 'scheduled',
'company_id': env.company.id,
})
# No driver, no vehicle
d.action_start_route()
gate('Delivery start_route blocked without driver',
t_dlv_start_no_driver,
['driver', 'vehicle'])
# 9f. WO finish for inspection WO without thickness readings logged
inspect_wo = find_wo('inspect')
if inspect_wo and inspect_wo.state == 'progress':
def t_inspect_finish_no_readings():
# Wipe all readings linked to this MO
readings = env['fp.thickness.reading'].sudo().search([
('production_id', '=', inspect_wo.production_id.id),
])
readings.unlink()
inspect_wo.sudo().button_finish()
gate('WO[inspect] finish blocked without any thickness readings',
t_inspect_finish_no_readings,
['thickness', 'reading'])
# 9g. Bath log create without any readings
bath = env['fusion.plating.bath'].search([], limit=1)
if bath:
def t_bath_log_no_lines():
env['fusion.plating.bath.log'].sudo().create({
'bath_id': bath.id,
}) # no line_ids — should this be allowed?
gate('Bath log create blocked without any parameter readings',
t_bath_log_no_lines,
['line', 'reading', 'parameter'])
# 9h. Quality hold without inspector / reason
def t_hold_no_data():
env['fusion.plating.quality.hold'].sudo().create({
# All optional except partner_id?
})
gate('Quality hold create requires partner+reason+description',
t_hold_no_data,
['partner', 'reason', 'description', 'NotNull'])
# =====================================================================
section('SUMMARY')
# =====================================================================
passed = sum(1 for v, _, _ in RESULTS if v == 'PASS')
gaps = sum(1 for v, _, _ in RESULTS if v == 'GAP ')
errs = sum(1 for v, _, _ in RESULTS if v == 'ERR ')
total = len(RESULTS)
print(f'\n {passed} ENFORCED / {gaps} GAPS / {errs} ERR (out of {total} checks)')
if gaps:
print('\n Gates that DON\'T fire today (potential enforcement gaps):')
for v, label, msg in RESULTS:
if v == 'GAP ':
print(f'{label}')
if errs:
print('\n Gates that errored unexpectedly (test setup issue or new bug):')
for v, label, msg in RESULTS:
if v == 'ERR ':
print(f' ? {label}: {msg}')