diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py
index bfb59d29..53315e54 100644
--- a/fusion_plating/fusion_plating_certificates/__manifest__.py
+++ b/fusion_plating/fusion_plating_certificates/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
- 'version': '19.0.5.4.0',
+ 'version': '19.0.5.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """
@@ -27,6 +27,7 @@ Includes Fischerscope thickness measurement data capture.
'fusion_plating_portal',
'fusion_plating_batch',
'fusion_plating_configurator',
+ 'fusion_plating_logistics',
'sale_management',
],
'data': [
diff --git a/fusion_plating/fusion_plating_certificates/models/__init__.py b/fusion_plating/fusion_plating_certificates/models/__init__.py
index 9b8ea7b7..999c44d1 100644
--- a/fusion_plating/fusion_plating_certificates/models/__init__.py
+++ b/fusion_plating/fusion_plating_certificates/models/__init__.py
@@ -7,3 +7,4 @@ from . import fp_thickness_reading
from . import fp_certificate
from . import res_config_settings
from . import res_partner
+from . import fp_delivery
diff --git a/fusion_plating/fusion_plating_certificates/models/fp_delivery.py b/fusion_plating/fusion_plating_certificates/models/fp_delivery.py
new file mode 100644
index 00000000..c433ad3b
--- /dev/null
+++ b/fusion_plating/fusion_plating_certificates/models/fp_delivery.py
@@ -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()
diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py
index 31d13c9b..037ce1ee 100644
--- a/fusion_plating/fusion_plating_jobs/__manifest__.py
+++ b/fusion_plating/fusion_plating_jobs/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
- 'version': '19.0.8.20.6',
+ 'version': '19.0.8.19.6',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'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
module family — configurator, portal, logistics, quality, certificates.
-As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only
-fulfilment path. SO confirm always creates fp.job records here. The
-former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0
-once the legacy fallback became unreachable.
+Coexists with fusion_plating_bridge_mrp during the migration period.
+Activate native jobs via the x_fc_use_native_jobs settings flag (default:
+False). When False, SO confirm continues to create mrp.production records
+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
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).
'data/fp_workflow_state_data.xml',
'views/fp_workflow_state_views.xml',
+ 'views/res_config_settings_views.xml',
'views/fp_job_step_quick_look_views.xml',
'views/fp_job_form_inherit.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/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
+ 'views/res_users_views.xml',
'wizards/fp_job_step_move_wizard_views.xml',
'wizards/fp_job_step_input_wizard_views.xml',
'report/report_fp_job_sticker.xml',
diff --git a/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml b/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml
index ade92f76..e8302b13 100644
--- a/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml
+++ b/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml
@@ -61,8 +61,8 @@
shipped
60
success
- ship
- Shipment confirmed (BOL or carrier pickup). Customer can be notified.
+
+ Shipment confirmed (delivery marked delivered). Customer can be notified.
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py
index fccfde84..baf7005d 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py
@@ -106,6 +106,8 @@ class FpJob(models.Model):
'step_ids.recipe_node_id.default_kind',
'step_ids.recipe_node_id.triggers_workflow_state_id',
'quality_hold_count',
+ 'delivery_id',
+ 'delivery_id.state',
)
def _compute_workflow_state_id(self):
WS = self.env['fp.job.workflow.state']
@@ -137,6 +139,210 @@ class FpJob(models.Model):
timelog_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(
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
'step_ids.time_log_ids', 'origin', 'partner_id',
@@ -374,6 +580,15 @@ class FpJob(models.Model):
'fusion_plating_jobs.action_report_fp_job_traveller'
).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):
"""Print the Steelhead-style Work Order Detail PDF — chronological
chain-of-custody + per-step inputs + Certified By page. Use this
@@ -1285,93 +1500,102 @@ class FpJob(models.Model):
)
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
- Issue without filling 6 fields first:
- - partner_id from job
- - 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
+ Each cert is pre-populated with everything action_issue needs
+ (partner, spec_reference, part_number, quantity_shipped, po,
+ SO link, job link) so the manager just reviews and clicks Issue.
- Idempotent — if a cert already exists for this job, skip
- (prevents dupes when button_mark_done is re-run after a
- manager bypass).
+ Replaces the single-CoC implementation: now honours
+ part.certificate_requirement (coc / coc_thickness / none /
+ inherit) and partner-level send_coc / send_thickness_report
+ flags. Closes spec gap C-G1.
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
return
Cert = self.env['fp.certificate'].sudo()
- # Idempotency: don't double-create on retry.
- existing_dom = []
- if 'x_fc_job_id' in Cert._fields:
- existing_dom.append(('x_fc_job_id', '=', self.id))
- elif self.sale_order_id and 'sale_order_id' in Cert._fields:
- existing_dom.append(('sale_order_id', '=', self.sale_order_id.id))
- if existing_dom:
- existing = Cert.search(existing_dom, limit=1)
- if existing:
- _logger.info(
- 'Job %s: cert %s already exists, skipping auto-create',
- self.name, existing.name,
+ required = self._resolve_required_cert_types()
+ if not required:
+ return
+ has_job_link = 'x_fc_job_id' in Cert._fields
+ coating = self.coating_config_id
+ for cert_type in sorted(required):
+ # Idempotency per type.
+ existing_dom = [('certificate_type', '=', cert_type)]
+ if has_job_link:
+ existing_dom.append(('x_fc_job_id', '=', self.id))
+ elif self.sale_order_id and 'sale_order_id' in Cert._fields:
+ existing_dom.append(
+ ('sale_order_id', '=', self.sale_order_id.id),
)
- return
- try:
- vals = {'partner_id': self.partner_id.id}
- if 'certificate_type' in Cert._fields:
- vals['certificate_type'] = 'coc'
- if 'state' in Cert._fields:
- vals['state'] = 'draft'
- # Job + SO links.
- if 'x_fc_job_id' in Cert._fields:
- vals['x_fc_job_id'] = self.id
- elif 'job_id' in Cert._fields:
- vals['job_id'] = self.id
- if 'sale_order_id' in Cert._fields and self.sale_order_id:
- vals['sale_order_id'] = self.sale_order_id.id
- # Pre-fill from coating: the spec_reference is what action_issue
- # blocks on — without this every cert needs a manual edit.
- coating = self.coating_config_id
- if coating and 'spec_reference' in Cert._fields \
- and getattr(coating, 'spec_reference', False):
- vals['spec_reference'] = coating.spec_reference
- # Pre-fill part_number from the part catalog if we have one.
- if 'part_number' in Cert._fields and self.part_catalog_id:
- vals['part_number'] = self.part_catalog_id.part_number or ''
- # Quantity shipped = job qty minus scrap. AS9100 wants the
- # actual count that left the shop, not the order count.
- if 'quantity_shipped' in Cert._fields:
- vals['quantity_shipped'] = int(
- (self.qty_done or self.qty or 0) - (self.qty_scrapped or 0)
+ else:
+ continue # can't safely identify — skip
+ if Cert.search_count(existing_dom):
+ continue
+ try:
+ vals = {
+ 'partner_id': self.partner_id.id,
+ 'certificate_type': cert_type,
+ }
+ if 'state' in Cert._fields:
+ vals['state'] = 'draft'
+ if has_job_link:
+ vals['x_fc_job_id'] = self.id
+ elif 'job_id' in Cert._fields:
+ vals['job_id'] = self.id
+ if 'sale_order_id' in Cert._fields and self.sale_order_id:
+ vals['sale_order_id'] = self.sale_order_id.id
+ # spec_reference is what action_issue blocks on.
+ if coating and 'spec_reference' in Cert._fields \
+ and getattr(coating, 'spec_reference', False):
+ vals['spec_reference'] = coating.spec_reference
+ if 'part_number' in Cert._fields and self.part_catalog_id:
+ vals['part_number'] = (
+ self.part_catalog_id.part_number or ''
+ )
+ if 'quantity_shipped' in Cert._fields:
+ vals['quantity_shipped'] = int(
+ (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 %(n)s 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.
- 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 ''
- # 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 %s 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,
- )
+ self.message_post(body=_(
+ 'Cert auto-create (%(t)s) failed: %(e)s. '
+ 'Create manually.'
+ ) % {'t': cert_type, 'e': e})
class FpJobStep(models.Model):
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py b/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py
index dffd6bc2..9da46fc5 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py
@@ -129,6 +129,18 @@ class FpJobWorkflowState(models.Model):
'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(
string='Blocked by Quality Hold',
default=False,
@@ -180,6 +192,12 @@ class FpJobWorkflowState(models.Model):
return False
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
if self.trigger_first_step_started:
wet_kinds = ('wet', 'bake', 'mask', 'rack')
diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py
index 0c22091c..56d0ea0d 100644
--- a/fusion_plating/fusion_plating_jobs/tests/__init__.py
+++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py
@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_fp_job_extensions
+from . import test_fp_job_milestone_cascade
diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py
new file mode 100644
index 00000000..92f8b729
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py
@@ -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))
diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
index 7aa315ff..711d8f09 100644
--- a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
+++ b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
@@ -35,11 +35,37 @@
string="Finish & Next"
class="btn-primary"
icon="fa-arrow-right"
- invisible="state not in ('confirmed', 'in_progress')"/>
-
+
+
+
+
+
+
+
+
+
-