From b2592d70f8b9e1abdc9f9efea4bf050336db6bfc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 11 May 2026 22:01:10 -0400 Subject: [PATCH] docs: job milestone cascade design spec (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...2026-05-12-job-milestone-cascade-design.md | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 fusion-plating/docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md diff --git a/fusion-plating/docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md b/fusion-plating/docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md new file mode 100644 index 00000000..8338268d --- /dev/null +++ b/fusion-plating/docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md @@ -0,0 +1,310 @@ +# 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 +