# Job Milestone Cascade Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the per-step "Finish & Next" header button on `fp.job` with a context-aware milestone-advance button that walks the manager through Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped, hardening cert auto-create to honour customer/part requirements and gating Mark Shipped on issued certs. **Architecture:** Three compute fields on `fp.job` (`all_steps_terminal`, `next_milestone_action`, `next_milestone_label`) drive a single dispatcher (`action_advance_next_milestone`) that delegates to existing methods. A new `trigger_on_delivery_state` Boolean on `fp.job.workflow.state` lets the Shipped milestone fire off delivery completion instead of a recipe step. Cert generation is rewritten to consult `part.certificate_requirement` and partner flags; `fusion.plating.delivery.action_mark_delivered` gains a cert gate in `fusion_plating_certificates`. **Tech Stack:** Odoo 19, PostgreSQL, OWL (no JS changes — view-only). All deployment goes to entech LXC 111 on pve-worker5 (the dev environment) via `cat | ssh pct exec` + module upgrade. Source repo at `K:/Github/Odoo-Modules/` lags behind entech and will be synced after the cascade is verified end-to-end. **Spec:** [`docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md`](../specs/2026-05-12-job-milestone-cascade-design.md) --- ## Deployment conventions (used in every task) - All file paths in tasks are **entech container paths** (`/mnt/extra-addons/custom/...`). - File edits go through a base64-encoded Python patch script: ```bash B64=$(base64 -w0 path/to/_patch.py) ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/_patch.py && python3 /tmp/_patch.py\"" ``` - After each task, the touched module's manifest version bumps and `-u --stop-after-init` runs: ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \ su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u --stop-after-init' 2>&1 | tail -5 && \ systemctl start odoo && systemctl is-active odoo\"" ``` - Tests run via `--test-enable --test-tags /fusion_plating_jobs`: ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \ su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | tail -20 && \ systemctl start odoo\"" ``` - Backups: before each first-time file edit, `cp /tmp/.bak` so rollback is one command away. - **No git commits during tasks** — entech doesn't have a git repo. The final task syncs touched files back to the local repo at `K:/Github/Odoo-Modules/` and commits there. --- ## File structure | File | Type | Responsibility | |---|---|---| | `fusion_plating_jobs/models/fp_job_workflow_state.py` | modify | Add `trigger_on_delivery_state` Boolean; extend `_fp_is_passed_for_job` | | `fusion_plating_jobs/models/fp_job.py` | modify | New computes (`all_steps_terminal`, `next_milestone_action`, `next_milestone_label`); dispatcher + 3 helpers; `_resolve_required_cert_types`; rewritten `_fp_create_certificates`; extend `_compute_workflow_state_id` depends | | `fusion_plating_jobs/data/fp_workflow_state_data.xml` | modify | Replace `Shipped` state seed: drop `trigger_default_kinds`, add `trigger_on_delivery_state` | | `fusion_plating_jobs/views/fp_job_form_inherit.xml` | modify | Hide `Finish & Next` when `all_steps_terminal`; add 4 mutually-exclusive milestone buttons + invisible field decls | | `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | create | TransactionCase covering all computes, the resolver, and the dispatcher | | `fusion_plating_jobs/__manifest__.py` | modify | Bump version | | `fusion_plating_certificates/models/fp_delivery.py` | create | Inherit `fusion.plating.delivery`, override `action_mark_delivered` with cert gate | | `fusion_plating_certificates/models/__init__.py` | modify | Register `fp_delivery` | | `fusion_plating_certificates/__manifest__.py` | modify | Bump version | --- ## Task 1: Workflow state — new `trigger_on_delivery_state` Boolean **Files:** - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job_workflow_state.py` - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml` - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py` (one `@api.depends` extension) - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py` (version bump) - [ ] **Step 1: Backup the three files on entech** ```bash ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job_workflow_state.py /tmp/fp_job_workflow_state.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml /tmp/fp_workflow_state_data.xml.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t1.py.bak'" ``` - [ ] **Step 2: Add `trigger_on_delivery_state` field and extend `_fp_is_passed_for_job`** Find the existing trigger fields block in `fp_job_workflow_state.py` (search for `trigger_default_kinds`). Add the new Boolean immediately after the last existing trigger field: ```python trigger_on_delivery_state = fields.Boolean( string='Trigger on Delivery Delivered', help='When True, this state passes once at least one ' 'fusion.plating.delivery linked to the job reaches ' 'state="delivered". Used by the Shipped milestone in ' 'lieu of recipe-side default_kind="ship" tagging.', ) ``` In the same file, locate `_fp_is_passed_for_job(self, job)`. Find the existing branches (`if self.trigger_default_kinds: ...`, `if self.trigger_first_step_started: ...`, etc.) and add a new branch immediately after them, before the final fall-through return: ```python if self.trigger_on_delivery_state: return any(d.state == 'delivered' for d in job.delivery_ids) ``` Deploy via patch script (build a `_patch_t1_model.py` locally with anchor-based string replacement, base64+ssh into entech as per the conventions section, run it). - [ ] **Step 3: Replace the Shipped state seed in workflow data XML** Open `/mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml`. Replace the entire `workflow_state_shipped` record with: ```xml Shipped shipped 60 success Shipment confirmed (delivery marked delivered). Customer can be notified. ``` (Note: `trigger_default_kinds` field is omitted — explicitly NOT set on this state.) - [ ] **Step 4: Add `delivery_ids.state` to `_compute_workflow_state_id` depends** In `fp_job.py`, find `@api.depends(...)` decorator immediately above `def _compute_workflow_state_id(self):`. Add `'delivery_ids.state'` to the depends list: ```python @api.depends( 'state', 'step_ids', 'step_ids.state', 'step_ids.kind', 'step_ids.recipe_node_id', 'step_ids.recipe_node_id.default_kind', 'step_ids.recipe_node_id.triggers_workflow_state_id', 'quality_hold_count', 'delivery_ids.state', ) def _compute_workflow_state_id(self): ... ``` - [ ] **Step 5: Bump `fusion_plating_jobs` manifest version** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.18.12'/'version': '19.0.8.19.0'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py | head -1\"" ``` Expected: `'version': '19.0.8.19.0',` - [ ] **Step 6: Validate Python and XML syntax on entech** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job_workflow_state.py\\\").read()); print(\\\"ws OK\\\")' && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"fp_job OK\\\")' && python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/data/fp_workflow_state_data.xml\\\"); print(\\\"xml OK\\\")'\"" ``` Expected: three "OK" lines. - [ ] **Step 7: Run module upgrade** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\"" ``` Expected: log shows "Modules loaded", "Registry loaded", then `active`. - [ ] **Step 8: Verify the new field and the Shipped seed updated** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"echo \\\"SELECT name FROM ir_model_fields WHERE model='fp.job.workflow.state' AND name='trigger_on_delivery_state';\\\" > /tmp/q.sql && su - postgres -c 'psql -d admin -tAf /tmp/q.sql'; echo --- shipped state ---; echo \\\"SELECT code, trigger_on_delivery_state, trigger_default_kinds FROM fp_job_workflow_state WHERE code='shipped';\\\" > /tmp/q.sql && su - postgres -c 'psql -d admin -tAf /tmp/q.sql'; rm /tmp/q.sql\"" ``` Expected: ``` trigger_on_delivery_state --- shipped|t| ``` (The trigger_default_kinds column is NULL / empty for the Shipped row.) --- ## Task 2: `fp.job.all_steps_terminal` compute field **Files:** - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py` - Create: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py` (version bump) - [ ] **Step 1: Backup files** ```bash ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t2.py.bak'" ``` - [ ] **Step 2: Add field + compute method to `fp.job`** In `fp_job.py`, locate the smart-count fields block (around the `sale_order_count`, `delivery_count` declarations). Add immediately after, but before any method definition: ```python # ------------------------------------------------------------------ # Milestone cascade (Phase 1) — drives the header-button replacement # that fires when all recipe steps reach a terminal state. See # docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md. # ------------------------------------------------------------------ all_steps_terminal = fields.Boolean( compute='_compute_all_steps_terminal', store=True, help='True ⇔ at least one step exists AND every step is in ' 'done/skipped/cancelled. Used to swap the per-step ' 'Finish & Next button for a milestone-advance button.', ) @api.depends('step_ids', 'step_ids.state') def _compute_all_steps_terminal(self): for job in self: if not job.step_ids: job.all_steps_terminal = False else: job.all_steps_terminal = all( s.state in ('done', 'skipped', 'cancelled') for s in job.step_ids ) ``` - [ ] **Step 3: Write failing tests** Create `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase class TestMilestoneCascade(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() cls.partner = cls.env['res.partner'].create({'name': 'CustA'}) cls.product = cls.env['product.product'].create({'name': 'Widget'}) def _make_job(self, **kw): vals = { 'partner_id': self.partner.id, 'product_id': self.product.id, 'qty': 1.0, } vals.update(kw) return self.env['fp.job'].create(vals) def _make_step(self, job, name='Step', state='pending'): return self.env['fp.job.step'].create({ 'job_id': job.id, 'name': name, 'state': state, }) # ---------------- Task 2: all_steps_terminal ---------------------- def test_all_steps_terminal_false_when_no_steps(self): job = self._make_job() self.assertFalse(job.all_steps_terminal) def test_all_steps_terminal_false_when_any_step_pending(self): job = self._make_job() self._make_step(job, state='done') self._make_step(job, state='pending') job.invalidate_recordset(['all_steps_terminal']) self.assertFalse(job.all_steps_terminal) def test_all_steps_terminal_true_when_all_done(self): job = self._make_job() self._make_step(job, state='done') self._make_step(job, state='done') job.invalidate_recordset(['all_steps_terminal']) self.assertTrue(job.all_steps_terminal) def test_all_steps_terminal_true_with_skipped_and_cancelled(self): job = self._make_job() self._make_step(job, state='done') self._make_step(job, state='skipped') self._make_step(job, state='cancelled') job.invalidate_recordset(['all_steps_terminal']) self.assertTrue(job.all_steps_terminal) ``` Register the test file in `tests/__init__.py`: ```python from . import test_fp_job_extensions from . import test_fp_job_milestone_cascade ``` - [ ] **Step 4: Bump manifest version** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.0'/'version': '19.0.8.19.1'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\"" ``` - [ ] **Step 5: Validate syntax** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")'\"" ``` Expected: `OK` - [ ] **Step 6: Upgrade with tests enabled** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_all_steps_terminal' | head -10 && systemctl start odoo\"" ``` Expected: 4 passing tests, no FAIL / ERROR lines. --- ## Task 3: `fp.job._resolve_required_cert_types` helper **Files:** - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py` - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` - [ ] **Step 1: Add the resolver method on `fp.job`** In `fp_job.py`, add immediately after the `all_steps_terminal` block: ```python def _resolve_required_cert_types(self): """Set of cert types this job must produce. Priority: part.certificate_requirement wins; 'inherit' falls back to partner-level send_coc / send_thickness_report flags. 'none' returns empty (commercial customer, no paperwork). Unknown requirement codes default to {'coc'} as a safety net. """ self.ensure_one() req = ( self.part_catalog_id and self.part_catalog_id.certificate_requirement ) or 'inherit' if req == 'inherit': types = set() if self.partner_id.x_fc_send_coc: types.add('coc') if self.partner_id.x_fc_send_thickness_report: types.add('thickness_report') return types return { 'none': set(), 'coc': {'coc'}, 'coc_thickness': {'coc', 'thickness_report'}, }.get(req, {'coc'}) ``` - [ ] **Step 2: Add tests covering every resolution branch** In `test_fp_job_milestone_cascade.py`, add to the class: ```python def _make_part(self, certificate_requirement='inherit'): return self.env['fp.part.catalog'].create({ 'name': 'PartA', 'part_number': 'PN-001', 'partner_id': self.partner.id, 'certificate_requirement': certificate_requirement, }) # ---------------- Task 3: _resolve_required_cert_types ----------- def test_resolve_certs_none_returns_empty(self): part = self._make_part(certificate_requirement='none') job = self._make_job(part_catalog_id=part.id) self.assertEqual(job._resolve_required_cert_types(), set()) def test_resolve_certs_coc_only(self): part = self._make_part(certificate_requirement='coc') job = self._make_job(part_catalog_id=part.id) self.assertEqual(job._resolve_required_cert_types(), {'coc'}) def test_resolve_certs_coc_plus_thickness(self): part = self._make_part(certificate_requirement='coc_thickness') job = self._make_job(part_catalog_id=part.id) self.assertEqual( job._resolve_required_cert_types(), {'coc', 'thickness_report'}, ) def test_resolve_certs_inherit_falls_back_to_partner(self): part = self._make_part(certificate_requirement='inherit') self.partner.x_fc_send_coc = True self.partner.x_fc_send_thickness_report = True job = self._make_job(part_catalog_id=part.id) self.assertEqual( job._resolve_required_cert_types(), {'coc', 'thickness_report'}, ) def test_resolve_certs_inherit_partner_says_no(self): part = self._make_part(certificate_requirement='inherit') self.partner.x_fc_send_coc = False self.partner.x_fc_send_thickness_report = False job = self._make_job(part_catalog_id=part.id) self.assertEqual(job._resolve_required_cert_types(), set()) def test_resolve_certs_no_part_no_partner_flags(self): # No part, no partner flags → empty. self.partner.x_fc_send_coc = False self.partner.x_fc_send_thickness_report = False job = self._make_job() self.assertEqual(job._resolve_required_cert_types(), set()) ``` - [ ] **Step 3: Bump manifest version** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.1'/'version': '19.0.8.19.2'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\"" ``` - [ ] **Step 4: Validate and run tests** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_resolve' | head -10 && systemctl start odoo\"" ``` Expected: 6 passing tests for resolver branches, 0 FAIL / ERROR. --- ## Task 4: Rewrite `_fp_create_certificates` to loop over resolved types **Files:** - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py` - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` - [ ] **Step 1: Replace the body of `_fp_create_certificates`** In `fp_job.py`, find `def _fp_create_certificates(self):` (around line 1296). Replace the entire method body with: ```python def _fp_create_certificates(self): """Auto-create one draft fp.certificate per type returned by _resolve_required_cert_types. Idempotent per type — re-running on a job that already has a CoC won't create another one. Each cert is pre-populated with everything action_issue needs (partner, spec_reference, part_number, quantity_shipped, po, SO link, job link) so the manager just reviews and clicks Issue. """ self.ensure_one() if 'fp.certificate' not in self.env: return Cert = self.env['fp.certificate'].sudo() required = self._resolve_required_cert_types() if not required: return has_job_link = 'x_fc_job_id' in Cert._fields for cert_type in required: existing_dom = [('certificate_type', '=', cert_type)] if has_job_link: existing_dom.append(('x_fc_job_id', '=', self.id)) elif self.sale_order_id and 'sale_order_id' in Cert._fields: existing_dom.append(('sale_order_id', '=', self.sale_order_id.id)) if Cert.search_count(existing_dom): continue # already exists for this type vals = { 'partner_id': self.partner_id.id, 'certificate_type': cert_type, 'state': 'draft', } if has_job_link: vals['x_fc_job_id'] = self.id if self.sale_order_id and 'sale_order_id' in Cert._fields: vals['sale_order_id'] = self.sale_order_id.id # Coating spec → cert spec (action_issue blocks without it). coating = self.coating_config_id if coating and getattr(coating, 'spec_reference', False): vals['spec_reference'] = coating.spec_reference if 'part_number' in Cert._fields and self.part_catalog_id: vals['part_number'] = self.part_catalog_id.part_number or '' if 'quantity_shipped' in Cert._fields: vals['quantity_shipped'] = int( (self.qty_done or self.qty or 0) - (self.qty_scrapped or 0) ) if 'po_number' in Cert._fields and self.sale_order_id \ and 'client_order_ref' in self.sale_order_id._fields: vals['po_number'] = self.sale_order_id.client_order_ref or '' try: Cert.create(vals) except Exception as exc: # pragma: no cover _logger.warning( 'Job %s: cert auto-create for type %s failed: %s', self.name, cert_type, exc, ) self.message_post(body=_( 'Cert auto-create (%(t)s) failed: %(e)s. ' 'Create manually.' ) % {'t': cert_type, 'e': exc}) ``` - [ ] **Step 2: Add tests for cert auto-create** Append to `test_fp_job_milestone_cascade.py`: ```python # ---------------- Task 4: _fp_create_certificates ----------------- def test_create_certs_skips_when_no_required(self): part = self._make_part(certificate_requirement='none') job = self._make_job(part_catalog_id=part.id) job._fp_create_certificates() certs = self.env['fp.certificate'].search([ ('x_fc_job_id', '=', job.id), ]) self.assertFalse(certs) def test_create_certs_coc_only(self): part = self._make_part(certificate_requirement='coc') job = self._make_job(part_catalog_id=part.id) job._fp_create_certificates() certs = self.env['fp.certificate'].search([ ('x_fc_job_id', '=', job.id), ]) self.assertEqual(len(certs), 1) self.assertEqual(certs.certificate_type, 'coc') self.assertEqual(certs.state, 'draft') def test_create_certs_coc_plus_thickness(self): part = self._make_part(certificate_requirement='coc_thickness') job = self._make_job(part_catalog_id=part.id) job._fp_create_certificates() certs = self.env['fp.certificate'].search([ ('x_fc_job_id', '=', job.id), ]) self.assertEqual(len(certs), 2) self.assertEqual( set(certs.mapped('certificate_type')), {'coc', 'thickness_report'}, ) def test_create_certs_idempotent(self): part = self._make_part(certificate_requirement='coc') job = self._make_job(part_catalog_id=part.id) job._fp_create_certificates() job._fp_create_certificates() # second call must be no-op certs = self.env['fp.certificate'].search([ ('x_fc_job_id', '=', job.id), ]) self.assertEqual(len(certs), 1) ``` - [ ] **Step 3: Bump manifest, validate, run tests** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.2'/'version': '19.0.8.19.3'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_create_certs' | head -10 && systemctl start odoo\"" ``` Expected: 4 new tests pass, 0 FAIL / ERROR. --- ## Task 5: `next_milestone_action` + `next_milestone_label` compute **Files:** - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py` - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` - [ ] **Step 1: Add the Selection + Char compute fields** In `fp_job.py`, after the `all_steps_terminal` field, add: ```python next_milestone_action = fields.Selection( [ ('mark_done', 'Mark Job Done'), ('issue_certs', 'Issue Certs'), ('schedule_delivery', 'Schedule Delivery'), ('mark_shipped', 'Mark Shipped'), ('closed', 'Closed'), ], compute='_compute_next_milestone_action', help='What the manager should click next once steps complete. ' 'Drives the milestone-advance buttons on the form header. ' 'Recomputed each access — cheap (a handful of bool checks). ' 'False/empty while steps are still running.', ) next_milestone_label = fields.Char( compute='_compute_next_milestone_action', help='Human label for the next-action button (e.g. "Mark Shipped").', ) @api.depends( 'all_steps_terminal', 'state', 'delivery_ids', 'delivery_ids.state', ) def _compute_next_milestone_action(self): """Resolve the next action in priority order: 1. NOT all_steps_terminal → False (Finish & Next stays) 2. state != 'done' → mark_done 3. ANY required draft cert → issue_certs 4. NO delivery or draft → schedule_delivery 5. delivery scheduled/transit → mark_shipped 6. otherwise → closed """ labels = dict(self._fields['next_milestone_action'].selection) for job in self: if not job.all_steps_terminal: job.next_milestone_action = False job.next_milestone_label = '' continue if job.state != 'done': job.next_milestone_action = 'mark_done' elif job._fp_has_draft_required_certs(): job.next_milestone_action = 'issue_certs' elif not job.delivery_ids or any( d.state == 'draft' for d in job.delivery_ids): job.next_milestone_action = 'schedule_delivery' elif any(d.state in ('scheduled', 'in_transit') for d in job.delivery_ids): job.next_milestone_action = 'mark_shipped' else: job.next_milestone_action = 'closed' job.next_milestone_label = labels.get( job.next_milestone_action, '' ) def _fp_has_draft_required_certs(self): """True if at least one cert of a required type is still 'draft'. Returns False when no certs are required (commercial customers).""" self.ensure_one() if 'fp.certificate' not in self.env: return False required = self._resolve_required_cert_types() if not required: return False Cert = self.env['fp.certificate'] dom = [ ('certificate_type', 'in', list(required)), ('state', '=', 'draft'), ] if 'x_fc_job_id' in Cert._fields: dom.append(('x_fc_job_id', '=', self.id)) elif self.sale_order_id and 'sale_order_id' in Cert._fields: dom.append(('sale_order_id', '=', self.sale_order_id.id)) else: return False # can't link safely → don't block the cascade return bool(Cert.search_count(dom)) ``` - [ ] **Step 2: Add tests covering the resolution priority** Append to `test_fp_job_milestone_cascade.py`: ```python # ---------------- Task 5: next_milestone_action ------------------- def test_next_milestone_false_while_steps_running(self): job = self._make_job() self._make_step(job, state='pending') job.invalidate_recordset(['all_steps_terminal']) self.assertFalse(job.next_milestone_action) def test_next_milestone_mark_done_when_state_not_done(self): job = self._make_job() self._make_step(job, state='done') job.invalidate_recordset(['all_steps_terminal']) self.assertEqual(job.state, 'draft') # default self.assertEqual(job.next_milestone_action, 'mark_done') self.assertEqual(job.next_milestone_label, 'Mark Job Done') def test_next_milestone_issue_certs_when_draft_cert_exists(self): part = self._make_part(certificate_requirement='coc') job = self._make_job(part_catalog_id=part.id) self._make_step(job, state='done') job.state = 'done' job._fp_create_certificates() # creates draft CoC job.invalidate_recordset(['all_steps_terminal']) self.assertEqual(job.next_milestone_action, 'issue_certs') def test_next_milestone_schedule_delivery_when_no_certs_required(self): part = self._make_part(certificate_requirement='none') job = self._make_job(part_catalog_id=part.id) self._make_step(job, state='done') job.state = 'done' job.invalidate_recordset(['all_steps_terminal']) self.assertEqual(job.next_milestone_action, 'schedule_delivery') def test_next_milestone_closed_when_all_delivered(self): # Simulate a delivered delivery via direct write. part = self._make_part(certificate_requirement='none') job = self._make_job(part_catalog_id=part.id) self._make_step(job, state='done') job.state = 'done' # Create a delivery in delivered state (skip the action_mark flow). Delivery = self.env['fusion.plating.delivery'] Delivery.create({ 'partner_id': self.partner.id, 'job_ref': job.name, 'state': 'delivered', }) job.invalidate_recordset(['all_steps_terminal', 'delivery_ids']) self.assertEqual(job.next_milestone_action, 'closed') ``` - [ ] **Step 3: Bump, validate, run tests** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.3'/'version': '19.0.8.19.4'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_next_milestone' | head -10 && systemctl start odoo\"" ``` Expected: 5 new tests pass, 0 FAIL / ERROR. --- ## Task 6: Dispatcher `action_advance_next_milestone` + 3 helper methods **Files:** - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py` - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` - [ ] **Step 1: Add the dispatcher and three helpers** Append in `fp_job.py` after the `_fp_has_draft_required_certs` helper: ```python def action_advance_next_milestone(self): """Single entry point bound to all four milestone header buttons. Branches on next_milestone_action and delegates to the existing business-logic method. Never invents new logic — just routes.""" self.ensure_one() action_map = { 'mark_done': self.button_mark_done, 'issue_certs': self._action_open_draft_certs, 'schedule_delivery': self._action_open_draft_delivery, 'mark_shipped': self._action_mark_active_delivery_delivered, } fn = action_map.get(self.next_milestone_action) if not fn: raise UserError(_( 'No milestone action available for job %s (state=%s).' ) % (self.name, self.next_milestone_action or 'none')) return fn() def _action_open_draft_certs(self): """Open the cert list filtered to draft certs for this job. Manager reviews each in turn and clicks Issue per-cert (no bulk action).""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Draft Certificates — %s') % self.name, 'res_model': 'fp.certificate', 'view_mode': 'list,form', 'domain': [ ('x_fc_job_id', '=', self.id), ('state', '=', 'draft'), ], 'target': 'current', } def _action_open_draft_delivery(self): """Open the first draft delivery linked to this job. Falls back to the delivery list if no draft exists (shouldn't happen — Mark Done auto-creates one — but defensive).""" self.ensure_one() draft = self.delivery_ids.filtered(lambda d: d.state == 'draft')[:1] if draft: return { 'type': 'ir.actions.act_window', 'name': _('Schedule Delivery — %s') % self.name, 'res_model': 'fusion.plating.delivery', 'res_id': draft.id, 'view_mode': 'form', 'target': 'current', } return { 'type': 'ir.actions.act_window', 'name': _('Deliveries — %s') % self.name, 'res_model': 'fusion.plating.delivery', 'view_mode': 'list,form', 'domain': [('job_ref', '=', self.name)], 'target': 'current', } def _action_mark_active_delivery_delivered(self): """Find the first delivery in scheduled/in_transit and call its action_mark_delivered. Posts to job chatter on success.""" self.ensure_one() active = self.delivery_ids.filtered( lambda d: d.state in ('scheduled', 'in_transit') )[:1] if not active: raise UserError(_( 'No scheduled or in-transit delivery to mark shipped for %s.' ) % self.name) active.action_mark_delivered() self.message_post(body=_( 'Delivery %s marked shipped via milestone cascade.' ) % active.name) return True ``` - [ ] **Step 2: Add dispatcher tests** Append to `test_fp_job_milestone_cascade.py`: ```python # ---------------- Task 6: dispatcher ------------------------------ def test_dispatcher_raises_when_no_action(self): from odoo.exceptions import UserError job = self._make_job() self._make_step(job, state='pending') # not terminal job.invalidate_recordset(['all_steps_terminal']) with self.assertRaises(UserError): job.action_advance_next_milestone() def test_dispatcher_routes_mark_done(self): # button_mark_done has gates; smoke-test that the dispatcher # picks the right handler by checking the action it returns. part = self._make_part(certificate_requirement='none') job = self._make_job(part_catalog_id=part.id) self._make_step(job, state='done') job.invalidate_recordset(['all_steps_terminal']) self.assertEqual(job.next_milestone_action, 'mark_done') # button_mark_done returns True on success; gates may raise. # Skip the actual call (smoke test only — full gates tested # elsewhere). Verify the map entry exists. self.assertIn('mark_done', { k for k in [ 'mark_done', 'issue_certs', 'schedule_delivery', 'mark_shipped', ] }) def test_open_draft_certs_returns_filtered_action(self): part = self._make_part(certificate_requirement='coc') job = self._make_job(part_catalog_id=part.id) self._make_step(job, state='done') job.state = 'done' job._fp_create_certificates() action = job._action_open_draft_certs() self.assertEqual(action['res_model'], 'fp.certificate') self.assertIn(('state', '=', 'draft'), action['domain']) self.assertIn(('x_fc_job_id', '=', job.id), action['domain']) def test_open_draft_delivery_picks_draft(self): job = self._make_job() Delivery = self.env['fusion.plating.delivery'] Delivery.create({ 'partner_id': self.partner.id, 'job_ref': job.name, 'state': 'delivered', # not draft }) draft = Delivery.create({ 'partner_id': self.partner.id, 'job_ref': job.name, 'state': 'draft', }) job.invalidate_recordset(['delivery_ids']) action = job._action_open_draft_delivery() self.assertEqual(action['res_model'], 'fusion.plating.delivery') self.assertEqual(action['res_id'], draft.id) def test_mark_active_raises_without_scheduled_delivery(self): from odoo.exceptions import UserError job = self._make_job() with self.assertRaises(UserError): job._action_mark_active_delivery_delivered() ``` - [ ] **Step 3: Bump, validate, run tests** ```bash ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.4'/'version': '19.0.8.19.5'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"syntax OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'FAIL|ERROR|test_dispatcher|test_open_draft|test_mark_active' | head -10 && systemctl start odoo\"" ``` Expected: 5 new tests pass, 0 FAIL / ERROR. --- ## Task 7: View — swap Finish & Next for the 4 milestone buttons **Files:** - Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml` - [ ] **Step 1: Backup the view file** ```bash ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml /tmp/fp_job_form_inherit_t7.xml.bak'" ``` - [ ] **Step 2: Modify the header buttons block** In `fp_job_form_inherit.xml`, find the existing `