# 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` ```python 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 ```python 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` ```python 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: ```python 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`: ```python 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: ```python 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`: ```xml