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

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-init runs:
    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>.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

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:

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.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:

@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
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 &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
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:

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/... 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:

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_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.stateworkflow_state_id reconciliation — these are Phase 2+ and are not included in any task here.