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:
@@ -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 & 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.
|
||||
Reference in New Issue
Block a user