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:
@@ -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': [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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',
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
<field name="code">shipped</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="color">success</field>
|
||||
<field name="trigger_default_kinds">ship</field>
|
||||
<field name="description">Shipment confirmed (BOL or carrier pickup). Customer can be notified.</field>
|
||||
<field name="trigger_on_delivery_state" eval="True"/>
|
||||
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_done" model="fp.job.workflow.state">
|
||||
|
||||
@@ -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 <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.
|
||||
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 <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,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Cert auto-create (%(t)s) failed: %(e)s. '
|
||||
'Create manually.'
|
||||
) % {'t': cert_type, 'e': e})
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_fp_job_extensions
|
||||
from . import test_fp_job_milestone_cascade
|
||||
|
||||
@@ -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))
|
||||
@@ -35,11 +35,37 @@
|
||||
string="Finish & Next"
|
||||
class="btn-primary"
|
||||
icon="fa-arrow-right"
|
||||
invisible="state not in ('confirmed', 'in_progress')"/>
|
||||
<button name="action_print_traveller" type="object"
|
||||
string="Print Traveller"
|
||||
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
|
||||
|
||||
<!-- 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"
|
||||
icon="fa-print"
|
||||
icon="fa-tag"
|
||||
invisible="state == 'draft'"/>
|
||||
<button name="action_print_wo_detail" type="object"
|
||||
string="Print WO Detail"
|
||||
@@ -229,22 +255,6 @@
|
||||
<field name="quality_hold_count" widget="statinfo"
|
||||
string="Holds"/>
|
||||
</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"
|
||||
class="oe_stat_button" icon="fa-certificate"
|
||||
invisible="certificate_count == 0">
|
||||
|
||||
Reference in New Issue
Block a user