diff --git a/fusion-plating/docs/superpowers/plans/2026-05-12-job-milestone-cascade.md b/fusion-plating/docs/superpowers/plans/2026-05-12-job-milestone-cascade.md new file mode 100644 index 00000000..9ff8e5cb --- /dev/null +++ b/fusion-plating/docs/superpowers/plans/2026-05-12-job-milestone-cascade.md @@ -0,0 +1,1289 @@ +# 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 `