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:
@@ -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': """
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
463
fusion_plating/scripts/fp_enforcement_audit.py
Normal file
463
fusion_plating/scripts/fp_enforcement_audit.py
Normal 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}')
|
||||
Reference in New Issue
Block a user