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>
This commit is contained in:
gsinghpal
2026-05-11 22:01:10 -04:00
parent 03f14c2c40
commit b2592d70f8

View File

@@ -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
<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`:
```xml
<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:
```xml
<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.state` ↔ `workflow_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.