Replaces per-step Finish & Next with a context-aware milestone-advance button cycling Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped. Architecture, cascade, gates, files-touched, and the cert-gate hard-block decision are all captured for implementation planning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
Job Milestone Cascade — Design Spec
Date: 2026-05-12
Status: Approved for implementation (Phase 1)
Scope: fusion_plating, fusion_plating_jobs, fusion_plating_certificates, fusion_plating_logistics (on entech)
Goal
Replace the per-step "Finish & Next" button on the fp.job form header with a single context-aware milestone-advance button. When all steps are done, the button cycles the manager through the remaining post-step lifecycle:
Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped → (closed)
Each click runs the existing downstream method (no new business logic invented). The button is one place the manager looks; the system always tells them what's next.
Motivation (workflow gap audit)
End-to-end audit found:
- G1.
fp.job.stateandfp.job.workflow_state_idare two parallel state machines that drift. - G2. No auto-fire of
button_mark_donewhen all steps complete. The cascade (delivery / cert / notification) hangs off a manual click that has no UI surface after Finish & Next becomes a no-op. - G3. Delivery + cert creation only happen via
button_mark_done. - G4. Invoice timing is strategy-dependent; no
on_job_donestrategy. - G5. Certificate auto-creation is best-effort and only spawns CoC. Thickness Report cert is never auto-created even when the part / partner requires it.
- G6. No "next action" surface on the job header.
Phase 1 closes G2 and G6 directly, makes meaningful progress on G5, and lays groundwork for G3/G4. G1 is explicitly deferred.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Ship in recipe vs separate | Separate (Option C — Hybrid) | Recipes = manufacturing; deliveries = logistics. Surface "next" on the job header so manager doesn't have to navigate. Supports split shipments naturally. |
| Cert gate strictness on Mark Shipped | Hard block (with manager bypass via context key) | AS9100 / Nadcap compliance — no shipping without paperwork. |
| Per-cert vs bulk issuance | Per-cert | Each cert (CoC vs Thickness Report) needs its own compliance review. |
| No-cert-required jobs | Skip Issue Certs, go straight to Schedule Delivery | Commercial customers don't need to click a button that has nothing to do. |
| Migration of existing data | None — dev stage | No production jobs to preserve. Just rewrite the Shipped state seed XML; -u reloads it. |
Architecture
New compute fields on fp.job
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.',
)
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')
next_milestone_label = fields.Char(
compute='_compute_next_milestone_action',
help='Human label for the next-action button — read by the view.',
)
_compute_next_milestone_action resolution order (top wins):
1. NOT all_steps_terminal → None (the existing Finish & Next stays)
2. state != 'done' → mark_done
3. ANY required cert in state='draft' → issue_certs
4. NO delivery, OR delivery in state='draft' → schedule_delivery
5. delivery.state in scheduled/in_transit → mark_shipped
6. otherwise → closed
Dispatcher action
def action_advance_next_milestone(self):
"""Single entry point — branches on next_milestone_action and
delegates to the existing method. Never invents new business logic."""
self.ensure_one()
handlers = {
'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 = handlers.get(self.next_milestone_action)
if fn:
return fn()
return True
Helper methods (each returns an Odoo action dict or calls the existing business-logic method):
_action_open_draft_certs→ returns anir.actions.act_windowopening thefp.certificatelist view with domain[('x_fc_job_id', '=', self.id), ('state', '=', 'draft')]andtarget='current'so the manager works on the cert list, then uses the breadcrumb to return._action_open_draft_delivery→ finds the first delivery instate='draft'for this job and returns anir.actions.act_windowopening that record's form intarget='current'. Falls back to the delivery list view filtered to this job if no draft delivery exists._action_mark_active_delivery_delivered→ finds the first delivery instate in ('scheduled', 'in_transit'), callsaction_mark_deliveredon it directly (no UI navigation — the cascade just does the thing). Posts to job chatter on success.
target='current' is chosen everywhere because the manager is working
on the cascade as a multi-step process; a popup would lose breadcrumb
context. The existing job-form breadcrumb survives, so they can navigate
back when done.
New trigger on fp.job.workflow.state
trigger_on_delivery_state = fields.Boolean(
string='Trigger on Delivery Delivered',
help='When True, this state passes once at least one '
'fusion.plating.delivery linked to the job reaches '
'state="delivered". Use for the Shipped milestone in '
'lieu of recipe-side default_kind="ship" tagging.',
)
fp.job.workflow.state._fp_is_passed_for_job(job) gains:
if self.trigger_on_delivery_state:
return any(d.state == 'delivered' for d in job.delivery_ids)
fp.job._compute_workflow_state_id's @api.depends extends to include delivery_ids.state.
Cert auto-create hardening
Add to fp.job:
def _resolve_required_cert_types(self):
"""Return the set of cert types this job must produce.
Reads the part's certificate_requirement; falls back to the
customer's send_coc / send_thickness_report flags when the part
is set to 'inherit'."""
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'})
_fp_create_certificates is rewritten to loop over the resolved set and create one draft fp.certificate per type, idempotent per type (checks x_fc_job_id + certificate_type before creating).
Cert gate on Mark Shipped
fusion.plating.delivery.action_mark_delivered gains a gate:
def action_mark_delivered(self):
skip_cert = self.env.context.get('fp_skip_cert_gate')
for delivery in self:
if not skip_cert and delivery.job_ref:
job = self.env['fp.job'].search(
[('name', '=', delivery.job_ref)], limit=1)
if job:
draft_certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
('state', '=', 'draft'),
])
if draft_certs:
raise UserError(_(
'Cannot mark delivery %(d)s shipped — '
'job %(j)s still has %(n)d draft certificate(s). '
'Issue them first, or override via '
'fp_skip_cert_gate=True context key.'
) % {
'd': delivery.name,
'j': job.name,
'n': len(draft_certs),
})
return super().action_mark_delivered()
Lives in fusion_plating_certificates/models/fp_delivery.py (so the gate ships with the certs module — no coupling to logistics).
View changes
In fusion_plating_jobs/views/fp_job_form_inherit.xml:
-
Hide existing Finish & Next when
all_steps_terminal:<button name="action_finish_current_step" type="object" string="Finish & Next" class="btn-primary" icon="fa-arrow-right" invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/> -
Add four mutually-exclusive milestone buttons. Each binds to
action_advance_next_milestonebut with a hardcoded label so users don't see a generic button. Visibility is gated onnext_milestone_action:<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'"/>next_milestone_action == 'closed'shows nothing (terminal). -
Hide invisible field — register
<field name="next_milestone_action" invisible="1"/>and<field name="all_steps_terminal" invisible="1"/>so the view can reference them ininvisible=expressions.
Data change — Shipped workflow state seed
In fusion_plating_jobs/data/fp_workflow_state_data.xml, replace the Shipped state record:
<record id="workflow_state_shipped" model="fp.job.workflow.state">
<field name="name">Shipped</field>
<field name="code">shipped</field>
<field name="sequence">60</field>
<field name="color">success</field>
<field name="trigger_on_delivery_state" eval="True"/>
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
</record>
Keep noupdate="1" on the wrapping <data> block since shops may further customise. In dev, -u fusion_plating_jobs re-applies it on fresh DBs.
State transition cascade (visual)
┌──────────────────────┐
│ Steps still running │ ← Finish & Next visible
└──────────┬───────────┘
▼ last step done
┌──────────────────────┐
│ Mark Job Done │ ← button cascade starts
└──────────┬───────────┘
▼ button_mark_done (gates + create delivery + cert)
┌────────────────────────────┴─────────────────────────────┐
│ │
any draft cert? no required certs
│ │
▼ ▼
┌────────────┐ (skip to next)
│ Issue Certs│
└─────┬──────┘
▼ all certs issued
┌─────────────────┐
│ Schedule Deliv. │
└─────┬───────────┘
▼ delivery scheduled
┌─────────────┐
│ Mark Shipped │ ← gates on issued certs (cert module)
└─────┬────────┘
▼ delivery.action_mark_delivered
(workflow_state → Shipped via the new trigger;
invoice fires if strategy='on_delivery')
│
▼
Closed
Files touched
| File | Change |
|---|---|
fusion_plating_jobs/models/fp_job.py |
Add all_steps_terminal, next_milestone_action, next_milestone_label compute fields. Add action_advance_next_milestone dispatcher + 3 helper methods. Add _resolve_required_cert_types. Rewrite _fp_create_certificates to loop over resolved types. Extend @api.depends on _compute_workflow_state_id to include delivery_ids.state. |
fusion_plating_jobs/models/fp_job_workflow_state.py |
Add trigger_on_delivery_state Boolean. Extend _fp_is_passed_for_job with delivery-state branch. |
fusion_plating_jobs/data/fp_workflow_state_data.xml |
Rewrite Shipped state seed: drop trigger_default_kinds='ship', add trigger_on_delivery_state=True. |
fusion_plating_jobs/views/fp_job_form_inherit.xml |
Hide Finish & Next when all_steps_terminal. Add 4 milestone buttons. Add invisible field declarations. |
fusion_plating_certificates/models/fp_delivery.py |
Inherit fusion.plating.delivery; override action_mark_delivered to gate on draft certs. Manager bypass via fp_skip_cert_gate=True. |
fusion_plating_certificates/__init__.py / models/__init__.py |
Register the new fp_delivery.py if needed. |
Manifest versions to bump:
fusion_plating_jobsfusion_plating_certificates
Out of scope (Phase 2+)
- Send Certs to Customer button — wrap
action_send_to_customerper cert into the cascade after Mark Shipped. Existingfp_notification_triggerhooks already handle ship-time customer email; needs integration design. on_job_doneinvoice strategy — currently invoices fire at SO confirm or delivery delivered. A "fire at job done" option is desirable for cash-up-front shops; needs strategy-pattern extension infusion_plating_invoicing/models/sale_order.py.fp.job.state↔workflow_state_idreconciliation (G1) — pick one source of truth, drop or compute the other. Larger refactor; defer until Phase 1 lands and we see how the cascade affects state-machine readability.
Implementation notes / gotchas
next_milestone_actionis not stored — recompute on every access. Cheap (4 boolean checks). Avoids dependency-tracking complexity when delivery state changes.- The cascade reads
delivery_idsonfp.job. Confirm this field exists (related/computed) before relying on it. Fallback: searchfusion.plating.deliverybyjob_ref == self.name. - The cert gate in
action_mark_deliveredlives in the certs module so logistics doesn't depend on certs (currently logistics is upstream of certs in the dependency graph — verify). - View buttons share the same
name="action_advance_next_milestone"but Odoo distinguishes them by theirstring=attribute in the rendered DOM — this is the standard Odoo pattern for context-aware buttons (seesale.orderaction buttons). - All four buttons are inside the header; users won't see more than one at a time thanks to the
invisible=filters.