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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user