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