Files
Odoo-Modules/fusion-plating/docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md
gsinghpal b2592d70f8 docs: job milestone cascade design spec (Phase 1)
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>
2026-05-11 22:01:10 -04:00

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.state and fp.job.workflow_state_id are two parallel state machines that drift.
  • G2. No auto-fire of button_mark_done when 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_done strategy.
  • 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 an ir.actions.act_window opening the fp.certificate list view with domain [('x_fc_job_id', '=', self.id), ('state', '=', 'draft')] and target='current' so the manager works on the cert list, then uses the breadcrumb to return.
  • _action_open_draft_delivery → finds the first delivery in state='draft' for this job and returns an ir.actions.act_window opening that record's form in target='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 in state in ('scheduled', 'in_transit'), calls action_mark_delivered on 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:

  1. Hide existing Finish & Next when all_steps_terminal:

    <button name="action_finish_current_step" type="object"
            string="Finish &amp; Next" class="btn-primary" icon="fa-arrow-right"
            invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
    
  2. Add four mutually-exclusive milestone buttons. Each binds to action_advance_next_milestone but with a hardcoded label so users don't see a generic button. Visibility is gated on next_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).

  3. 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 in invisible= 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_jobs
  • fusion_plating_certificates

Out of scope (Phase 2+)

  • Send Certs to Customer button — wrap action_send_to_customer per cert into the cascade after Mark Shipped. Existing fp_notification_trigger hooks already handle ship-time customer email; needs integration design.
  • on_job_done invoice 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 in fusion_plating_invoicing/models/sale_order.py.
  • fp.job.stateworkflow_state_id reconciliation (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_action is not stored — recompute on every access. Cheap (4 boolean checks). Avoids dependency-tracking complexity when delivery state changes.
  • The cascade reads delivery_ids on fp.job. Confirm this field exists (related/computed) before relying on it. Fallback: search fusion.plating.delivery by job_ref == self.name.
  • The cert gate in action_mark_delivered lives 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 their string= attribute in the rendered DOM — this is the standard Odoo pattern for context-aware buttons (see sale.order action buttons).
  • All four buttons are inside the header; users won't see more than one at a time thanks to the invisible= filters.