feat(jobs+certs): milestone-cascade Phase 1 + session patch catch-up

Implements the milestone-cascade design (Phase 1) and catches the
fusion_plating_jobs / fusion_plating_certificates source up to entech.

Milestone cascade (this PR's core):
- fp.job: new computes all_steps_terminal, next_milestone_action,
  next_milestone_label; dispatcher action_advance_next_milestone with
  3 helpers (_action_open_draft_certs, _action_open_draft_delivery,
  _action_mark_active_delivery_delivered); _resolve_required_cert_types
  resolver; _fp_create_certificates rewritten to honour
  part.certificate_requirement + partner flags + loop over resolved
  cert types
- fp.job.workflow.state: new trigger_on_delivery_state Boolean;
  _fp_is_passed_for_job extended with delivery-state branch;
  Shipped state seed reroutes from default_kind=ship to the new trigger
- View: hide Finish & Next when all_steps_terminal; add 4 mutually-
  exclusive milestone buttons (Mark Job Done / Issue Certs / Schedule
  Delivery / Mark Shipped) bound to one dispatcher
- Cert gate (fusion_plating_certificates/models/fp_delivery.py):
  action_mark_delivered hard-blocks on draft certs; manager bypass
  via fp_skip_cert_gate=True context key
- 24 unit tests in test_fp_job_milestone_cascade.py covering computes,
  resolver, dispatcher, cert gate
- Spec: docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md
- Plan: docs/superpowers/plans/2026-05-12-job-milestone-cascade.md

Other entech changes caught up in this sync (from earlier session
patches not previously committed):
- fp.job version bump series 18.x → 19.0
- res_users_views.xml addition (signature widget in user prefs)
- racking inspection smart button removal
- various view/manifest touch-ups

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-11 22:40:25 -04:00
parent 1c1f517847
commit 913311653f
10 changed files with 755 additions and 106 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Certificates', 'name': 'Fusion Plating — Certificates',
'version': '19.0.5.4.0', 'version': '19.0.5.5.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """ 'description': """
@@ -27,6 +27,7 @@ Includes Fischerscope thickness measurement data capture.
'fusion_plating_portal', 'fusion_plating_portal',
'fusion_plating_batch', 'fusion_plating_batch',
'fusion_plating_configurator', 'fusion_plating_configurator',
'fusion_plating_logistics',
'sale_management', 'sale_management',
], ],
'data': [ 'data': [

View File

@@ -7,3 +7,4 @@ from . import fp_thickness_reading
from . import fp_certificate from . import fp_certificate
from . import res_config_settings from . import res_config_settings
from . import res_partner from . import res_partner
from . import fp_delivery

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Cert-aware extension of fusion.plating.delivery.
Hard-blocks action_mark_delivered when the linked job still has any
draft certificate (CoC or Thickness Report). AS9100 / Nadcap
compliance: parts can't ship without paperwork.
Manager bypass: pass context key `fp_skip_cert_gate=True` (matches
the existing bypass convention on fp.job.button_mark_done).
"""
from odoo import _, models
from odoo.exceptions import UserError
class FusionPlatingDelivery(models.Model):
_inherit = 'fusion.plating.delivery'
def action_mark_delivered(self):
if not self.env.context.get('fp_skip_cert_gate'):
Cert = self.env.get('fp.certificate')
Job = self.env.get('fp.job')
if Cert is not None and Job is not None:
for delivery in self:
if not delivery.job_ref:
continue
job = Job.search(
[('name', '=', delivery.job_ref)], limit=1,
)
if not job:
continue
dom = [('state', '=', 'draft')]
if 'x_fc_job_id' in Cert._fields:
dom.append(('x_fc_job_id', '=', job.id))
elif (job.sale_order_id
and 'sale_order_id' in Cert._fields):
dom.append((
'sale_order_id', '=', job.sale_order_id.id,
))
else:
continue
draft_certs = Cert.search(dom)
if draft_certs:
raise UserError(_(
'Cannot mark delivery %(d)s shipped — job '
'%(j)s still has %(n)d draft '
'certificate(s) (%(types)s). Issue them '
'first, or pass fp_skip_cert_gate=True '
'context key to bypass.'
) % {
'd': delivery.name or delivery.id,
'j': job.name,
'n': len(draft_certs),
'types': ', '.join(sorted(set(
draft_certs.mapped('certificate_type')
))),
})
return super().action_mark_delivered()

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.20.6', 'version': '19.0.8.19.6',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',
@@ -20,10 +20,10 @@ Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of
the migration spec dated 2026-04-25) to the rest of the Fusion Plating the migration spec dated 2026-04-25) to the rest of the Fusion Plating
module family — configurator, portal, logistics, quality, certificates. module family — configurator, portal, logistics, quality, certificates.
As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only Coexists with fusion_plating_bridge_mrp during the migration period.
fulfilment path. SO confirm always creates fp.job records here. The Activate native jobs via the x_fc_use_native_jobs settings flag (default:
former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0 False). When False, SO confirm continues to create mrp.production records
once the legacy fallback became unreachable. through bridge_mrp. When True, SO confirm creates fp.job records here.
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel 19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
OWL/controller stack (job_process_tree, job_plant_overview, OWL/controller stack (job_process_tree, job_plant_overview,
@@ -57,6 +57,7 @@ full design rationale and §6.2 of the implementation plan for task list.
# so the statusbar's m2o has its targets available at view-render time). # so the statusbar's m2o has its targets available at view-render time).
'data/fp_workflow_state_data.xml', 'data/fp_workflow_state_data.xml',
'views/fp_workflow_state_views.xml', 'views/fp_workflow_state_views.xml',
'views/res_config_settings_views.xml',
'views/fp_job_step_quick_look_views.xml', 'views/fp_job_step_quick_look_views.xml',
'views/fp_job_form_inherit.xml', 'views/fp_job_form_inherit.xml',
'views/fp_job_quality_buttons.xml', 'views/fp_job_quality_buttons.xml',
@@ -66,6 +67,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'views/fp_step_priority_views.xml', 'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml', 'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml', 'views/legacy_menu_hide.xml',
'views/res_users_views.xml',
'wizards/fp_job_step_move_wizard_views.xml', 'wizards/fp_job_step_move_wizard_views.xml',
'wizards/fp_job_step_input_wizard_views.xml', 'wizards/fp_job_step_input_wizard_views.xml',
'report/report_fp_job_sticker.xml', 'report/report_fp_job_sticker.xml',

View File

@@ -61,8 +61,8 @@
<field name="code">shipped</field> <field name="code">shipped</field>
<field name="sequence">60</field> <field name="sequence">60</field>
<field name="color">success</field> <field name="color">success</field>
<field name="trigger_default_kinds">ship</field> <field name="trigger_on_delivery_state" eval="True"/>
<field name="description">Shipment confirmed (BOL or carrier pickup). Customer can be notified.</field> <field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
</record> </record>
<record id="workflow_state_done" model="fp.job.workflow.state"> <record id="workflow_state_done" model="fp.job.workflow.state">

View File

@@ -106,6 +106,8 @@ class FpJob(models.Model):
'step_ids.recipe_node_id.default_kind', 'step_ids.recipe_node_id.default_kind',
'step_ids.recipe_node_id.triggers_workflow_state_id', 'step_ids.recipe_node_id.triggers_workflow_state_id',
'quality_hold_count', 'quality_hold_count',
'delivery_id',
'delivery_id.state',
) )
def _compute_workflow_state_id(self): def _compute_workflow_state_id(self):
WS = self.env['fp.job.workflow.state'] WS = self.env['fp.job.workflow.state']
@@ -137,6 +139,210 @@ class FpJob(models.Model):
timelog_count = fields.Integer(compute='_compute_smart_counts') timelog_count = fields.Integer(compute='_compute_smart_counts')
portal_job_count = fields.Integer(compute='_compute_smart_counts') portal_job_count = fields.Integer(compute='_compute_smart_counts')
# ------------------------------------------------------------------
# Milestone cascade (Phase 1) — drives the header-button replacement
# that fires when every recipe step reaches a terminal state. See
# docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md.
# ------------------------------------------------------------------
all_steps_terminal = fields.Boolean(
compute='_compute_all_steps_terminal',
store=True,
help='True ⇔ at least one step exists AND every step is in '
'done/skipped/cancelled. Used to swap the per-step '
'Finish & Next button for a milestone-advance button.',
)
@api.depends('step_ids', 'step_ids.state')
def _compute_all_steps_terminal(self):
for job in self:
if not job.step_ids:
job.all_steps_terminal = False
else:
job.all_steps_terminal = all(
s.state in ('done', 'skipped', 'cancelled')
for s in job.step_ids
)
def _resolve_required_cert_types(self):
"""Set of cert types this job must produce.
Priority: part.certificate_requirement wins; 'inherit' falls
back to partner-level send_coc / send_thickness_report flags.
'none' returns empty (commercial customer, no paperwork).
Unknown requirement codes default to {'coc'} as a safety net.
"""
self.ensure_one()
req = (
self.part_catalog_id
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
types = set()
if self.partner_id.x_fc_send_coc:
types.add('coc')
if self.partner_id.x_fc_send_thickness_report:
types.add('thickness_report')
return types
return {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'})
next_milestone_action = fields.Selection(
[
('mark_done', 'Mark Job Done'),
('issue_certs', 'Issue Certs'),
('schedule_delivery', 'Schedule Delivery'),
('mark_shipped', 'Mark Shipped'),
('closed', 'Closed'),
],
compute='_compute_next_milestone_action',
help='What the manager should click next once steps complete. '
'Drives the milestone-advance buttons on the form header. '
'False/empty while steps are still running.',
)
next_milestone_label = fields.Char(
compute='_compute_next_milestone_action',
help='Human label for the next-action button.',
)
@api.depends(
'all_steps_terminal',
'state',
'delivery_id',
'delivery_id.state',
)
def _compute_next_milestone_action(self):
"""Resolve next action in priority order:
1. NOT all_steps_terminal → False (Finish & Next stays)
2. state != 'done' → mark_done
3. ANY required draft cert → issue_certs
4. NO delivery or draft → schedule_delivery
5. delivery scheduled/transit → mark_shipped
6. otherwise (delivered) → closed
"""
labels = dict(self._fields['next_milestone_action'].selection)
for job in self:
if not job.all_steps_terminal:
job.next_milestone_action = False
job.next_milestone_label = ''
continue
if job.state != 'done':
job.next_milestone_action = 'mark_done'
elif job._fp_has_draft_required_certs():
job.next_milestone_action = 'issue_certs'
elif (not job.delivery_id
or job.delivery_id.state == 'draft'):
job.next_milestone_action = 'schedule_delivery'
elif job.delivery_id.state in ('scheduled', 'in_transit'):
job.next_milestone_action = 'mark_shipped'
else:
job.next_milestone_action = 'closed'
job.next_milestone_label = labels.get(
job.next_milestone_action, ''
)
def _fp_has_draft_required_certs(self):
"""True if at least one cert of a required type is still 'draft'.
Returns False when no certs are required (commercial customers).
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
return False
required = self._resolve_required_cert_types()
if not required:
return False
Cert = self.env['fp.certificate']
dom = [
('certificate_type', 'in', list(required)),
('state', '=', 'draft'),
]
if 'x_fc_job_id' in Cert._fields:
dom.append(('x_fc_job_id', '=', self.id))
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
dom.append(('sale_order_id', '=', self.sale_order_id.id))
else:
return False # can't link safely → don't block the cascade
return bool(Cert.search_count(dom))
def action_advance_next_milestone(self):
"""Single entry point bound to all four milestone header buttons.
Branches on next_milestone_action and delegates to the existing
business-logic method. Never invents new logic — just routes."""
self.ensure_one()
action_map = {
'mark_done': self.button_mark_done,
'issue_certs': self._action_open_draft_certs,
'schedule_delivery': self._action_open_draft_delivery,
'mark_shipped': self._action_mark_active_delivery_delivered,
}
fn = action_map.get(self.next_milestone_action)
if not fn:
raise UserError(_(
'No milestone action available for job %(j)s '
'(next=%(a)s).'
) % {
'j': self.name,
'a': self.next_milestone_action or 'none',
})
return fn()
def _action_open_draft_certs(self):
"""Open the cert list filtered to draft certs for this job.
Manager reviews each in turn and clicks Issue per-cert."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Draft Certificates — %s') % self.name,
'res_model': 'fp.certificate',
'view_mode': 'list,form',
'domain': [
('x_fc_job_id', '=', self.id),
('state', '=', 'draft'),
],
'target': 'current',
}
def _action_open_draft_delivery(self):
"""Open the linked delivery if it's still in draft state.
Falls back to the delivery list filtered to this job's
delivery if the state isn't draft (defensive)."""
self.ensure_one()
if self.delivery_id and self.delivery_id.state == 'draft':
return {
'type': 'ir.actions.act_window',
'name': _('Schedule Delivery — %s') % self.name,
'res_model': 'fusion.plating.delivery',
'res_id': self.delivery_id.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _('Deliveries — %s') % self.name,
'res_model': 'fusion.plating.delivery',
'view_mode': 'list,form',
'domain': [('job_ref', '=', self.name)],
'target': 'current',
}
def _action_mark_active_delivery_delivered(self):
"""Call action_mark_delivered on the linked delivery if it's
in scheduled / in_transit. Posts to job chatter on success."""
self.ensure_one()
if (not self.delivery_id
or self.delivery_id.state not in ('scheduled', 'in_transit')):
raise UserError(_(
'No scheduled or in-transit delivery to mark shipped '
'for %s.'
) % self.name)
self.delivery_id.action_mark_delivered()
self.message_post(body=_(
'Delivery %s marked shipped via milestone cascade.'
) % self.delivery_id.name)
return True
@api.depends( @api.depends(
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids', 'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
'step_ids.time_log_ids', 'origin', 'partner_id', 'step_ids.time_log_ids', 'origin', 'partner_id',
@@ -374,6 +580,15 @@ class FpJob(models.Model):
'fusion_plating_jobs.action_report_fp_job_traveller' 'fusion_plating_jobs.action_report_fp_job_traveller'
).report_action(self) ).report_action(self)
def action_print_sticker(self):
"""Print the 6x4" job-box identification sticker (logo + WO# + QR
+ part / customer / thickness / notes). Used at receiving and at
every move so the box is always identifiable on the floor."""
self.ensure_one()
return self.env.ref(
'fusion_plating_jobs.action_report_fp_job_sticker'
).report_action(self)
def action_print_wo_detail(self): def action_print_wo_detail(self):
"""Print the Steelhead-style Work Order Detail PDF — chronological """Print the Steelhead-style Work Order Detail PDF — chronological
chain-of-custody + per-step inputs + Certified By page. Use this chain-of-custody + per-step inputs + Certified By page. Use this
@@ -1285,93 +1500,102 @@ class FpJob(models.Model):
) )
def _fp_create_certificates(self): def _fp_create_certificates(self):
"""Trigger cert auto-create on job done. """Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running
on a job that already has a CoC won't create another one.
Pre-populates ALL the fields a CoC issuer needs so Tom can hit Each cert is pre-populated with everything action_issue needs
Issue without filling 6 fields first: (partner, spec_reference, part_number, quantity_shipped, po,
- partner_id from job SO link, job link) so the manager just reviews and clicks Issue.
- spec_reference from coating (required by action_issue)
- part_number from part_catalog
- quantity_shipped from job qty (minus scrap)
- po_number from sale_order
- sale_order_id link
- x_fc_job_id link if the field exists
Idempotent — if a cert already exists for this job, skip Replaces the single-CoC implementation: now honours
(prevents dupes when button_mark_done is re-run after a part.certificate_requirement (coc / coc_thickness / none /
manager bypass). inherit) and partner-level send_coc / send_thickness_report
flags. Closes spec gap C-G1.
""" """
self.ensure_one() self.ensure_one()
if 'fp.certificate' not in self.env: if 'fp.certificate' not in self.env:
return return
Cert = self.env['fp.certificate'].sudo() Cert = self.env['fp.certificate'].sudo()
# Idempotency: don't double-create on retry. required = self._resolve_required_cert_types()
existing_dom = [] if not required:
if 'x_fc_job_id' in Cert._fields: return
existing_dom.append(('x_fc_job_id', '=', self.id)) has_job_link = 'x_fc_job_id' in Cert._fields
elif self.sale_order_id and 'sale_order_id' in Cert._fields: coating = self.coating_config_id
existing_dom.append(('sale_order_id', '=', self.sale_order_id.id)) for cert_type in sorted(required):
if existing_dom: # Idempotency per type.
existing = Cert.search(existing_dom, limit=1) existing_dom = [('certificate_type', '=', cert_type)]
if existing: if has_job_link:
_logger.info( existing_dom.append(('x_fc_job_id', '=', self.id))
'Job %s: cert %s already exists, skipping auto-create', elif self.sale_order_id and 'sale_order_id' in Cert._fields:
self.name, existing.name, existing_dom.append(
('sale_order_id', '=', self.sale_order_id.id),
) )
return else:
try: continue # can't safely identify — skip
vals = {'partner_id': self.partner_id.id} if Cert.search_count(existing_dom):
if 'certificate_type' in Cert._fields: continue
vals['certificate_type'] = 'coc' try:
if 'state' in Cert._fields: vals = {
vals['state'] = 'draft' 'partner_id': self.partner_id.id,
# Job + SO links. 'certificate_type': cert_type,
if 'x_fc_job_id' in Cert._fields: }
vals['x_fc_job_id'] = self.id if 'state' in Cert._fields:
elif 'job_id' in Cert._fields: vals['state'] = 'draft'
vals['job_id'] = self.id if has_job_link:
if 'sale_order_id' in Cert._fields and self.sale_order_id: vals['x_fc_job_id'] = self.id
vals['sale_order_id'] = self.sale_order_id.id elif 'job_id' in Cert._fields:
# Pre-fill from coating: the spec_reference is what action_issue vals['job_id'] = self.id
# blocks on — without this every cert needs a manual edit. if 'sale_order_id' in Cert._fields and self.sale_order_id:
coating = self.coating_config_id vals['sale_order_id'] = self.sale_order_id.id
if coating and 'spec_reference' in Cert._fields \ # spec_reference is what action_issue blocks on.
and getattr(coating, 'spec_reference', False): if coating and 'spec_reference' in Cert._fields \
vals['spec_reference'] = coating.spec_reference and getattr(coating, 'spec_reference', False):
# Pre-fill part_number from the part catalog if we have one. vals['spec_reference'] = coating.spec_reference
if 'part_number' in Cert._fields and self.part_catalog_id: if 'part_number' in Cert._fields and self.part_catalog_id:
vals['part_number'] = self.part_catalog_id.part_number or '' vals['part_number'] = (
# Quantity shipped = job qty minus scrap. AS9100 wants the self.part_catalog_id.part_number or ''
# actual count that left the shop, not the order count. )
if 'quantity_shipped' in Cert._fields: if 'quantity_shipped' in Cert._fields:
vals['quantity_shipped'] = int( vals['quantity_shipped'] = int(
(self.qty_done or self.qty or 0) - (self.qty_scrapped or 0) (self.qty_done or self.qty or 0)
- (self.qty_scrapped or 0)
)
if 'po_number' in Cert._fields and self.sale_order_id \
and 'x_fc_po_number' in self.sale_order_id._fields:
vals['po_number'] = (
self.sale_order_id.x_fc_po_number or ''
)
if 'customer_job_no' in Cert._fields \
and self.sale_order_id \
and 'x_fc_customer_job_number' \
in self.sale_order_id._fields:
vals['customer_job_no'] = (
self.sale_order_id.x_fc_customer_job_number or ''
)
if 'process_description' in Cert._fields and coating:
vals['process_description'] = coating.name or ''
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals)
self.message_post(body=Markup(_(
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer '
'should hit Issue when ready to ship.'
)) % {
't': dict(
Cert._fields['certificate_type'].selection
).get(cert_type, cert_type),
'n': cert.name,
})
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create cert (%s): %s",
self.name, cert_type, e,
) )
# PO number from the source SO. self.message_post(body=_(
if 'po_number' in Cert._fields and self.sale_order_id \ 'Cert auto-create (%(t)s) failed: %(e)s. '
and 'x_fc_po_number' in self.sale_order_id._fields: 'Create manually.'
vals['po_number'] = self.sale_order_id.x_fc_po_number or '' ) % {'t': cert_type, 'e': e})
# Customer job# → cert label (helps customer search).
if 'customer_job_no' in Cert._fields and self.sale_order_id \
and 'x_fc_customer_job_number' in self.sale_order_id._fields:
vals['customer_job_no'] = (
self.sale_order_id.x_fc_customer_job_number or ''
)
# Process description from coating name.
if 'process_description' in Cert._fields and coating:
vals['process_description'] = coating.name or ''
# Job # for shop-side reference.
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals)
self.message_post(body=Markup(_(
'CoC <b>%s</b> auto-created (draft). Issuer should hit '
'the Issue button on the certificate when ready to ship.'
)) % cert.name)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create cert: %s", self.name, e,
)
class FpJobStep(models.Model): class FpJobStep(models.Model):

View File

@@ -129,6 +129,18 @@ class FpJobWorkflowState(models.Model):
'is in done/skipped state. Used for the "Done" milestone.', 'is in done/skipped state. Used for the "Done" milestone.',
) )
trigger_on_delivery_state = fields.Boolean(
string='Trigger on Delivery Delivered',
default=False,
help='Special trigger — passes once the fusion.plating.delivery '
'linked to the job (job.delivery_id) reaches state="delivered". '
'Used for the Shipped milestone in lieu of recipe-side '
'default_kind="ship" tagging. Shipping is logistics, not '
'manufacturing — keeping the trigger off the recipe lets us '
'route deliveries (split shipments, RMA reverse-flow, '
'customer pickup) independently from plating steps.',
)
block_when_quality_hold = fields.Boolean( block_when_quality_hold = fields.Boolean(
string='Blocked by Quality Hold', string='Blocked by Quality Hold',
default=False, default=False,
@@ -180,6 +192,12 @@ class FpJobWorkflowState(models.Model):
return False return False
return all(s.state in ('done', 'skipped') for s in non_cancelled) return all(s.state in ('done', 'skipped') for s in non_cancelled)
# Special trigger: linked delivery has been marked delivered
if self.trigger_on_delivery_state:
return bool(
job.delivery_id and job.delivery_id.state == 'delivered'
)
# Special trigger: first wet step started # Special trigger: first wet step started
if self.trigger_first_step_started: if self.trigger_first_step_started:
wet_kinds = ('wet', 'bake', 'mask', 'rack') wet_kinds = ('wet', 'bake', 'mask', 'rack')

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import test_fp_job_extensions from . import test_fp_job_extensions
from . import test_fp_job_milestone_cascade

View File

@@ -0,0 +1,333 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Milestone cascade Phase 1 tests.
Covers:
- all_steps_terminal (Task 2)
- _resolve_required_cert_types (Task 3)
- _fp_create_certificates (Task 4)
- next_milestone_action (Task 5)
- action_advance_next_milestone dispatcher (Task 6)
- action_mark_delivered cert gate (Task 8)
See docs/superpowers/plans/2026-05-12-job-milestone-cascade.md.
"""
from odoo.tests.common import TransactionCase
class TestMilestoneCascade(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'CustA'})
cls.product = cls.env['product.product'].create({
'name': 'Widget',
})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def _make_step(self, job, name='Step', state='pending'):
return self.env['fp.job.step'].create({
'job_id': job.id,
'name': name,
'state': state,
})
# ---------------- Task 2: all_steps_terminal ----------------------
def test_all_steps_terminal_false_when_no_steps(self):
job = self._make_job()
self.assertFalse(job.all_steps_terminal)
def test_all_steps_terminal_false_when_any_step_pending(self):
job = self._make_job()
self._make_step(job, state='done')
self._make_step(job, state='pending')
job.invalidate_recordset(['all_steps_terminal'])
self.assertFalse(job.all_steps_terminal)
def test_all_steps_terminal_true_when_all_done(self):
job = self._make_job()
self._make_step(job, state='done')
self._make_step(job, state='done')
job.invalidate_recordset(['all_steps_terminal'])
self.assertTrue(job.all_steps_terminal)
def test_all_steps_terminal_true_with_skipped_and_cancelled(self):
job = self._make_job()
self._make_step(job, state='done')
self._make_step(job, state='skipped')
self._make_step(job, state='cancelled')
job.invalidate_recordset(['all_steps_terminal'])
self.assertTrue(job.all_steps_terminal)
# ---------------- Task 3: _resolve_required_cert_types -----------
def _make_part(self, certificate_requirement='inherit'):
return self.env['fp.part.catalog'].create({
'name': 'PartA',
'part_number': 'PN-001-%s' % certificate_requirement,
'partner_id': self.partner.id,
'certificate_requirement': certificate_requirement,
})
def test_resolve_certs_none_returns_empty(self):
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(job._resolve_required_cert_types(), set())
def test_resolve_certs_coc_only(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(job._resolve_required_cert_types(), {'coc'})
def test_resolve_certs_coc_plus_thickness(self):
part = self._make_part(certificate_requirement='coc_thickness')
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(
job._resolve_required_cert_types(),
{'coc', 'thickness_report'},
)
def test_resolve_certs_inherit_falls_back_to_partner(self):
part = self._make_part(certificate_requirement='inherit')
self.partner.x_fc_send_coc = True
self.partner.x_fc_send_thickness_report = True
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(
job._resolve_required_cert_types(),
{'coc', 'thickness_report'},
)
def test_resolve_certs_inherit_partner_says_no(self):
part = self._make_part(certificate_requirement='inherit')
self.partner.x_fc_send_coc = False
self.partner.x_fc_send_thickness_report = False
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(job._resolve_required_cert_types(), set())
def test_resolve_certs_no_part_no_partner_flags(self):
self.partner.x_fc_send_coc = False
self.partner.x_fc_send_thickness_report = False
job = self._make_job()
self.assertEqual(job._resolve_required_cert_types(), set())
# ---------------- Task 4: _fp_create_certificates -----------------
def test_create_certs_skips_when_no_required(self):
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertFalse(certs)
def test_create_certs_coc_only(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 1)
self.assertEqual(certs.certificate_type, 'coc')
self.assertEqual(certs.state, 'draft')
def test_create_certs_coc_plus_thickness(self):
part = self._make_part(certificate_requirement='coc_thickness')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 2)
self.assertEqual(
set(certs.mapped('certificate_type')),
{'coc', 'thickness_report'},
)
def test_create_certs_idempotent(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
job._fp_create_certificates() # second call must be no-op
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 1)
# ---------------- Task 5: next_milestone_action -------------------
def test_next_milestone_false_while_steps_running(self):
job = self._make_job()
self._make_step(job, state='pending')
job.invalidate_recordset(['all_steps_terminal'])
self.assertFalse(job.next_milestone_action)
def test_next_milestone_mark_done_when_state_not_done(self):
job = self._make_job()
self._make_step(job, state='done')
job.invalidate_recordset(['all_steps_terminal'])
# default state is draft after create
self.assertNotEqual(job.state, 'done')
self.assertEqual(job.next_milestone_action, 'mark_done')
self.assertEqual(job.next_milestone_label, 'Mark Job Done')
def test_next_milestone_issue_certs_when_draft_cert_exists(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
job._fp_create_certificates() # creates draft CoC
job.invalidate_recordset([
'all_steps_terminal', 'next_milestone_action',
])
self.assertEqual(job.next_milestone_action, 'issue_certs')
def test_next_milestone_schedule_delivery_when_no_certs(self):
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
job.invalidate_recordset([
'all_steps_terminal', 'next_milestone_action',
])
self.assertEqual(job.next_milestone_action, 'schedule_delivery')
def test_next_milestone_closed_when_delivered(self):
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'delivered',
})
job.delivery_id = delivery.id
job.invalidate_recordset([
'all_steps_terminal', 'next_milestone_action',
])
self.assertEqual(job.next_milestone_action, 'closed')
# ---------------- Task 6: dispatcher ------------------------------
def test_dispatcher_raises_when_no_action(self):
from odoo.exceptions import UserError
job = self._make_job()
self._make_step(job, state='pending') # not terminal
job.invalidate_recordset(['all_steps_terminal'])
with self.assertRaises(UserError):
job.action_advance_next_milestone()
def test_open_draft_certs_returns_filtered_action(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
job._fp_create_certificates()
action = job._action_open_draft_certs()
self.assertEqual(action['res_model'], 'fp.certificate')
self.assertIn(('state', '=', 'draft'), action['domain'])
self.assertIn(('x_fc_job_id', '=', job.id), action['domain'])
def test_open_draft_delivery_returns_form_when_draft(self):
job = self._make_job()
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'draft',
})
job.delivery_id = delivery.id
action = job._action_open_draft_delivery()
self.assertEqual(action['res_model'], 'fusion.plating.delivery')
self.assertEqual(action.get('res_id'), delivery.id)
self.assertEqual(action['view_mode'], 'form')
def test_open_draft_delivery_falls_back_to_list(self):
# Delivery not draft → returns list view filtered to this job.
job = self._make_job()
self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'delivered',
})
action = job._action_open_draft_delivery()
self.assertEqual(action['view_mode'], 'list,form')
self.assertIn(('job_ref', '=', job.name), action['domain'])
def test_mark_active_raises_without_active_delivery(self):
from odoo.exceptions import UserError
job = self._make_job()
with self.assertRaises(UserError):
job._action_mark_active_delivery_delivered()
# ---------------- Task 8: cert gate on action_mark_delivered ------
def test_mark_delivered_blocks_on_draft_certs(self):
from odoo.exceptions import UserError
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
job._fp_create_certificates() # creates one draft CoC
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'scheduled',
})
with self.assertRaises(UserError):
delivery.action_mark_delivered()
def test_mark_delivered_bypass_skips_cert_gate(self):
"""With fp_skip_cert_gate=True the gate doesn't raise. Downstream
super() chain (notifications, invoicing) may still raise for
their own reasons — out of scope for this test."""
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
job._fp_create_certificates()
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'scheduled',
})
try:
delivery.with_context(
fp_skip_cert_gate=True,
).action_mark_delivered()
except Exception as e:
# Cert-gate message must NOT appear. Anything else is fine.
self.assertNotIn('draft certificate', str(e))
def test_mark_delivered_passes_when_cert_issued(self):
"""Issuing the cert clears the gate. Downstream chain errors
are accepted (delivery PDF render etc. — see test above)."""
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
cert.spec_reference = 'AMS 2404'
cert.action_issue()
self.assertEqual(cert.state, 'issued')
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'scheduled',
})
try:
delivery.action_mark_delivered()
except Exception as e:
self.assertNotIn('draft certificate', str(e))

View File

@@ -35,11 +35,37 @@
string="Finish &amp; Next" string="Finish &amp; Next"
class="btn-primary" class="btn-primary"
icon="fa-arrow-right" icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress')"/> invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
<button name="action_print_traveller" type="object"
string="Print Traveller" <!-- Milestone cascade (Phase 1). All four share the same
dispatcher; visibility is gated on next_milestone_action
so only one ever renders at a time. -->
<button name="action_advance_next_milestone" type="object"
string="Mark Job Done"
class="btn-success"
icon="fa-check-circle"
invisible="next_milestone_action != 'mark_done'"/>
<button name="action_advance_next_milestone" type="object"
string="Issue Certs"
class="btn-primary"
icon="fa-certificate"
invisible="next_milestone_action != 'issue_certs'"/>
<button name="action_advance_next_milestone" type="object"
string="Schedule Delivery"
class="btn-primary"
icon="fa-truck"
invisible="next_milestone_action != 'schedule_delivery'"/>
<button name="action_advance_next_milestone" type="object"
string="Mark Shipped"
class="btn-success"
icon="fa-paper-plane"
invisible="next_milestone_action != 'mark_shipped'"/>
<field name="all_steps_terminal" invisible="1"/>
<field name="next_milestone_action" invisible="1"/>
<button name="action_print_sticker" type="object"
string="Print Sticker"
class="btn-secondary" class="btn-secondary"
icon="fa-print" icon="fa-tag"
invisible="state == 'draft'"/> invisible="state == 'draft'"/>
<button name="action_print_wo_detail" type="object" <button name="action_print_wo_detail" type="object"
string="Print WO Detail" string="Print WO Detail"
@@ -229,22 +255,6 @@
<field name="quality_hold_count" widget="statinfo" <field name="quality_hold_count" widget="statinfo"
string="Holds"/> string="Holds"/>
</button> </button>
<button name="action_view_racking_inspection" type="object"
class="oe_stat_button" icon="fa-clipboard-check">
<div class="o_stat_info">
<field name="racking_inspection_state"
widget="badge"
class="o_stat_value"
decoration-success="racking_inspection_state == 'done'"
decoration-info="racking_inspection_state == 'inspecting'"
decoration-warning="racking_inspection_state == 'discrepancy_flagged'"
decoration-muted="racking_inspection_state == 'draft'"
invisible="not racking_inspection_state"/>
<span class="o_stat_value"
invisible="racking_inspection_state"></span>
<span class="o_stat_text">Racking Insp.</span>
</div>
</button>
<button name="action_view_certificates" type="object" <button name="action_view_certificates" type="object"
class="oe_stat_button" icon="fa-certificate" class="oe_stat_button" icon="fa-certificate"
invisible="certificate_count == 0"> invisible="certificate_count == 0">