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>
54 KiB
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
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:
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-initruns: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: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>.bakso 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.dependsextension) -
Modify:
/mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py(version bump) -
Step 1: Backup the three files on entech
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_statefield 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:
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:
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:
<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.stateto_compute_workflow_state_iddepends
In fp_job.py, find @api.depends(...) decorator immediately above def _compute_workflow_state_id(self):. Add 'delivery_ids.state' to the depends list:
@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_jobsmanifest version
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
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
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
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
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:
# ------------------------------------------------------------------
# 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:
# -*- 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:
from . import test_fp_job_extensions
from . import test_fp_job_milestone_cascade
- Step 4: Bump manifest version
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
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
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:
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:
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
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
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:
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:
# ---------------- 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
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:
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:
# ---------------- 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
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:
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:
# ---------------- 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
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
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):
<!-- 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 & 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
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:
- While any step is still
pending/ready/in_progress/paused→ header shows green "Finish & Next" arrow button. - Mark every step done / skipped / cancelled (via the embedded step list buttons).
- Refresh the form. Verify:
- "Finish & Next" is gone.
- "Mark Job Done" (green check-circle icon) appears.
- Click "Mark Job Done". After the gates pass (or the manager bypasses), verify:
- Either "Issue Certs" (if
coc/coc_thicknesspart) or "Schedule Delivery" (ifnone) appears in its place.
- Either "Issue Certs" (if
If a button doesn't appear when expected, run in odoo shell:
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:
# -*- 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:
from . import fp_delivery
- Step 3: Verify dependency direction
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:
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
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:
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
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:
# ---------------- 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
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
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
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/...andK:/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:
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
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
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
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_actionselection keys (mark_done,issue_certs,schedule_delivery,mark_shipped,closed) match the dispatcher'saction_mapkeys and the view'sinvisible=expressions.x_fc_job_idis referenced consistently in cert auto-create + draft-cert lookup + cert gate. - Out of scope (confirmed in spec): Send Certs to Customer button,
on_job_doneinvoice strategy,fp.job.state↔workflow_state_idreconciliation — these are Phase 2+ and are not included in any task here.