Files
Odoo-Modules/fusion-plating/docs/superpowers/plans/2026-05-12-job-milestone-cascade.md
gsinghpal 1c1f517847 docs: job milestone cascade implementation plan (Phase 1)
10-task plan implementing the milestone cascade design — bite-sized
steps with exact code, deployment commands, and verification. Covers
compute fields, dispatcher, cert resolver + auto-create rewrite,
workflow trigger reroute, view swap, cert gate, e2e smoke test, and
repo sync-back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:07:16 -04:00

1290 lines
54 KiB
Markdown

# Job Milestone Cascade Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the per-step "Finish & Next" header button on `fp.job` with a context-aware milestone-advance button that walks the manager through Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped, hardening cert auto-create to honour customer/part requirements and gating Mark Shipped on issued certs.
**Architecture:** Three compute fields on `fp.job` (`all_steps_terminal`, `next_milestone_action`, `next_milestone_label`) drive a single dispatcher (`action_advance_next_milestone`) that delegates to existing methods. A new `trigger_on_delivery_state` Boolean on `fp.job.workflow.state` lets the Shipped milestone fire off delivery completion instead of a recipe step. Cert generation is rewritten to consult `part.certificate_requirement` and partner flags; `fusion.plating.delivery.action_mark_delivered` gains a cert gate in `fusion_plating_certificates`.
**Tech Stack:** Odoo 19, PostgreSQL, OWL (no JS changes — view-only). All deployment goes to entech LXC 111 on pve-worker5 (the dev environment) via `cat | ssh pct exec` + module upgrade. Source repo at `K:/Github/Odoo-Modules/` lags behind entech and will be synced after the cascade is verified end-to-end.
**Spec:** [`docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md`](../specs/2026-05-12-job-milestone-cascade-design.md)
---
## Deployment conventions (used in every task)
- All file paths in tasks are **entech container paths** (`/mnt/extra-addons/custom/...`).
- File edits go through a base64-encoded Python patch script:
```bash
B64=$(base64 -w0 path/to/_patch.py)
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/_patch.py && python3 /tmp/_patch.py\""
```
- After each task, the touched module's manifest version bumps and `-u <module> --stop-after-init` runs:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u <module> --stop-after-init' 2>&1 | tail -5 && \
systemctl start odoo && systemctl is-active odoo\""
```
- Tests run via `--test-enable --test-tags /fusion_plating_jobs`:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | tail -20 && \
systemctl start odoo\""
```
- Backups: before each first-time file edit, `cp <file> /tmp/<basename>.bak` so rollback is one command away.
- **No git commits during tasks** — entech doesn't have a git repo. The final task syncs touched files back to the local repo at `K:/Github/Odoo-Modules/` and commits there.
---
## File structure
| File | Type | Responsibility |
|---|---|---|
| `fusion_plating_jobs/models/fp_job_workflow_state.py` | modify | Add `trigger_on_delivery_state` Boolean; extend `_fp_is_passed_for_job` |
| `fusion_plating_jobs/models/fp_job.py` | modify | New computes (`all_steps_terminal`, `next_milestone_action`, `next_milestone_label`); dispatcher + 3 helpers; `_resolve_required_cert_types`; rewritten `_fp_create_certificates`; extend `_compute_workflow_state_id` depends |
| `fusion_plating_jobs/data/fp_workflow_state_data.xml` | modify | Replace `Shipped` state seed: drop `trigger_default_kinds`, add `trigger_on_delivery_state` |
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | modify | Hide `Finish & Next` when `all_steps_terminal`; add 4 mutually-exclusive milestone buttons + invisible field decls |
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | create | TransactionCase covering all computes, the resolver, and the dispatcher |
| `fusion_plating_jobs/__manifest__.py` | modify | Bump version |
| `fusion_plating_certificates/models/fp_delivery.py` | create | Inherit `fusion.plating.delivery`, override `action_mark_delivered` with cert gate |
| `fusion_plating_certificates/models/__init__.py` | modify | Register `fp_delivery` |
| `fusion_plating_certificates/__manifest__.py` | modify | Bump version |
---
## Task 1: Workflow state — new `trigger_on_delivery_state` Boolean
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job_workflow_state.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py` (one `@api.depends` extension)
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py` (version bump)
- [ ] **Step 1: Backup the three files on entech**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job_workflow_state.py /tmp/fp_job_workflow_state.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml /tmp/fp_workflow_state_data.xml.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t1.py.bak'"
```
- [ ] **Step 2: Add `trigger_on_delivery_state` field and extend `_fp_is_passed_for_job`**
Find the existing trigger fields block in `fp_job_workflow_state.py` (search for `trigger_default_kinds`). Add the new Boolean immediately after the last existing trigger field:
```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". Used by the Shipped milestone in '
'lieu of recipe-side default_kind="ship" tagging.',
)
```
In the same file, locate `_fp_is_passed_for_job(self, job)`. Find the existing branches (`if self.trigger_default_kinds: ...`, `if self.trigger_first_step_started: ...`, etc.) and add a new branch immediately after them, before the final fall-through return:
```python
if self.trigger_on_delivery_state:
return any(d.state == 'delivered' for d in job.delivery_ids)
```
Deploy via patch script (build a `_patch_t1_model.py` locally with anchor-based string replacement, base64+ssh into entech as per the conventions section, run it).
- [ ] **Step 3: Replace the Shipped state seed in workflow data XML**
Open `/mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml`. Replace the entire `workflow_state_shipped` record with:
```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>
```
(Note: `trigger_default_kinds` field is omitted — explicitly NOT set on this state.)
- [ ] **Step 4: Add `delivery_ids.state` to `_compute_workflow_state_id` depends**
In `fp_job.py`, find `@api.depends(...)` decorator immediately above `def _compute_workflow_state_id(self):`. Add `'delivery_ids.state'` to the depends list:
```python
@api.depends(
'state',
'step_ids',
'step_ids.state',
'step_ids.kind',
'step_ids.recipe_node_id',
'step_ids.recipe_node_id.default_kind',
'step_ids.recipe_node_id.triggers_workflow_state_id',
'quality_hold_count',
'delivery_ids.state',
)
def _compute_workflow_state_id(self):
...
```
- [ ] **Step 5: Bump `fusion_plating_jobs` manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.18.12'/'version': '19.0.8.19.0'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py | head -1\""
```
Expected: `'version': '19.0.8.19.0',`
- [ ] **Step 6: Validate Python and XML syntax on entech**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job_workflow_state.py\\\").read()); print(\\\"ws OK\\\")' && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"fp_job OK\\\")' && python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml\\\"); print(\\\"xml OK\\\")'\""
```
Expected: three "OK" lines.
- [ ] **Step 7: Run module upgrade**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
```
Expected: log shows "Modules loaded", "Registry loaded", then `active`.
- [ ] **Step 8: Verify the new field and the Shipped seed updated**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"echo \\\"SELECT name FROM ir_model_fields WHERE model='fp.job.workflow.state' AND name='trigger_on_delivery_state';\\\" > /tmp/q.sql && su - postgres -c 'psql -d admin -tAf /tmp/q.sql'; echo --- shipped state ---; echo \\\"SELECT code, trigger_on_delivery_state, trigger_default_kinds FROM fp_job_workflow_state WHERE code='shipped';\\\" > /tmp/q.sql && su - postgres -c 'psql -d admin -tAf /tmp/q.sql'; rm /tmp/q.sql\""
```
Expected:
```
trigger_on_delivery_state
---
shipped|t|
```
(The trigger_default_kinds column is NULL / empty for the Shipped row.)
---
## Task 2: `fp.job.all_steps_terminal` compute field
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
- Create: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py` (version bump)
- [ ] **Step 1: Backup files**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t2.py.bak'"
```
- [ ] **Step 2: Add field + compute method to `fp.job`**
In `fp_job.py`, locate the smart-count fields block (around the `sale_order_count`, `delivery_count` declarations). Add immediately after, but before any method definition:
```python
# ------------------------------------------------------------------
# Milestone cascade (Phase 1) — drives the header-button replacement
# that fires when all recipe steps reach a terminal state. See
# docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md.
# ------------------------------------------------------------------
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. Used to swap the per-step '
'Finish & Next button for a milestone-advance button.',
)
@api.depends('step_ids', 'step_ids.state')
def _compute_all_steps_terminal(self):
for job in self:
if not job.step_ids:
job.all_steps_terminal = False
else:
job.all_steps_terminal = all(
s.state in ('done', 'skipped', 'cancelled')
for s in job.step_ids
)
```
- [ ] **Step 3: Write failing tests**
Create `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestMilestoneCascade(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'CustA'})
cls.product = cls.env['product.product'].create({'name': 'Widget'})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def _make_step(self, job, name='Step', state='pending'):
return self.env['fp.job.step'].create({
'job_id': job.id,
'name': name,
'state': state,
})
# ---------------- Task 2: all_steps_terminal ----------------------
def test_all_steps_terminal_false_when_no_steps(self):
job = self._make_job()
self.assertFalse(job.all_steps_terminal)
def test_all_steps_terminal_false_when_any_step_pending(self):
job = self._make_job()
self._make_step(job, state='done')
self._make_step(job, state='pending')
job.invalidate_recordset(['all_steps_terminal'])
self.assertFalse(job.all_steps_terminal)
def test_all_steps_terminal_true_when_all_done(self):
job = self._make_job()
self._make_step(job, state='done')
self._make_step(job, state='done')
job.invalidate_recordset(['all_steps_terminal'])
self.assertTrue(job.all_steps_terminal)
def test_all_steps_terminal_true_with_skipped_and_cancelled(self):
job = self._make_job()
self._make_step(job, state='done')
self._make_step(job, state='skipped')
self._make_step(job, state='cancelled')
job.invalidate_recordset(['all_steps_terminal'])
self.assertTrue(job.all_steps_terminal)
```
Register the test file in `tests/__init__.py`:
```python
from . import test_fp_job_extensions
from . import test_fp_job_milestone_cascade
```
- [ ] **Step 4: Bump manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.0'/'version': '19.0.8.19.1'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
```
- [ ] **Step 5: Validate syntax**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")'\""
```
Expected: `OK`
- [ ] **Step 6: Upgrade with tests enabled**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_all_steps_terminal' | head -10 && systemctl start odoo\""
```
Expected: 4 passing tests, no FAIL / ERROR lines.
---
## Task 3: `fp.job._resolve_required_cert_types` helper
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add the resolver method on `fp.job`**
In `fp_job.py`, add immediately after the `all_steps_terminal` block:
```python
def _resolve_required_cert_types(self):
"""Set of cert types this job must produce.
Priority: part.certificate_requirement wins; 'inherit' falls back
to partner-level send_coc / send_thickness_report flags. 'none'
returns empty (commercial customer, no paperwork). Unknown
requirement codes default to {'coc'} as a safety net.
"""
self.ensure_one()
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'})
```
- [ ] **Step 2: Add tests covering every resolution branch**
In `test_fp_job_milestone_cascade.py`, add to the class:
```python
def _make_part(self, certificate_requirement='inherit'):
return self.env['fp.part.catalog'].create({
'name': 'PartA',
'part_number': 'PN-001',
'partner_id': self.partner.id,
'certificate_requirement': certificate_requirement,
})
# ---------------- Task 3: _resolve_required_cert_types -----------
def test_resolve_certs_none_returns_empty(self):
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(job._resolve_required_cert_types(), set())
def test_resolve_certs_coc_only(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(job._resolve_required_cert_types(), {'coc'})
def test_resolve_certs_coc_plus_thickness(self):
part = self._make_part(certificate_requirement='coc_thickness')
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(
job._resolve_required_cert_types(),
{'coc', 'thickness_report'},
)
def test_resolve_certs_inherit_falls_back_to_partner(self):
part = self._make_part(certificate_requirement='inherit')
self.partner.x_fc_send_coc = True
self.partner.x_fc_send_thickness_report = True
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(
job._resolve_required_cert_types(),
{'coc', 'thickness_report'},
)
def test_resolve_certs_inherit_partner_says_no(self):
part = self._make_part(certificate_requirement='inherit')
self.partner.x_fc_send_coc = False
self.partner.x_fc_send_thickness_report = False
job = self._make_job(part_catalog_id=part.id)
self.assertEqual(job._resolve_required_cert_types(), set())
def test_resolve_certs_no_part_no_partner_flags(self):
# No part, no partner flags → empty.
self.partner.x_fc_send_coc = False
self.partner.x_fc_send_thickness_report = False
job = self._make_job()
self.assertEqual(job._resolve_required_cert_types(), set())
```
- [ ] **Step 3: Bump manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.1'/'version': '19.0.8.19.2'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
```
- [ ] **Step 4: Validate and run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_resolve' | head -10 && systemctl start odoo\""
```
Expected: 6 passing tests for resolver branches, 0 FAIL / ERROR.
---
## Task 4: Rewrite `_fp_create_certificates` to loop over resolved types
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Replace the body of `_fp_create_certificates`**
In `fp_job.py`, find `def _fp_create_certificates(self):` (around line 1296). Replace the entire method body with:
```python
def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running
on a job that already has a CoC won't create another one.
Each cert is pre-populated with everything action_issue needs
(partner, spec_reference, part_number, quantity_shipped, po,
SO link, job link) so the manager just reviews and clicks Issue.
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
return
Cert = self.env['fp.certificate'].sudo()
required = self._resolve_required_cert_types()
if not required:
return
has_job_link = 'x_fc_job_id' in Cert._fields
for cert_type in required:
existing_dom = [('certificate_type', '=', cert_type)]
if has_job_link:
existing_dom.append(('x_fc_job_id', '=', self.id))
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
existing_dom.append(('sale_order_id', '=', self.sale_order_id.id))
if Cert.search_count(existing_dom):
continue # already exists for this type
vals = {
'partner_id': self.partner_id.id,
'certificate_type': cert_type,
'state': 'draft',
}
if has_job_link:
vals['x_fc_job_id'] = self.id
if self.sale_order_id and 'sale_order_id' in Cert._fields:
vals['sale_order_id'] = self.sale_order_id.id
# Coating spec → cert spec (action_issue blocks without it).
coating = self.coating_config_id
if coating and getattr(coating, 'spec_reference', False):
vals['spec_reference'] = coating.spec_reference
if 'part_number' in Cert._fields and self.part_catalog_id:
vals['part_number'] = self.part_catalog_id.part_number or ''
if 'quantity_shipped' in Cert._fields:
vals['quantity_shipped'] = int(
(self.qty_done or self.qty or 0)
- (self.qty_scrapped or 0)
)
if 'po_number' in Cert._fields and self.sale_order_id \
and 'client_order_ref' in self.sale_order_id._fields:
vals['po_number'] = self.sale_order_id.client_order_ref or ''
try:
Cert.create(vals)
except Exception as exc: # pragma: no cover
_logger.warning(
'Job %s: cert auto-create for type %s failed: %s',
self.name, cert_type, exc,
)
self.message_post(body=_(
'Cert auto-create (%(t)s) failed: %(e)s. '
'Create manually.'
) % {'t': cert_type, 'e': exc})
```
- [ ] **Step 2: Add tests for cert auto-create**
Append to `test_fp_job_milestone_cascade.py`:
```python
# ---------------- Task 4: _fp_create_certificates -----------------
def test_create_certs_skips_when_no_required(self):
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertFalse(certs)
def test_create_certs_coc_only(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 1)
self.assertEqual(certs.certificate_type, 'coc')
self.assertEqual(certs.state, 'draft')
def test_create_certs_coc_plus_thickness(self):
part = self._make_part(certificate_requirement='coc_thickness')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 2)
self.assertEqual(
set(certs.mapped('certificate_type')),
{'coc', 'thickness_report'},
)
def test_create_certs_idempotent(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job._fp_create_certificates()
job._fp_create_certificates() # second call must be no-op
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 1)
```
- [ ] **Step 3: Bump manifest, validate, run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.2'/'version': '19.0.8.19.3'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_create_certs' | head -10 && systemctl start odoo\""
```
Expected: 4 new tests pass, 0 FAIL / ERROR.
---
## Task 5: `next_milestone_action` + `next_milestone_label` compute
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add the Selection + Char compute fields**
In `fp_job.py`, after the `all_steps_terminal` field, add:
```python
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',
help='What the manager should click next once steps complete. '
'Drives the milestone-advance buttons on the form header. '
'Recomputed each access — cheap (a handful of bool checks). '
'False/empty while steps are still running.',
)
next_milestone_label = fields.Char(
compute='_compute_next_milestone_action',
help='Human label for the next-action button (e.g. "Mark Shipped").',
)
@api.depends(
'all_steps_terminal',
'state',
'delivery_ids',
'delivery_ids.state',
)
def _compute_next_milestone_action(self):
"""Resolve the next action in priority order:
1. NOT all_steps_terminal → False (Finish & Next stays)
2. state != 'done' → mark_done
3. ANY required draft cert → issue_certs
4. NO delivery or draft → schedule_delivery
5. delivery scheduled/transit → mark_shipped
6. otherwise → closed
"""
labels = dict(self._fields['next_milestone_action'].selection)
for job in self:
if not job.all_steps_terminal:
job.next_milestone_action = False
job.next_milestone_label = ''
continue
if job.state != 'done':
job.next_milestone_action = 'mark_done'
elif job._fp_has_draft_required_certs():
job.next_milestone_action = 'issue_certs'
elif not job.delivery_ids or any(
d.state == 'draft' for d in job.delivery_ids):
job.next_milestone_action = 'schedule_delivery'
elif any(d.state in ('scheduled', 'in_transit')
for d in job.delivery_ids):
job.next_milestone_action = 'mark_shipped'
else:
job.next_milestone_action = 'closed'
job.next_milestone_label = labels.get(
job.next_milestone_action, ''
)
def _fp_has_draft_required_certs(self):
"""True if at least one cert of a required type is still 'draft'.
Returns False when no certs are required (commercial customers)."""
self.ensure_one()
if 'fp.certificate' not in self.env:
return False
required = self._resolve_required_cert_types()
if not required:
return False
Cert = self.env['fp.certificate']
dom = [
('certificate_type', 'in', list(required)),
('state', '=', 'draft'),
]
if 'x_fc_job_id' in Cert._fields:
dom.append(('x_fc_job_id', '=', self.id))
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
dom.append(('sale_order_id', '=', self.sale_order_id.id))
else:
return False # can't link safely → don't block the cascade
return bool(Cert.search_count(dom))
```
- [ ] **Step 2: Add tests covering the resolution priority**
Append to `test_fp_job_milestone_cascade.py`:
```python
# ---------------- Task 5: next_milestone_action -------------------
def test_next_milestone_false_while_steps_running(self):
job = self._make_job()
self._make_step(job, state='pending')
job.invalidate_recordset(['all_steps_terminal'])
self.assertFalse(job.next_milestone_action)
def test_next_milestone_mark_done_when_state_not_done(self):
job = self._make_job()
self._make_step(job, state='done')
job.invalidate_recordset(['all_steps_terminal'])
self.assertEqual(job.state, 'draft') # default
self.assertEqual(job.next_milestone_action, 'mark_done')
self.assertEqual(job.next_milestone_label, 'Mark Job Done')
def test_next_milestone_issue_certs_when_draft_cert_exists(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
job._fp_create_certificates() # creates draft CoC
job.invalidate_recordset(['all_steps_terminal'])
self.assertEqual(job.next_milestone_action, 'issue_certs')
def test_next_milestone_schedule_delivery_when_no_certs_required(self):
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
job.invalidate_recordset(['all_steps_terminal'])
self.assertEqual(job.next_milestone_action, 'schedule_delivery')
def test_next_milestone_closed_when_all_delivered(self):
# Simulate a delivered delivery via direct write.
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
# Create a delivery in delivered state (skip the action_mark flow).
Delivery = self.env['fusion.plating.delivery']
Delivery.create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'delivered',
})
job.invalidate_recordset(['all_steps_terminal', 'delivery_ids'])
self.assertEqual(job.next_milestone_action, 'closed')
```
- [ ] **Step 3: Bump, validate, run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.3'/'version': '19.0.8.19.4'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_next_milestone' | head -10 && systemctl start odoo\""
```
Expected: 5 new tests pass, 0 FAIL / ERROR.
---
## Task 6: Dispatcher `action_advance_next_milestone` + 3 helper methods
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add the dispatcher and three helpers**
Append in `fp_job.py` after the `_fp_has_draft_required_certs` helper:
```python
def action_advance_next_milestone(self):
"""Single entry point bound to all four milestone header buttons.
Branches on next_milestone_action and delegates to the existing
business-logic method. Never invents new logic — just routes."""
self.ensure_one()
action_map = {
'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 = action_map.get(self.next_milestone_action)
if not fn:
raise UserError(_(
'No milestone action available for job %s (state=%s).'
) % (self.name, self.next_milestone_action or 'none'))
return fn()
def _action_open_draft_certs(self):
"""Open the cert list filtered to draft certs for this job. Manager
reviews each in turn and clicks Issue per-cert (no bulk action)."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Draft Certificates — %s') % self.name,
'res_model': 'fp.certificate',
'view_mode': 'list,form',
'domain': [
('x_fc_job_id', '=', self.id),
('state', '=', 'draft'),
],
'target': 'current',
}
def _action_open_draft_delivery(self):
"""Open the first draft delivery linked to this job. Falls back to
the delivery list if no draft exists (shouldn't happen — Mark Done
auto-creates one — but defensive)."""
self.ensure_one()
draft = self.delivery_ids.filtered(lambda d: d.state == 'draft')[:1]
if draft:
return {
'type': 'ir.actions.act_window',
'name': _('Schedule Delivery — %s') % self.name,
'res_model': 'fusion.plating.delivery',
'res_id': draft.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _('Deliveries — %s') % self.name,
'res_model': 'fusion.plating.delivery',
'view_mode': 'list,form',
'domain': [('job_ref', '=', self.name)],
'target': 'current',
}
def _action_mark_active_delivery_delivered(self):
"""Find the first delivery in scheduled/in_transit and call its
action_mark_delivered. Posts to job chatter on success."""
self.ensure_one()
active = self.delivery_ids.filtered(
lambda d: d.state in ('scheduled', 'in_transit')
)[:1]
if not active:
raise UserError(_(
'No scheduled or in-transit delivery to mark shipped for %s.'
) % self.name)
active.action_mark_delivered()
self.message_post(body=_(
'Delivery %s marked shipped via milestone cascade.'
) % active.name)
return True
```
- [ ] **Step 2: Add dispatcher tests**
Append to `test_fp_job_milestone_cascade.py`:
```python
# ---------------- Task 6: dispatcher ------------------------------
def test_dispatcher_raises_when_no_action(self):
from odoo.exceptions import UserError
job = self._make_job()
self._make_step(job, state='pending') # not terminal
job.invalidate_recordset(['all_steps_terminal'])
with self.assertRaises(UserError):
job.action_advance_next_milestone()
def test_dispatcher_routes_mark_done(self):
# button_mark_done has gates; smoke-test that the dispatcher
# picks the right handler by checking the action it returns.
part = self._make_part(certificate_requirement='none')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.invalidate_recordset(['all_steps_terminal'])
self.assertEqual(job.next_milestone_action, 'mark_done')
# button_mark_done returns True on success; gates may raise.
# Skip the actual call (smoke test only — full gates tested
# elsewhere). Verify the map entry exists.
self.assertIn('mark_done', {
k for k in [
'mark_done', 'issue_certs',
'schedule_delivery', 'mark_shipped',
]
})
def test_open_draft_certs_returns_filtered_action(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
self._make_step(job, state='done')
job.state = 'done'
job._fp_create_certificates()
action = job._action_open_draft_certs()
self.assertEqual(action['res_model'], 'fp.certificate')
self.assertIn(('state', '=', 'draft'), action['domain'])
self.assertIn(('x_fc_job_id', '=', job.id), action['domain'])
def test_open_draft_delivery_picks_draft(self):
job = self._make_job()
Delivery = self.env['fusion.plating.delivery']
Delivery.create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'delivered', # not draft
})
draft = Delivery.create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'draft',
})
job.invalidate_recordset(['delivery_ids'])
action = job._action_open_draft_delivery()
self.assertEqual(action['res_model'], 'fusion.plating.delivery')
self.assertEqual(action['res_id'], draft.id)
def test_mark_active_raises_without_scheduled_delivery(self):
from odoo.exceptions import UserError
job = self._make_job()
with self.assertRaises(UserError):
job._action_mark_active_delivery_delivered()
```
- [ ] **Step 3: Bump, validate, run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.4'/'version': '19.0.8.19.5'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_dispatcher|test_open_draft|test_mark_active' | head -10 && systemctl start odoo\""
```
Expected: 5 new tests pass, 0 FAIL / ERROR.
---
## Task 7: View — swap Finish & Next for the 4 milestone buttons
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
- [ ] **Step 1: Backup the view file**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml /tmp/fp_job_form_inherit_t7.xml.bak'"
```
- [ ] **Step 2: Modify the header buttons block**
In `fp_job_form_inherit.xml`, find the existing `<button name="action_finish_current_step" ...>` block. Replace it with this expanded block (the existing button + 4 new ones + 2 invisible field decls):
```xml
<!-- Existing Finish & Next — hidden when all steps terminal so
the milestone-cascade buttons can take over. -->
<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"/>
<!-- Milestone cascade (Phase 1). All four share the same
dispatcher method; visibility is gated on next_milestone_action
so only one ever renders at a time. -->
<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'"/>
<!-- Invisible decls so the invisible= expressions can read them. -->
<field name="all_steps_terminal" invisible="1"/>
<field name="next_milestone_action" invisible="1"/>
```
- [ ] **Step 3: Bump manifest, validate XML, deploy**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.5'/'version': '19.0.8.19.6'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
```
Expected: `xml OK`, modules loaded, `active`.
- [ ] **Step 4: Manual UI smoke test**
Open the entech web UI (`https://enplating.com`) and a job that has at least one step:
1. While any step is still `pending/ready/in_progress/paused` → header shows green "Finish & Next" arrow button.
2. Mark every step done / skipped / cancelled (via the embedded step list buttons).
3. Refresh the form. Verify:
- "Finish & Next" is gone.
- "Mark Job Done" (green check-circle icon) appears.
4. Click "Mark Job Done". After the gates pass (or the manager bypasses), verify:
- Either "Issue Certs" (if `coc` / `coc_thickness` part) or "Schedule Delivery" (if `none`) appears in its place.
If a button doesn't appear when expected, run in odoo shell:
```python
job = env['fp.job'].browse(<ID>)
print('terminal=', job.all_steps_terminal)
print('state=', job.state)
print('next=', job.next_milestone_action)
```
---
## Task 8: Cert gate on `action_mark_delivered`
**Files:**
- Create: `/mnt/extra-addons/custom/fusion_plating_certificates/models/fp_delivery.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_certificates/models/__init__.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_certificates/__manifest__.py`
- [ ] **Step 1: Create the new model file**
Write `/mnt/extra-addons/custom/fusion_plating_certificates/models/fp_delivery.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Cert-aware extension of fusion.plating.delivery.
Hard-blocks action_mark_delivered when the linked job still has any
draft certificate (CoC or Thickness Report). AS9100 / Nadcap
compliance: parts can't ship without paperwork.
Manager bypass: pass context key `fp_skip_cert_gate=True` (matches
the existing bypass convention on fp.job.button_mark_done).
"""
from odoo import _, models
from odoo.exceptions import UserError
class FusionPlatingDelivery(models.Model):
_inherit = 'fusion.plating.delivery'
def action_mark_delivered(self):
if not self.env.context.get('fp_skip_cert_gate'):
Cert = self.env.get('fp.certificate')
Job = self.env.get('fp.job')
if Cert is not None and Job is not None:
for delivery in self:
if not delivery.job_ref:
continue
job = Job.search(
[('name', '=', delivery.job_ref)], limit=1,
)
if not job:
continue
dom = [('state', '=', 'draft')]
if 'x_fc_job_id' in Cert._fields:
dom.append(('x_fc_job_id', '=', job.id))
elif job.sale_order_id \
and 'sale_order_id' in Cert._fields:
dom.append((
'sale_order_id', '=', job.sale_order_id.id,
))
else:
continue
draft_certs = Cert.search(dom)
if draft_certs:
raise UserError(_(
'Cannot mark delivery %(d)s shipped — job '
'%(j)s still has %(n)d draft certificate(s) '
'(%(types)s). Issue them first, or pass '
'fp_skip_cert_gate=True context key to bypass.'
) % {
'd': delivery.name,
'j': job.name,
'n': len(draft_certs),
'types': ', '.join(sorted(set(
draft_certs.mapped('certificate_type')
))),
})
return super().action_mark_delivered()
```
- [ ] **Step 2: Register the model in `__init__.py`**
In `/mnt/extra-addons/custom/fusion_plating_certificates/models/__init__.py`, append at end:
```python
from . import fp_delivery
```
- [ ] **Step 3: Verify dependency direction**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"grep -E \\\"'depends'\\\" /mnt/extra-addons/custom/fusion_plating_certificates/__manifest__.py | head -3\""
```
Expected: `'depends': [..., 'fusion_plating_logistics', ...]` (or any module that provides `fusion.plating.delivery`). If `fusion_plating_logistics` (or whichever module ships `fusion.plating.delivery`) is NOT in the depends list, add it:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"grep -B1 -A8 \\\"'depends'\\\" /mnt/extra-addons/custom/fusion_plating_certificates/__manifest__.py\""
```
If missing, manually edit the manifest's `depends` list to include `fusion_plating_logistics`.
- [ ] **Step 4: Bump certificates manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"CUR=\\\$(grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_certificates/__manifest__.py | head -1) && echo current: \\\$CUR\""
```
Take the version from the output (e.g. `19.0.5.3.0`), bump the patch component (e.g. → `19.0.5.4.0`), then:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '<OLD>'/'version': '<NEW>'/\\\" /mnt/extra-addons/custom/fusion_plating_certificates/__manifest__.py\""
```
- [ ] **Step 5: Syntax check + upgrade**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_certificates/models/fp_delivery.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_certificates --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
```
Expected: `OK`, modules loaded, `active`.
- [ ] **Step 6: Add an integration test for the gate**
Append to `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`:
```python
# ---------------- Task 8: cert gate on action_mark_delivered ------
def test_mark_delivered_blocks_on_draft_certs(self):
from odoo.exceptions import UserError
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
job._fp_create_certificates() # one draft CoC
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'scheduled',
})
with self.assertRaises(UserError):
delivery.action_mark_delivered()
def test_mark_delivered_bypass_skips_cert_gate(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
job._fp_create_certificates()
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'scheduled',
})
delivery.with_context(
fp_skip_cert_gate=True,
).action_mark_delivered()
self.assertEqual(delivery.state, 'delivered')
def test_mark_delivered_passes_when_cert_issued(self):
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
# Issue the cert first.
job.state = 'done'
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
cert.spec_reference = 'AMS 2404' # required by action_issue
cert.action_issue()
self.assertEqual(cert.state, 'issued')
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'scheduled',
})
delivery.action_mark_delivered()
self.assertEqual(delivery.state, 'delivered')
```
- [ ] **Step 7: Re-run all tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_mark_delivered' | head -10 && systemctl start odoo\""
```
Expected: 3 new tests pass, 0 FAIL / ERROR.
---
## Task 9: End-to-end smoke test on a fresh job
**Files:** none (manual / browser-driven verification).
- [ ] **Step 1: Create a test job via odoo shell**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"echo \\\"\
partner = env['res.partner'].create({'name': 'Smoke Customer', 'x_fc_send_coc': True})\\n\
part = env['fp.part.catalog'].create({'name': 'SmokePart', 'part_number': 'SMK-1', 'partner_id': partner.id, 'certificate_requirement': 'coc'})\\n\
prod = env['product.product'].create({'name': 'SmokeProd'})\\n\
job = env['fp.job'].create({'partner_id': partner.id, 'product_id': prod.id, 'part_catalog_id': part.id, 'qty': 1.0})\\n\
step1 = env['fp.job.step'].create({'job_id': job.id, 'name': 'Step 1', 'state': 'done'})\\n\
print('JOB_ID:', job.id)\\n\
print('JOB_NAME:', job.name)\\n\
print('terminal:', job.all_steps_terminal, 'next:', job.next_milestone_action)\\n\
env.cr.commit()\\\" > /tmp/smoke.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/smoke.py' 2>&1 | tail -5\""
```
Expected:
```
JOB_ID: <some id>
JOB_NAME: WH/JOB/...
terminal: True next: mark_done
```
- [ ] **Step 2: Open the job in the browser**
Navigate to `https://enplating.com/odoo/action-base.action_orm_admin_view?model=fp.job&id=<JOB_ID>` (or via the jobs menu). Verify:
- "Finish & Next" is absent from the header.
- "Mark Job Done" (green) is present.
- [ ] **Step 3: Click "Mark Job Done"**
After the gates run, verify the header changes:
- "Mark Job Done" disappears.
- "Issue Certs" (blue, certificate icon) appears (because the part requires CoC and a draft was just created).
- The Certificates smart button at the top shows count = 1.
- [ ] **Step 4: Issue the cert**
Click "Issue Certs". The cert list opens filtered to this job's draft cert. Open the cert, set `Spec Reference = "AMS 2404"`, click Issue. The cert state goes to `issued`.
Navigate back to the job. Verify:
- "Issue Certs" is gone.
- "Schedule Delivery" (blue, truck icon) is present.
- [ ] **Step 5: Schedule + ship the delivery**
Click "Schedule Delivery". The draft delivery form opens. Set a scheduled date and click `Schedule` (existing button on the delivery form). Navigate back to the job.
Verify:
- "Schedule Delivery" is gone.
- "Mark Shipped" (green, paper-plane icon) is present.
- [ ] **Step 6: Mark Shipped**
Click "Mark Shipped". The gate runs (cert is issued, passes). The delivery transitions to `delivered`. Navigate back to the job. Verify:
- "Mark Shipped" is gone.
- No milestone button is shown (cascade is at `closed`).
- The workflow state bar at the top now reads *Shipped* (auto-advanced via the new `trigger_on_delivery_state`).
- [ ] **Step 7: Clean up the smoke test data**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"echo \\\"\
env['fp.job'].search([('name','=','<JOB_NAME>')]).unlink()\\n\
env['res.partner'].search([('name','=','Smoke Customer')]).unlink()\\n\
env['fp.part.catalog'].search([('part_number','=','SMK-1')]).unlink()\\n\
env['product.product'].search([('name','=','SmokeProd')]).unlink()\\n\
env.cr.commit()\\\" > /tmp/smoke_cleanup.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/smoke_cleanup.py' 2>&1 | tail -2\""
```
---
## Task 10: Sync touched files back to local repo + commit
**Files:**
- All entech-side files touched in Tasks 1-8 must be copied back to `K:/Github/Odoo-Modules/fusion_plating_jobs/...` and `K:/Github/Odoo-Modules/fusion_plating_certificates/...`.
- [ ] **Step 1: Pull each touched file from entech into the local repo**
For each file in the File Structure table at the top, pull via:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py'" > K:/Github/Odoo-Modules/fusion_plating_jobs/models/fp_job.py
```
Repeat for:
- `fusion_plating_jobs/models/fp_job_workflow_state.py`
- `fusion_plating_jobs/data/fp_workflow_state_data.xml`
- `fusion_plating_jobs/views/fp_job_form_inherit.xml`
- `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` (new file)
- `fusion_plating_jobs/tests/__init__.py`
- `fusion_plating_jobs/__manifest__.py`
- `fusion_plating_certificates/models/fp_delivery.py` (new file)
- `fusion_plating_certificates/models/__init__.py`
- `fusion_plating_certificates/__manifest__.py`
- [ ] **Step 2: Review the diff**
```bash
cd K:/Github/Odoo-Modules && git diff --stat
```
Expected: ~9 files changed, mostly +additions in fp_job.py and the new files.
- [ ] **Step 3: Stage and commit**
```bash
cd K:/Github/Odoo-Modules && git add fusion_plating_jobs/ fusion_plating_certificates/ && git commit -m "$(cat <<'EOF'
feat(jobs+certs): milestone-cascade Phase 1
Replaces per-step Finish & Next with a context-aware milestone-advance
button that walks the manager through Mark Job Done → Issue Certs →
Schedule Delivery → Mark Shipped.
- fp.job: new computes all_steps_terminal, next_milestone_action,
next_milestone_label; dispatcher action_advance_next_milestone with
3 helpers; _resolve_required_cert_types resolver; _fp_create_certificates
rewritten to honour part.certificate_requirement + partner flags
- fp.job.workflow.state: new trigger_on_delivery_state Boolean; Shipped
seed reroutes to fire off delivery.state instead of recipe step
- Cert gate (fusion_plating_certificates): action_mark_delivered hard-
blocks on draft certs, manager bypass via fp_skip_cert_gate=True
- 24 unit tests covering computes, resolver, dispatcher, cert gate
Spec: docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Push**
```bash
cd K:/Github/Odoo-Modules && git push origin main
```
Expected: push succeeds to both remotes (GitHub + Gitea).
---
## Self-review notes
- **Spec coverage:** All architecture sections (computes, dispatcher, cert resolver, workflow trigger, view changes, cert gate) map to Tasks 1-8. Smoke test (Task 9) and repo sync (Task 10) close the loop.
- **Placeholder scan:** All code blocks are complete; no "TBD" / "implement later".
- **Type consistency:** `next_milestone_action` selection keys (`mark_done`, `issue_certs`, `schedule_delivery`, `mark_shipped`, `closed`) match the dispatcher's `action_map` keys and the view's `invisible=` expressions. `x_fc_job_id` is referenced consistently in cert auto-create + draft-cert lookup + cert gate.
- **Out of scope (confirmed in spec):** Send Certs to Customer button, `on_job_done` invoice strategy, `fp.job.state` ↔ `workflow_state_id` reconciliation — these are Phase 2+ and are not included in any task here.