7 Commits

Author SHA1 Message Date
gsinghpal
b0070afc1b feat(jobs): step qty gate + partial-qty + display rename
Three coupled shop-floor corrections:
- fp.job._compute_display_name: renders "Work Order # 00011" in
  form header, breadcrumbs, M2O dropdowns, and error messages.
  DB name stays as WH/JOB/00011 - existing chatter/cert/delivery
  references unchanged.
- fp.job.step.button_finish: refuses if qty_at_step > 0 AND a
  downstream pending/ready step exists. Last runnable step is
  exempt (parts complete in place). Manager bypass via
  fp_skip_qty_gate=True context key.
- fp.job.step.action_complete_one_to_next: new per-row button
  "Complete 1 -> Next" for streaming flow (large parts going
  one-by-one). Records move(qty=1) to next step; if drain takes
  qty_at_step to 0, auto-finishes source + auto-starts destination
  via existing action_finish_and_advance.
- fp.job.step._fp_record_one_piece_auto_move: auto-move shim
  wired into action_finish_and_advance. qty=1 + downstream =>
  silently record move(1). qty>1 + downstream => raise pointing
  at Complete 1 -> Next. Last step always allowed.
- 16 new TestQtyGate tests covering gate / shim / auto-finish /
  last-step exemption / display rename / Move wizard zero-qty.

Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:31:56 -04:00
gsinghpal
9e39e41b0d docs: step qty gate + display rename implementation plan
7-task plan: display rename (compute + view), qty gate on
button_finish with last-step exemption, action_complete_one_to_next
row button, auto-move shim on Finish & Next, view additions,
end-to-end smoke test, and repo sync-back.

14 unit tests in the existing TestQtyGate class covering all five
state-machine branches plus display-name format and Move wizard
zero-qty regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:10:58 -04:00
gsinghpal
f4c41de91c docs: step qty gate + partial-qty + display rename design spec
Three coupled shop-floor corrections:
1. Job display rename: WH/JOB/00011 -> Work Order # 00011
   via display_name compute (name stays stable for DB refs)
2. Quantity gate on button_finish: refuses if qty_at_step > 0
   AND there is a downstream pending/ready step (last step exempt)
3. Partial-qty UX: new action_complete_one_to_next per-row button
   for streaming flow; auto-move shim on Finish for 1-of-1; Move
   wizard unchanged (already has zero-qty + over-qty guards)

Spec covers architecture, state transitions, test plan,
files-touched matrix, and explicit Out of Scope (qty_done auto-tick,
per-step scrap, cert PDF display).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:07:24 -04:00
gsinghpal
913311653f feat(jobs+certs): milestone-cascade Phase 1 + session patch catch-up
Implements the milestone-cascade design (Phase 1) and catches the
fusion_plating_jobs / fusion_plating_certificates source up to entech.

Milestone cascade (this PR's core):
- fp.job: new computes all_steps_terminal, next_milestone_action,
  next_milestone_label; dispatcher action_advance_next_milestone with
  3 helpers (_action_open_draft_certs, _action_open_draft_delivery,
  _action_mark_active_delivery_delivered); _resolve_required_cert_types
  resolver; _fp_create_certificates rewritten to honour
  part.certificate_requirement + partner flags + loop over resolved
  cert types
- fp.job.workflow.state: new trigger_on_delivery_state Boolean;
  _fp_is_passed_for_job extended with delivery-state branch;
  Shipped state seed reroutes from default_kind=ship to the new trigger
- View: hide Finish & Next when all_steps_terminal; add 4 mutually-
  exclusive milestone buttons (Mark Job Done / Issue Certs / Schedule
  Delivery / Mark Shipped) bound to one dispatcher
- Cert gate (fusion_plating_certificates/models/fp_delivery.py):
  action_mark_delivered hard-blocks on draft certs; manager bypass
  via fp_skip_cert_gate=True context key
- 24 unit tests in test_fp_job_milestone_cascade.py covering computes,
  resolver, dispatcher, cert gate
- Spec: docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md
- Plan: docs/superpowers/plans/2026-05-12-job-milestone-cascade.md

Other entech changes caught up in this sync (from earlier session
patches not previously committed):
- fp.job version bump series 18.x → 19.0
- res_users_views.xml addition (signature widget in user prefs)
- racking inspection smart button removal
- various view/manifest touch-ups

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:40:25 -04:00
gsinghpal
1c1f517847 docs: job milestone cascade implementation plan (Phase 1)
10-task plan implementing the milestone cascade design — bite-sized
steps with exact code, deployment commands, and verification. Covers
compute fields, dispatcher, cert resolver + auto-create rewrite,
workflow trigger reroute, view swap, cert gate, e2e smoke test, and
repo sync-back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:07:16 -04:00
gsinghpal
b2592d70f8 docs: job milestone cascade design spec (Phase 1)
Replaces per-step Finish & Next with a context-aware milestone-advance
button cycling Mark Job Done → Issue Certs → Schedule Delivery →
Mark Shipped. Architecture, cascade, gates, files-touched, and the
cert-gate hard-block decision are all captured for implementation
planning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:01:10 -04:00
gsinghpal
03f14c2c40 changes 2026-05-11 17:57:04 -04:00
21 changed files with 4169 additions and 238 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,969 @@
# Step Qty Gate, Partial-Qty Handling, and Job Display Rename — 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:** Add a quantity gate on `fp.job.step.button_finish` (with last-step exemption), introduce a per-row `Complete 1 → Next` action for streaming flow, add an auto-move shim on Finish & Next for the 1-of-1 case, and override `fp.job.display_name` so jobs render as `Work Order # 00011` instead of `WH/JOB/00011`.
**Architecture:** Five small Python changes (one compute + one gate + one action + one helper + manager-bypass keys) on `fp.job` and `fp.job.step`, plus two view edits (form `<h1>` and embedded step list row button). Move wizard's existing zero-qty + over-qty guards stay; one regression test added for them. All changes deploy on entech, sync back to the local repo as the final task.
**Tech Stack:** Odoo 19, PostgreSQL. No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md`](../specs/2026-05-12-step-qty-gate-and-display-rename-design.md)
---
## Deployment conventions
Same pattern as the milestone-cascade plan that just shipped:
- File paths are **entech container paths** (`/mnt/extra-addons/custom/...`).
- Edits go via base64-encoded Python patch scripts:
```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 Python change: manifest version bump, then upgrade module:
```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 via:
```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 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && \
systemctl start odoo\""
```
- Backups: `cp <file> /tmp/<basename>.bak` before the first patch of any file.
- No git commits during tasks. Final task (Task 7) syncs touched files back to `K:/Github/Odoo-Modules/` and commits there.
---
## File structure
| File | Type | Responsibility |
|---|---|---|
| `fusion_plating_jobs/models/fp_job.py` | modify | Add `_compute_display_name` override (renames `WH/JOB/00011` → `Work Order # 00011`). |
| `fusion_plating/models/fp_job_step.py` | modify | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move`; wire the helper into `action_finish_and_advance`. |
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | modify | `<h1>` binds `display_name`; per-row "Complete 1 → Next" button. |
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | modify | Append `TestQtyGate` class with 14 tests. |
| `fusion_plating/__manifest__.py` | modify | Version bump. |
| `fusion_plating_jobs/__manifest__.py` | modify | Version bump. |
---
## Task 1: Display rename — `Work Order # 00011`
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
- [ ] **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_t1.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml /tmp/fp_job_form_inherit_t1.xml.bak'"
```
- [ ] **Step 2: Add `_compute_display_name` to `fp.job`**
Locate the existing class declaration in `fp_job.py` (around the first `class FpJob(models.Model)` line, then the `_inherit = 'fp.job'` block). Find the existing `name` field declaration (around line 62 — `name = fields.Char(...)`). Add the new compute method immediately after the existing field declarations on the class (any spot inside the class body before existing `@api.depends` methods is fine; convention is to put it near the field it depends on).
Insert:
```python
@api.depends('name')
def _compute_display_name(self):
"""Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
human-facing surface (form header, breadcrumbs, M2O dropdowns,
smart-button titles, error messages). The DB `name` is unchanged
so existing certs / deliveries / chatter references don't break.
"""
for job in self:
if job.name and '/' in job.name:
suffix = job.name.rsplit('/', 1)[-1]
job.display_name = _('Work Order # %s') % suffix
else:
job.display_name = job.name or ''
```
Use a patch script with anchor-based string replacement. The anchor should be unique enough to find exactly one insertion site — pick a stable nearby field declaration (e.g. the `state` field's closing `)` if it's unique).
- [ ] **Step 3: Bind `display_name` in the form header**
In `fp_job_form_inherit.xml`, find the `<h1>` block in the sheet header that currently binds `name`:
Search anchor:
```xml
<h1><field name="name"/></h1>
```
Replace with:
```xml
<h1><field name="display_name"/></h1>
```
If the file uses a slightly different markup (e.g. with extra attributes like `class=...` or `readonly=...`), keep those attributes and just change `name="name"` to `name="display_name"`.
- [ ] **Step 4: Bump fusion_plating_jobs manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"CUR=\\\$(grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py | head -1 | grep -oP '\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+') && echo \\\"current: \\\$CUR\\\"\""
```
Bump the last component (`19.0.8.19.6` → `19.0.8.19.7`):
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.6'/'version': '19.0.8.19.7'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
```
(If the current version is different from `19.0.8.19.6` because Phase 1 work iterated more, substitute the actual current version.)
- [ ] **Step 5: Validate Python + XML 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()); print(\\\"py OK\\\")' && 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\\\")'\""
```
Expected: `py OK` and `xml OK`.
- [ ] **Step 6: Upgrade 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 --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
```
Expected: `Modules loaded`, `Registry loaded`, then `active`.
- [ ] **Step 7: Verify display_name renders correctly via odoo shell**
```bash
SCRIPT='job = env["fp.job"].search([("name", "like", "WH/JOB/")], limit=1)
print(">>> name=", job.name)
print(">>> display_name=", job.display_name)'
B64=$(echo -n "$SCRIPT" | base64 -w0)
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/check.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/check.py' 2>&1 | grep '>>>'\""
```
Expected:
```
>>> name= WH/JOB/00011
>>> display_name= Work Order # 00011
```
---
## Task 2: Quantity gate on `button_finish`
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Backup**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py /tmp/fp_job_step_t2.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py /tmp/test_fp_job_milestone_cascade_t2.py.bak'"
```
- [ ] **Step 2: Add quantity gate to `button_finish`**
Find the existing method in `fp_job_step.py` (around line 385). The current opening looks like:
```python
def button_finish(self):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
```
Use a patch script to inject the quantity gate immediately after the existing `state != 'in_progress'` check. New text:
```python
def button_finish(self):
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
# Quantity gate: refuses if parts still parked AND there's a
# downstream step to move them to. Last runnable step is
# exempt — parts finishing there complete in place.
if not skip_qty_gate and step.qty_at_step > 0:
has_downstream = step.job_id.step_ids.filtered(
lambda s: s.sequence > step.sequence
and s.state in ('pending', 'ready')
)
if has_downstream:
raise UserError(_(
"Step '%(name)s' still has %(n)d part(s) parked "
"— move them to the next step before finishing. "
"Use the row's 'Complete 1 → Next' or 'Move…' "
"button."
) % {'name': step.name, 'n': step.qty_at_step})
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
```
Patch script uses the existing method-opening anchor (`def button_finish(self):\n for step in self:\n if step.state != 'in_progress':`) and replaces with the new opening.
- [ ] **Step 3: Add `TestQtyGate` test class skeleton + 3 gate tests**
Append to `test_fp_job_milestone_cascade.py`:
```python
class TestQtyGate(TransactionCase):
"""Step-level quantity gate + partial-qty handling.
Covers:
- button_finish blocks when qty_at_step > 0 AND downstream
steps exist (mid-recipe)
- manager bypass via fp_skip_qty_gate=True
- last-runnable-step exemption (qty_at_step > 0 allowed)
- action_complete_one_to_next (Task 3)
- auto-move shim on action_finish_and_advance (Task 4)
- display_name rename (Task 1)
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
cls.product = cls.env['product.product'].create({
'name': 'QtyWidget',
})
def _make_job(self, qty=3, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': qty,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def _make_step(self, job, name='Step', sequence=10, state='pending'):
return self.env['fp.job.step'].create({
'job_id': job.id,
'name': name,
'sequence': sequence,
'state': state,
})
def _make_two_step_chain(self, qty=3):
"""Create a job with two steps; the first is in_progress
with `qty` parts parked, the second is ready. Returns
(job, step1, step2)."""
job = self._make_job(qty=qty)
step1 = self._make_step(
job, name='Plate', sequence=10, state='in_progress',
)
step2 = self._make_step(
job, name='Bake', sequence=20, state='ready',
)
# date_started required by button_finish's timelog close
step1.date_started = fields.Datetime.now()
return job, step1, step2
# ---------------- button_finish gate ----------------------------
def test_button_finish_blocks_when_qty_at_step(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
# First-step seed gives step1 qty_at_step = job.qty = 3
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
with self.assertRaises(UserError) as exc:
step1.button_finish()
self.assertIn('parts parked', str(exc.exception))
def test_button_finish_bypass(self):
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
step1.with_context(fp_skip_qty_gate=True).button_finish()
self.assertEqual(step1.state, 'done')
def test_button_finish_allows_last_step_with_qty(self):
"""Last runnable step is exempt — parts complete in place."""
job = self._make_job(qty=5)
last = self._make_step(
job, name='FinalInspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
self.assertEqual(last.qty_at_step, 5) # first-step seed
# No downstream step → gate exempt
last.button_finish()
self.assertEqual(last.state, 'done')
def test_button_finish_passes_when_qty_zero(self):
"""qty_at_step==0 (already moved out manually) → no gate fires."""
job, step1, step2 = self._make_two_step_chain(qty=2)
# Move all parts out so step1.qty_at_step = 0
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 0)
step1.button_finish()
self.assertEqual(step1.state, 'done')
```
- [ ] **Step 4: Bump fusion_plating manifest version**
Find current version, bump the last component:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py | head -1\""
```
Then bump (assuming current is `19.0.18.14.12`):
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.12'/'version': '19.0.18.14.13'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
```
- [ ] **Step 5: Validate Python**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.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 fusion_plating + fusion_plating_jobs with 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,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && systemctl start odoo\""
```
Expected: 4 `Starting TestQtyGate.test_button_finish_*` lines, no FAIL or ERROR lines for TestQtyGate.
---
## Task 3: `action_complete_one_to_next`
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add `action_complete_one_to_next` method**
Append the new method to `fp_job_step.py` at the end of the `FpJobStep` class (after `button_manager_reset_to_ready` from the milestone-cascade Phase 1 work, since both are recent additions and group together). Patch via append-or-anchor-replace.
Code:
```python
def action_complete_one_to_next(self):
"""One-piece flow shortcut: records move(qty=1) from this step
to the next pending/ready step, drains qty_at_step by 1. If
the drain takes qty_at_step to 0, auto-finishes the source
and starts the destination step (delegates to
action_finish_and_advance)."""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' must be in progress to complete a part."
) % self.name)
if self.qty_at_step < 1:
raise UserError(_(
"No parts parked at step '%s' — nothing to complete."
) % self.name)
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
raise UserError(_(
"Step '%s' is the last runnable step on the job — "
"no downstream step to move into. Finish the step "
"instead (it will close out the job)."
) % self.name)
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
# qty_at_step is computed from moves; force re-read before
# checking whether this was the last part. Without invalidate
# the cache still says "still 1 parked" and auto-finish never
# fires.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step == 0:
return self.action_finish_and_advance()
return True
```
- [ ] **Step 2: Add 4 tests for `action_complete_one_to_next`**
Append to `TestQtyGate` class:
```python
# ---------------- action_complete_one_to_next -------------------
def test_complete_one_to_next_records_move(self):
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
step1.action_complete_one_to_next()
# One move(qty=1) created
moves = self.env['fp.job.step.move'].search([
('from_step_id', '=', step1.id),
])
self.assertEqual(len(moves), 1)
self.assertEqual(moves.qty_moved, 1)
# step1 still in progress, 2 parts left
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.state, 'in_progress')
self.assertEqual(step1.qty_at_step, 2)
def test_complete_one_to_next_auto_finishes_on_last(self):
job, step1, step2 = self._make_two_step_chain(qty=1)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 1)
step1.action_complete_one_to_next()
# Source step done; next step started
self.assertEqual(step1.state, 'done')
self.assertEqual(step2.state, 'in_progress')
def test_complete_one_to_next_blocks_when_empty(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=2)
# Move all out first → qty_at_step = 0
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
with self.assertRaises(UserError) as exc:
step1.action_complete_one_to_next()
self.assertIn('nothing to complete', str(exc.exception))
def test_complete_one_to_next_blocks_when_no_next_step(self):
from odoo.exceptions import UserError
job = self._make_job(qty=3)
last = self._make_step(
job, name='Inspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
with self.assertRaises(UserError) as exc:
last.action_complete_one_to_next()
self.assertIn('last runnable step', str(exc.exception))
def test_complete_one_to_next_blocks_when_not_in_progress(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.state = 'pending' # not in_progress
with self.assertRaises(UserError) as exc:
step1.action_complete_one_to_next()
self.assertIn('must be in progress', str(exc.exception))
```
- [ ] **Step 3: Bump fusion_plating manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.13'/'version': '19.0.18.14.14'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
```
- [ ] **Step 4: Validate + run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.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,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_complete_one_to_next.*(FAIL|ERROR|Starting)' | head -15 && systemctl start odoo\""
```
Expected: 5 `Starting` lines (the test from Step 2 plus 4 here), zero FAIL/ERROR.
---
## Task 4: Auto-move shim on Finish & Next
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add `_fp_record_one_piece_auto_move` helper**
Find the existing `action_finish_and_advance` method on `fp.job.step` (search for `def action_finish_and_advance`). It probably looks like:
```python
def action_finish_and_advance(self):
"""Finish this step and auto-start the next pending/ready
step (Steelhead-style per-row button)."""
self.ensure_one()
if self.state == 'in_progress':
self.button_finish()
# ...rest: pick next step + button_start
```
Add the helper as a sibling method, then wire it in. New code:
```python
def _fp_record_one_piece_auto_move(self):
"""Decide whether to silently record a move(qty=1) before
the step finishes. Five cases:
- qty_at_step == 0: nothing to do (parts already moved).
- last runnable step: parts complete in place; no move.
- qty_at_step == 1 + downstream: record move(1).
- qty_at_step > 1 + downstream: raise.
- qty_at_step > 1 + last step: allow (parts complete in
place; qty_done auto-tick is Phase 2).
Called from action_finish_and_advance just before
button_finish.
"""
self.ensure_one()
qty = self.qty_at_step
if qty <= 0:
return False
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
# Last runnable step: parts complete in place.
return False
if qty > 1:
raise UserError(_(
"Step '%s' still has %d parts here — use the row's "
"'Complete 1 → Next' button (for one-by-one flow) "
"or the 'Move…' wizard (for batched flow) to drain "
"the step before finishing."
) % (self.name, qty))
# qty == 1 + next_step exists → record move silently.
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
return True
```
- [ ] **Step 2: Wire the helper into `action_finish_and_advance`**
Find `action_finish_and_advance`. The current code likely starts:
```python
def action_finish_and_advance(self):
self.ensure_one()
if self.state == 'in_progress':
self.button_finish()
```
Insert the helper call before `button_finish`:
```python
def action_finish_and_advance(self):
self.ensure_one()
if self.state == 'in_progress':
# Auto-move shim: for qty_at_step==1 + downstream, record a
# move(qty=1) so the qty gate in button_finish passes. Raises
# for qty>1 with a friendly pointer to Complete 1 → Next.
self._fp_record_one_piece_auto_move()
self.button_finish()
```
The patch script uses the existing method's `self.ensure_one()\n if self.state == 'in_progress':\n self.button_finish()` as the anchor.
- [ ] **Step 3: Add 4 auto-move shim tests**
Append to `TestQtyGate`:
```python
# ---------------- auto-move shim on Finish & Next ---------------
def test_finish_and_advance_auto_move_for_qty_1(self):
job, step1, step2 = self._make_two_step_chain(qty=1)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 1)
step1.action_finish_and_advance()
# Move(qty=1) recorded silently
moves = self.env['fp.job.step.move'].search([
('from_step_id', '=', step1.id),
])
self.assertEqual(len(moves), 1)
self.assertEqual(moves.qty_moved, 1)
self.assertEqual(step1.state, 'done')
self.assertEqual(step2.state, 'in_progress')
def test_finish_and_advance_blocks_for_qty_gt_1(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
with self.assertRaises(UserError) as exc:
step1.action_finish_and_advance()
self.assertIn("Complete 1", str(exc.exception))
# State unchanged
self.assertEqual(step1.state, 'in_progress')
def test_finish_and_advance_passes_for_qty_0(self):
job, step1, step2 = self._make_two_step_chain(qty=2)
# Move all out first
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
before = self.env['fp.job.step.move'].search_count([
('from_step_id', '=', step1.id),
])
step1.action_finish_and_advance()
after = self.env['fp.job.step.move'].search_count([
('from_step_id', '=', step1.id),
])
self.assertEqual(after, before) # no extra move
self.assertEqual(step1.state, 'done')
def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
"""Last runnable step: parts complete in place; no auto-move,
no UserError, no qty gate."""
job = self._make_job(qty=5)
last = self._make_step(
job, name='FinalInspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
self.assertEqual(last.qty_at_step, 5)
before = self.env['fp.job.step.move'].search_count([])
last.action_finish_and_advance()
after = self.env['fp.job.step.move'].search_count([])
self.assertEqual(after, before) # no move recorded
self.assertEqual(last.state, 'done')
```
- [ ] **Step 4: Bump fusion_plating manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.14'/'version': '19.0.18.14.15'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
```
- [ ] **Step 5: Validate + run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.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,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_finish_and_advance.*(FAIL|ERROR|Starting)' | head -10 && systemctl start odoo\""
```
Expected: 4 `Starting` lines for `test_finish_and_advance_*`, zero FAIL/ERROR.
---
## Task 5: Per-row "Complete 1 → Next" button + display_name tests
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add the per-row button**
In `fp_job_form_inherit.xml`, find the embedded step list's button block. The existing per-row buttons include `button_pause`, `action_open_input_wizard`, `button_skip`, `action_open_move_wizard`. We're adding "Complete 1 → Next" after `button_pause` and before `action_open_input_wizard` (so it sits with the primary-action buttons).
Anchor — the existing Pause button:
```xml
<button name="button_pause" type="object"
string="Pause" icon="fa-pause"
class="btn-link text-warning"
invisible="state != 'in_progress'"/>
```
Insert immediately after Pause's closing `/>`:
```xml
<!-- Streaming flow: complete 1 part at a time, move to next
step. Hidden when there's nothing parked or the step isn't
actively running. Auto-finishes the step when qty_at_step
drains to 0. -->
<button name="action_complete_one_to_next" type="object"
string="Complete 1 → Next" icon="fa-forward"
class="btn-link text-success"
invisible="state != 'in_progress' or qty_at_step &lt; 1"/>
```
- [ ] **Step 2: Add display_name + Move wizard regression tests**
Append to `TestQtyGate`:
```python
# ---------------- display_name rename ----------------------------
def test_display_name_format(self):
job = self._make_job(qty=1)
# The default ir.sequence creates name='WH/JOB/NNNNN'.
self.assertTrue(job.name.startswith('WH/JOB/'))
self.assertTrue(job.display_name.startswith('Work Order # '))
# Suffix matches.
suffix = job.name.rsplit('/', 1)[-1]
self.assertEqual(job.display_name, 'Work Order # %s' % suffix)
def test_display_name_no_slash_passthrough(self):
"""Manually-named jobs without the sequence prefix display
as-is (no rewrite)."""
job = self._make_job(qty=1)
# Override name to something without a slash
job.name = 'SmokeJob42'
job.invalidate_recordset(['display_name'])
self.assertEqual(job.display_name, 'SmokeJob42')
# ---------------- Move wizard zero-qty regression ----------------
def test_move_wizard_blocks_zero_qty(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=2)
wiz = self.env['fp.job.step.move.wizard'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'qty_moved': 0,
'transfer_type':'step',
})
with self.assertRaises(UserError) as exc:
wiz.action_commit()
self.assertIn('at least 1', str(exc.exception))
```
- [ ] **Step 3: Bump fusion_plating_jobs manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.7'/'version': '19.0.8.19.8'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
```
- [ ] **Step 4: Validate XML + Python**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"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\\\")' && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"py OK\\\")'\""
```
Expected: `xml OK`, `py OK`.
- [ ] **Step 5: Upgrade fusion_plating_jobs + run 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 'TestQtyGate.*(FAIL|ERROR)' | head -10 && systemctl start odoo\""
```
Expected: 0 lines (no failures in `TestQtyGate`).
---
## Task 6: End-to-end smoke test on entech
**Files:** none (verification via odoo shell + browser).
- [ ] **Step 1: Create a 3-step recipe job with qty=2**
```bash
SCRIPT='partner = env["res.partner"].create({"name": "QtyGate Smoke"})
prod = env["product.product"].create({"name": "QtyGateProd"})
job = env["fp.job"].create({"partner_id": partner.id, "product_id": prod.id, "qty": 2})
step1 = env["fp.job.step"].create({"job_id": job.id, "name": "S1-Plate", "sequence": 10, "state": "in_progress"})
step1.date_started = fields.Datetime.now()
step2 = env["fp.job.step"].create({"job_id": job.id, "name": "S2-Bake", "sequence": 20, "state": "ready"})
step3 = env["fp.job.step"].create({"job_id": job.id, "name": "S3-Inspect", "sequence": 30, "state": "ready"})
job.invalidate_recordset()
print(">>> JOB_ID=", job.id)
print(">>> JOB_NAME=", job.name)
print(">>> DISPLAY_NAME=", job.display_name)
print(">>> step1.qty_at_step=", step1.qty_at_step)
env.cr.commit()'
B64=$(echo -n "$SCRIPT" | base64 -w0)
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/smoke_qty.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/smoke_qty.py' 2>&1 | grep '>>>'\""
```
Expected:
```
>>> JOB_ID= <some id>
>>> JOB_NAME= WH/JOB/00xxx
>>> DISPLAY_NAME= Work Order # 00xxx
>>> step1.qty_at_step= 2
```
Note JOB_ID for later steps.
- [ ] **Step 2: Try to finish step1 — must be blocked**
```bash
SCRIPT='from odoo.exceptions import UserError
step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
try:
step1.button_finish()
print(">>> RESULT: no error (unexpected)")
except UserError as e:
print(">>> RESULT: blocked,", str(e)[:120])'
```
Run the script (substituting JOB_ID). Expected:
```
>>> RESULT: blocked, Step 'S1-Plate' still has 2 part(s) parked — move them to the next step before finishing...
```
- [ ] **Step 3: Use action_complete_one_to_next to drain step1**
```bash
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
step1.action_complete_one_to_next()
step1.invalidate_recordset(["qty_at_step"])
print(">>> step1.state=", step1.state, "qty_at_step=", step1.qty_at_step)
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
step2.invalidate_recordset(["qty_at_step"])
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
env.cr.commit()'
```
Expected after first call:
```
>>> step1.state= in_progress qty_at_step= 1
>>> step2.state= ready qty_at_step= 0
```
(Step2 stays `ready` because step1 still has 1 part — step1 isn't done yet.)
- [ ] **Step 4: Complete the second part — auto-finish**
```bash
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
step1.action_complete_one_to_next()
step1.invalidate_recordset()
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
step2.invalidate_recordset()
print(">>> step1.state=", step1.state)
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
env.cr.commit()'
```
Expected:
```
>>> step1.state= done
>>> step2.state= in_progress qty_at_step= 2
```
(step2 now has both parts; auto-finish + auto-start fired on the last `Complete 1 → Next` call.)
- [ ] **Step 5: Open the job in browser, verify the header label**
Navigate to `https://enplating.com/odoo` → open the smoke job. Verify:
- Form header reads **"Work Order # 00xxx"** (not WH/JOB/00xxx).
- Step1 row no longer shows the "Complete 1 → Next" button (state=done).
- Step2 row DOES show "Complete 1 → Next" (state=in_progress, qty_at_step > 0).
- [ ] **Step 6: Clean up smoke data**
```bash
SCRIPT='job = env["fp.job"].browse(<JOB_ID>)
if job.exists():
env["fp.job.step.move"].search([("job_id", "=", job.id)]).sudo().unlink()
job.step_ids.sudo().unlink()
job.sudo().unlink()
env["res.partner"].search([("name", "=", "QtyGate Smoke")]).sudo().unlink()
env["product.product"].search([("name", "=", "QtyGateProd")]).sudo().unlink()
env.cr.commit()
print(">>> cleanup done")'
```
---
## Task 7: Sync touched files back to local repo + commit
**Files:**
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py`
- [ ] **Step 1: Pull each touched file from entech to local repo**
```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/fusion_plating_jobs/models/fp_job.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py
```
- [ ] **Step 2: Review diff**
```bash
cd K:/Github/Odoo-Modules && git diff --stat fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/
```
Expected: ~6 files changed, additions concentrated in `fp_job_step.py` (button_finish gate + action_complete_one_to_next + _fp_record_one_piece_auto_move + wiring), `fp_job.py` (_compute_display_name), and `test_fp_job_milestone_cascade.py` (14 new tests).
- [ ] **Step 3: Stage + commit**
```bash
cd K:/Github/Odoo-Modules && git add fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/ && git commit -m "$(cat <<'EOF'
feat(jobs): step qty gate + partial-qty + display rename
Three coupled shop-floor corrections:
- fp.job.step.button_finish: refuses if qty_at_step > 0 AND a
downstream pending/ready step exists. Last runnable step is
exempt (parts complete in place). Manager bypass via
fp_skip_qty_gate=True context key.
- fp.job.step.action_complete_one_to_next: per-row "Complete
1 -> Next" button. Records move(qty=1) to next step; if that
drains qty_at_step to 0, auto-finishes source + auto-starts
destination via existing action_finish_and_advance.
- fp.job.step._fp_record_one_piece_auto_move: auto-move shim
wired into action_finish_and_advance. qty=1 + downstream =>
silently record move(1). qty>1 + downstream => raise pointing
at Complete 1 -> Next. Last step always allowed.
- fp.job._compute_display_name: renders "Work Order # 00011"
in form header, breadcrumbs, M2O dropdowns, error messages.
DB name stays as WH/JOB/00011 - existing refs unchanged.
- 14 new TestQtyGate tests covering gate / shim / auto-finish /
last-step exemption / display rename / Move wizard zero-qty.
Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Push (optional)**
```bash
cd K:/Github/Odoo-Modules && git push origin main
```
---
## Self-review notes
- **Spec coverage:** Architecture sections 15 map to Tasks 1, 2, 3, 4, 5. State diagram entries are each covered by a dedicated test. Out-of-scope items (qty_done auto-tick, per-step scrap, cert PDF audit) are explicitly NOT in any task.
- **Placeholder scan:** Two `<JOB_ID>` placeholders in Task 6 are cross-step substitutions (the engineer reads the value from Step 1's output). All code blocks are complete; no "TBD" or "...similar to..." references.
- **Type consistency:** `action_complete_one_to_next` / `_fp_record_one_piece_auto_move` / `button_finish` all reference the same field names (`qty_at_step`, `state`, `sequence`, `job_id`, `step_ids`). The auto-move-shim's call site in `action_finish_and_advance` matches the helper's signature (no arguments, returns bool that the caller ignores). Test `TestQtyGate.setUpClass` matches the test method's `self.partner`, `self.product` references.
- **Field invalidation:** Every test that creates a Move and then checks `qty_at_step` calls `invalidate_recordset(['qty_at_step'])` first. Inside `action_complete_one_to_next` itself, the same invalidate is performed before the auto-finish check. The spec's "implementation notes" callout matches the tests.

View File

@@ -0,0 +1,310 @@
# Job Milestone Cascade — Design Spec
**Date:** 2026-05-12
**Status:** Approved for implementation (Phase 1)
**Scope:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_logistics` (on entech)
## Goal
Replace the per-step "Finish & Next" button on the `fp.job` form header with a single context-aware milestone-advance button. When all steps are done, the button cycles the manager through the remaining post-step lifecycle:
```
Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped → (closed)
```
Each click runs the existing downstream method (no new business logic invented). The button is **one place** the manager looks; the system always tells them what's next.
## Motivation (workflow gap audit)
End-to-end audit found:
- **G1.** `fp.job.state` and `fp.job.workflow_state_id` are two parallel state machines that drift.
- **G2.** No auto-fire of `button_mark_done` when all steps complete. The cascade (delivery / cert / notification) hangs off a manual click that has no UI surface after Finish & Next becomes a no-op.
- **G3.** Delivery + cert creation only happen via `button_mark_done`.
- **G4.** Invoice timing is strategy-dependent; no `on_job_done` strategy.
- **G5.** Certificate auto-creation is best-effort and only spawns CoC. Thickness Report cert is never auto-created even when the part / partner requires it.
- **G6.** No "next action" surface on the job header.
Phase 1 closes **G2 and G6 directly**, makes meaningful progress on **G5**, and lays groundwork for G3/G4. G1 is explicitly deferred.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Ship in recipe vs separate | **Separate (Option C — Hybrid)** | Recipes = manufacturing; deliveries = logistics. Surface "next" on the job header so manager doesn't have to navigate. Supports split shipments naturally. |
| Cert gate strictness on Mark Shipped | **Hard block** (with manager bypass via context key) | AS9100 / Nadcap compliance — no shipping without paperwork. |
| Per-cert vs bulk issuance | **Per-cert** | Each cert (CoC vs Thickness Report) needs its own compliance review. |
| No-cert-required jobs | Skip Issue Certs, go straight to Schedule Delivery | Commercial customers don't need to click a button that has nothing to do. |
| Migration of existing data | **None — dev stage** | No production jobs to preserve. Just rewrite the `Shipped` state seed XML; `-u` reloads it. |
## Architecture
### New compute fields on `fp.job`
```python
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.',
)
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')
next_milestone_label = fields.Char(
compute='_compute_next_milestone_action',
help='Human label for the next-action button — read by the view.',
)
```
`_compute_next_milestone_action` resolution order (top wins):
```
1. NOT all_steps_terminal → None (the existing Finish & Next stays)
2. state != 'done' → mark_done
3. ANY required cert in state='draft' → issue_certs
4. NO delivery, OR delivery in state='draft' → schedule_delivery
5. delivery.state in scheduled/in_transit → mark_shipped
6. otherwise → closed
```
### Dispatcher action
```python
def action_advance_next_milestone(self):
"""Single entry point — branches on next_milestone_action and
delegates to the existing method. Never invents new business logic."""
self.ensure_one()
handlers = {
'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 = handlers.get(self.next_milestone_action)
if fn:
return fn()
return True
```
**Helper methods** (each returns an Odoo action dict or calls the existing
business-logic method):
- `_action_open_draft_certs` → returns an `ir.actions.act_window` opening
the `fp.certificate` list view with domain
`[('x_fc_job_id', '=', self.id), ('state', '=', 'draft')]` and
`target='current'` so the manager works on the cert list, then uses the
breadcrumb to return.
- `_action_open_draft_delivery` → finds the first delivery in
`state='draft'` for this job and returns an `ir.actions.act_window`
opening that record's form in `target='current'`. Falls back to the
delivery list view filtered to this job if no draft delivery exists.
- `_action_mark_active_delivery_delivered` → finds the first delivery in
`state in ('scheduled', 'in_transit')`, calls `action_mark_delivered`
on it directly (no UI navigation — the cascade just *does* the thing).
Posts to job chatter on success.
`target='current'` is chosen everywhere because the manager is working
on the cascade as a multi-step process; a popup would lose breadcrumb
context. The existing job-form breadcrumb survives, so they can navigate
back when done.
### New trigger on `fp.job.workflow.state`
```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". Use for the Shipped milestone in '
'lieu of recipe-side default_kind="ship" tagging.',
)
```
`fp.job.workflow.state._fp_is_passed_for_job(job)` gains:
```python
if self.trigger_on_delivery_state:
return any(d.state == 'delivered' for d in job.delivery_ids)
```
`fp.job._compute_workflow_state_id`'s `@api.depends` extends to include `delivery_ids.state`.
### Cert auto-create hardening
Add to `fp.job`:
```python
def _resolve_required_cert_types(self):
"""Return the set of cert types this job must produce.
Reads the part's certificate_requirement; falls back to the
customer's send_coc / send_thickness_report flags when the part
is set to 'inherit'."""
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'})
```
`_fp_create_certificates` is rewritten to loop over the resolved set and create one draft `fp.certificate` per type, idempotent per type (checks `x_fc_job_id` + `certificate_type` before creating).
### Cert gate on Mark Shipped
`fusion.plating.delivery.action_mark_delivered` gains a gate:
```python
def action_mark_delivered(self):
skip_cert = self.env.context.get('fp_skip_cert_gate')
for delivery in self:
if not skip_cert and delivery.job_ref:
job = self.env['fp.job'].search(
[('name', '=', delivery.job_ref)], limit=1)
if job:
draft_certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
('state', '=', 'draft'),
])
if draft_certs:
raise UserError(_(
'Cannot mark delivery %(d)s shipped — '
'job %(j)s still has %(n)d draft certificate(s). '
'Issue them first, or override via '
'fp_skip_cert_gate=True context key.'
) % {
'd': delivery.name,
'j': job.name,
'n': len(draft_certs),
})
return super().action_mark_delivered()
```
Lives in `fusion_plating_certificates/models/fp_delivery.py` (so the gate ships with the certs module — no coupling to logistics).
### View changes
In `fusion_plating_jobs/views/fp_job_form_inherit.xml`:
1. **Hide existing Finish & Next** when `all_steps_terminal`:
```xml
<button name="action_finish_current_step" type="object"
string="Finish &amp; Next" class="btn-primary" icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
```
2. **Add four mutually-exclusive milestone buttons.** Each binds to `action_advance_next_milestone` but with a hardcoded label so users don't see a generic button. Visibility is gated on `next_milestone_action`:
```xml
<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'"/>
```
`next_milestone_action == 'closed'` shows nothing (terminal).
3. **Hide invisible field** — register `<field name="next_milestone_action" invisible="1"/>` and `<field name="all_steps_terminal" invisible="1"/>` so the view can reference them in `invisible=` expressions.
### Data change — Shipped workflow state seed
In `fusion_plating_jobs/data/fp_workflow_state_data.xml`, replace the `Shipped` state record:
```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>
```
Keep `noupdate="1"` on the wrapping `<data>` block since shops may further customise. In dev, `-u fusion_plating_jobs` re-applies it on fresh DBs.
## State transition cascade (visual)
```
┌──────────────────────┐
│ Steps still running │ ← Finish & Next visible
└──────────┬───────────┘
▼ last step done
┌──────────────────────┐
│ Mark Job Done │ ← button cascade starts
└──────────┬───────────┘
▼ button_mark_done (gates + create delivery + cert)
┌────────────────────────────┴─────────────────────────────┐
│ │
any draft cert? no required certs
│ │
▼ ▼
┌────────────┐ (skip to next)
│ Issue Certs│
└─────┬──────┘
▼ all certs issued
┌─────────────────┐
│ Schedule Deliv. │
└─────┬───────────┘
▼ delivery scheduled
┌─────────────┐
│ Mark Shipped │ ← gates on issued certs (cert module)
└─────┬────────┘
▼ delivery.action_mark_delivered
(workflow_state → Shipped via the new trigger;
invoice fires if strategy='on_delivery')
Closed
```
## Files touched
| File | Change |
|---|---|
| `fusion_plating_jobs/models/fp_job.py` | Add `all_steps_terminal`, `next_milestone_action`, `next_milestone_label` compute fields. Add `action_advance_next_milestone` dispatcher + 3 helper methods. Add `_resolve_required_cert_types`. Rewrite `_fp_create_certificates` to loop over resolved types. Extend `@api.depends` on `_compute_workflow_state_id` to include `delivery_ids.state`. |
| `fusion_plating_jobs/models/fp_job_workflow_state.py` | Add `trigger_on_delivery_state` Boolean. Extend `_fp_is_passed_for_job` with delivery-state branch. |
| `fusion_plating_jobs/data/fp_workflow_state_data.xml` | Rewrite `Shipped` state seed: drop `trigger_default_kinds='ship'`, add `trigger_on_delivery_state=True`. |
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Hide `Finish & Next` when `all_steps_terminal`. Add 4 milestone buttons. Add invisible field declarations. |
| `fusion_plating_certificates/models/fp_delivery.py` | Inherit `fusion.plating.delivery`; override `action_mark_delivered` to gate on draft certs. Manager bypass via `fp_skip_cert_gate=True`. |
| `fusion_plating_certificates/__init__.py` / `models/__init__.py` | Register the new `fp_delivery.py` if needed. |
Manifest versions to bump:
- `fusion_plating_jobs`
- `fusion_plating_certificates`
## Out of scope (Phase 2+)
- **Send Certs to Customer button** — wrap `action_send_to_customer` per cert into the cascade after Mark Shipped. Existing `fp_notification_trigger` hooks already handle ship-time customer email; needs integration design.
- **`on_job_done` invoice strategy** — currently invoices fire at SO confirm or delivery delivered. A "fire at job done" option is desirable for cash-up-front shops; needs strategy-pattern extension in `fusion_plating_invoicing/models/sale_order.py`.
- **`fp.job.state` ↔ `workflow_state_id` reconciliation (G1)** — pick one source of truth, drop or compute the other. Larger refactor; defer until Phase 1 lands and we see how the cascade affects state-machine readability.
## Implementation notes / gotchas
- `next_milestone_action` is **not stored** — recompute on every access. Cheap (4 boolean checks). Avoids dependency-tracking complexity when delivery state changes.
- The cascade reads `delivery_ids` on `fp.job`. Confirm this field exists (related/computed) before relying on it. Fallback: search `fusion.plating.delivery` by `job_ref == self.name`.
- The cert gate in `action_mark_delivered` lives in the certs module so logistics doesn't depend on certs (currently logistics is upstream of certs in the dependency graph — verify).
- View buttons share the same `name="action_advance_next_milestone"` but Odoo distinguishes them by their `string=` attribute in the rendered DOM — this is the standard Odoo pattern for context-aware buttons (see `sale.order` action buttons).
- All four buttons are inside the header; users won't see more than one at a time thanks to the `invisible=` filters.

View File

@@ -0,0 +1,294 @@
# Step Quantity Gate, Partial-Qty Handling, and Job Display Rename
**Date:** 2026-05-12
**Status:** Approved for implementation
**Scope:** `fusion_plating`, `fusion_plating_jobs` (on entech)
## Goal
Three coupled shop-floor corrections on `fp.job` / `fp.job.step`:
1. **Display rename:** show `Work Order # 00011` everywhere a job appears to humans, while keeping `name = "WH/JOB/00011"` as the stable DB identifier.
2. **Quantity gate on `button_finish`:** prevent a step from being marked Done while parts are still parked at it. The current implementation has no quantity check, which is how an operator can produce the "all steps Done, qty_done=0" state visible in production.
3. **Partial-quantity flow:** add a per-row "Complete 1 → Next" action so streaming (large parts moving one-by-one through the same step) is a single click per part. Keep the Move wizard for batched (sub-batch) flow. Keep "Finish & Next" working for the 1-of-1 case via a transparent auto-move shim.
## Motivation
The current state observed in production (job `WH/JOB/00011`, `qty=1`, `qty_done=0`, 11 steps all `Done`) shows the data integrity problem: `fp.job.step.button_finish()` checks only `state == 'in_progress'`. No quantity validation. The user can click Finish on every step regardless of whether parts physically moved through. The job-level `button_mark_done` catches the qty discrepancy at the very end, but by then the per-step audit trail is already a fiction.
Real shop floors run three flows on the same job model:
| Flow | Example | Operator UX needed |
|---|---|---|
| **1-of-1** | One large valve body, qty=1 | One click: Finish & Next (auto-moves the 1 part) |
| **Streaming** | 10 large parts going one-by-one through the same plating tank | One click per part: Complete 1 → Next |
| **Batched** | 50 small parts going through in groups of 10 | Move wizard for each chunk, then Finish |
The data model (`fp.job.step.move` records, `qty_at_step` compute) already supports all three. What's missing is the gate plus a first-class shortcut for streaming.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Job rename mechanism | Override `display_name` via compute; leave `name` untouched | DB identifier stable; old references in chatter/certs/deliveries don't break; rollback is one line |
| Quantity gate scope | `qty_at_step > 0` blocks `button_finish` | Catches the bug at the right layer; manager bypass via context |
| Partial qty UX | Move-driven (Option A from brainstorming) | Maps cleanly to all three flows with one click per natural unit of work |
| Streaming shortcut | New `action_complete_one_to_next` row button | First-class action for the one-by-one case; no wizard ceremony |
| 1-of-1 shortcut | Auto-move shim on existing `action_finish_current_step` + `action_finish_and_advance` | Keeps the single-click UX; transparently records the move |
| Move wizard zero-qty | Already guarded (`qty_moved <= 0` raises) | Verify with a test; no code change needed |
| Manager force-complete | Stays bypass-by-design (already skips `button_finish`) | Manager use-case is "this step was done outside ERP" — no qty in ERP to validate |
## Architecture
### 1. `fp.job.display_name` compute
Single override on `fp.job`. No model change beyond adding a computed method.
```python
@api.depends('name')
def _compute_display_name(self):
"""Reformat 'WH/JOB/00011''Work Order # 00011' for every
human-facing surface (form header, breadcrumbs, M2O dropdowns,
smart-button titles, error messages). The DB `name` is unchanged
so existing certs / deliveries / chatter references don't break.
"""
for job in self:
if job.name and '/' in job.name:
suffix = job.name.rsplit('/', 1)[-1]
job.display_name = _('Work Order # %s') % suffix
else:
job.display_name = job.name or ''
```
View change: the form `<h1>` binds `display_name` instead of `name`. Everywhere else Odoo uses `display_name` automatically — M2O widgets, kanban titles, list views, breadcrumbs.
### 2. Quantity gate on `fp.job.step.button_finish`
The gate only fires when there's a *downstream* step parts could move into. The **last runnable step** of a recipe is allowed to finish with parts here — they complete the recipe in place. (`qty_done` reconciliation at job close is unchanged for Phase 1; see Out of Scope.)
```python
def button_finish(self):
"""[existing docstring extended]
Quantity gate (new): refuses if qty_at_step > 0 AND there is at
least one downstream pending/ready step. The last runnable step
is exempt — parts finishing in place are valid. Manager bypass
via context key fp_skip_qty_gate=True.
"""
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
for step in self:
if step.state != 'in_progress':
raise UserError(...) # existing
if not skip_qty_gate and step.qty_at_step > 0:
has_downstream = step.job_id.step_ids.filtered(
lambda s: s.sequence > step.sequence
and s.state in ('pending', 'ready')
)
if has_downstream:
raise UserError(_(
"Step '%(name)s' still has %(n)d part(s) parked "
"— move them to the next step before finishing. "
"Use the row's 'Complete 1 → Next' or 'Move…' "
"button."
) % {'name': step.name, 'n': step.qty_at_step})
# No downstream step: this is the last runnable step.
# Parts finishing here become "done" with the recipe.
# ...remainder unchanged
```
### 3. New `fp.job.step.action_complete_one_to_next`
```python
def action_complete_one_to_next(self):
"""One-piece flow shortcut: records move(qty=1) from this step
to the next pending/ready step. Drains qty_at_step by 1. If the
drain takes qty_at_step to 0, auto-finishes the source step and
starts the destination step (delegates to action_finish_and_advance,
which already handles auto-start)."""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' must be in progress to complete a part."
) % self.name)
if self.qty_at_step < 1:
raise UserError(_(
"No parts parked at step '%s' — nothing to complete."
) % self.name)
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
raise UserError(_(
"Step '%s' is the last runnable step on the job — "
"no downstream step to move into. Finish the step "
"instead (it will close out the job)."
) % self.name)
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
# qty_at_step is computed from moves; force re-read before deciding
# whether this was the last part. Without invalidate the cache says
# "still 1 parked" and the auto-finish never fires.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step == 0:
return self.action_finish_and_advance()
return True
```
### 4. Auto-move shim on `action_finish_current_step` + `action_finish_and_advance`
Both methods finish "the current step" and (for the former) "auto-start the next". The shim adds:
- **Before finishing:** if `qty_at_step == 1` AND there's a next pending/ready step → record a `move(qty=1)` to the next step, then proceed.
- **If `qty_at_step > 1`:** raise with a friendly message pointing at "Complete 1 → Next" or "Move…".
- **If `qty_at_step == 0`:** proceed as today (the parts already moved via Move wizard or Complete 1 → Next).
The shim lives in `action_finish_and_advance` (on `fp.job.step`); `action_finish_current_step` (on `fp.job`) calls it, so it inherits the shim. Single point of behaviour.
```python
def _fp_record_one_piece_auto_move(self):
"""Helper called from action_finish_and_advance. Decides whether
to silently record a move(qty=1) before the step finishes. Three
cases:
- qty_at_step == 0: nothing to do (parts already moved manually).
- qty_at_step == 1 + downstream step exists: record move(1).
- qty_at_step == 1 + no downstream (last step): no move; parts
complete in place.
- qty_at_step > 1 + downstream exists: raise (operator must use
Complete 1 → Next or Move… to drain the step).
- qty_at_step > 1 + no downstream (last step): allow; parts
all complete in place. (qty_done auto-tick is Phase 2.)
"""
self.ensure_one()
qty = self.qty_at_step
if qty <= 0:
return False
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
# Last runnable step — parts here complete in place. The
# button_finish gate already permits this case; just allow.
return False
if qty > 1:
raise UserError(_(
"Step '%s' still has %d parts here — use the row's "
"'Complete 1 → Next' button (for one-by-one flow) or "
"the 'Move…' wizard (for batched flow) to drain the "
"step before finishing."
) % (self.name, qty))
# qty == 1 and next_step exists → record the move silently.
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
return True
```
Wired into `action_finish_and_advance` immediately before the existing finish logic:
```python
def action_finish_and_advance(self):
self.ensure_one()
if self.state == 'in_progress':
self._fp_record_one_piece_auto_move() # may raise on qty>1
# ...rest unchanged (button_finish + auto-start next)
```
### 5. View additions
In `fp_job_form_inherit.xml` (embedded step list):
```xml
<!-- Complete 1 part and advance — streaming flow (large parts
going one-by-one through the same step). Hidden when there's
nothing parked or the step isn't actively running. -->
<button name="action_complete_one_to_next" type="object"
string="Complete 1 → Next" icon="fa-forward"
class="btn-link text-success"
invisible="state != 'in_progress' or qty_at_step &lt; 1"/>
```
Placed in the row's button column, after "Pause" and before "Move…". The header `Finish & Next` button is unchanged in markup — the auto-move/qty-gate logic is entirely behind the existing button.
In the form header `<sheet>` block, change the `<h1>` to bind `display_name`:
```xml
<h1><field name="display_name"/></h1>
```
`qty_at_step` is already a list column on the embedded step list (visible as "Qty Here"). No change needed for visibility — the existing field declaration is sufficient for the `invisible=` expression.
## State transition diagram
```
Before this work:
in_progress ──button_finish──> done (no qty check)
After:
any step, qty_at_step==0 ──button_finish──> done
mid-recipe step, qty_at_step==1 ──Finish & Next──> [auto-move(1)] ──> done
mid-recipe step, qty_at_step==1 ──Complete 1→Next──> [move(1)] ──> done + start_next
mid-recipe step, qty_at_step>1 ──Complete 1→Next──> [move(1)] (stays in_progress)
mid-recipe step, qty_at_step>1 ──Finish & Next──> ❌ UserError (use shortcuts)
LAST recipe step, qty_at_step>0 ──Finish & Next──> done (no move; parts complete in place)
```
"Mid-recipe step" = at least one downstream step is pending/ready. "LAST recipe step" = no downstream step in pending/ready state (either truly last, or all later steps are skipped/cancelled).
## Test plan
New class `TestQtyGate` in `tests/test_fp_job_milestone_cascade.py`:
| Test | Scenario | Expected |
|---|---|---|
| `test_button_finish_blocks_when_qty_at_step` | qty_at_step=3, click Finish | `UserError("still 3 parts parked")` |
| `test_button_finish_bypass` | `fp_skip_qty_gate=True` context | state→done |
| `test_complete_one_to_next_records_move` | qty=3 → click | move(qty=1) created, qty_at_step=2, state still in_progress |
| `test_complete_one_to_next_auto_finishes_on_last` | qty=1 → click | move(qty=1), source state→done, next step started |
| `test_complete_one_to_next_blocks_when_empty` | qty=0 | `UserError("nothing to complete")` |
| `test_complete_one_to_next_blocks_when_no_next_step` | last step | `UserError("last runnable step")` |
| `test_complete_one_to_next_blocks_when_not_in_progress` | state=pending | `UserError("must be in progress")` |
| `test_finish_and_advance_auto_move_for_qty_1` | running step, qty_at_step=1 | move(qty=1) recorded, then finish + auto-start next |
| `test_finish_and_advance_blocks_for_qty_gt_1` | running step, qty_at_step=3 | `UserError("use Complete 1 → Next or Move")` |
| `test_finish_and_advance_passes_for_qty_0` | qty=0 (already moved) | finish proceeds, no extra move |
| `test_button_finish_allows_last_step_with_qty` | last runnable step, qty_at_step=3, click Finish | state→done; no UserError; no move recorded |
| `test_finish_and_advance_allows_last_step_with_qty_gt_1` | last runnable step, qty_at_step=5 | state→done; no auto-move; no UserError |
| `test_display_name_format` | name=`WH/JOB/00099` | display_name=`Work Order # 00099` |
| `test_display_name_no_slash_passthrough` | name=`SmokeJob` | display_name=`SmokeJob` |
| `test_move_wizard_blocks_zero_qty` | wizard.qty_moved=0 → commit | `UserError("at least 1")` |
## Files touched
| File | Change |
|---|---|
| `fusion_plating_jobs/models/fp_job.py` | Add `_compute_display_name` override. |
| `fusion_plating/models/fp_job_step.py` | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move` invoked from `action_finish_and_advance`. |
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Header `<h1>``display_name`; per-row "Complete 1 → Next" button. |
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | New `TestQtyGate` class with the 13 tests above. |
| `fusion_plating_jobs/__manifest__.py` | Version bump. |
| `fusion_plating/__manifest__.py` | Version bump (touches `fp_job_step.py`). |
## Out of scope
- **Auto-tick `job.qty_done` when last step finishes.** Currently `qty_done` is operator-entered before the job-level "Mark Job Done" button. A future improvement: when the last runnable step finishes with `qty_at_step > 0`, automatically bump `job.qty_done` by that count. Skipped from Phase 1 because (a) the existing job-level qty-reconciliation gate already catches mismatches and (b) it requires capturing pre-finish `qty_at_step` into the existing-but-unused `qty_at_step_finish` field, which expands scope.
- **Per-step scrap tracking** — currently scrap is captured at the *job* level (`qty_scrapped`). Per-step scrap (which step did each scrap event happen at?) is a real shop-floor desire but a bigger data-model change; future spec.
- **Auto-finish on Move wizard's last move** — when the Move wizard records a move that drops `qty_at_step` to 0, it could optionally auto-finish the source step. Skipped because the Move wizard is already explicit (operator chose a qty); an extra confirmation step adds value. Can reconsider if the manual Finish click after a manual Move becomes a friction complaint.
- **Display name in CoC / cert PDFs** — `display_name` automatically threads through Odoo's M2O rendering, but the CoC PDF template may hardcode `name` in places. Audit pass in a follow-up if/when shop reports the new label needs to land on customer-facing paperwork.
## Implementation notes / gotchas
- `qty_at_step` is `compute=False, store=False`. After creating a Move in `action_complete_one_to_next`, the in-memory cache still holds the pre-move value. Always call `invalidate_recordset(['qty_at_step'])` before reading it to decide auto-finish.
- The Move wizard's existing zero-qty guard lives in `action_commit` (raises `UserError`). The new `action_complete_one_to_next` doesn't go through the wizard, so it has its own `qty_at_step < 1` check (gates differently — refuses when nothing to move, vs. refusing when qty entered is 0). Both surfaces are now protected.
- `display_name` is a magic field in Odoo — overriding its compute is the supported pattern. Odoo's M2O widget, breadcrumb, and `name_get` API all route through it. No additional wiring needed.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.18.13.13',
'version': '19.0.18.15.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -66,6 +66,22 @@ class FpJob(models.Model):
default=lambda self: _('New'),
index=True,
)
@api.depends('name')
def _compute_display_name(self):
"""Reformat 'WH/JOB/00011''Work Order # 00011' for every
human-facing surface (form header, breadcrumbs, M2O dropdowns,
smart-button titles, error messages). The DB `name` is
unchanged so existing certs / deliveries / chatter references
don't break.
"""
for job in self:
if job.name and '/' in job.name:
suffix = job.name.rsplit('/', 1)[-1]
job.display_name = _('Work Order # %s') % suffix
else:
job.display_name = job.name or ''
state = fields.Selection(
[
('draft', 'Draft'),

View File

@@ -18,7 +18,7 @@
# cancelled
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.exceptions import AccessError, UserError
class FpJobStep(models.Model):
@@ -109,10 +109,28 @@ class FpJobStep(models.Model):
default='um',
)
dwell_time_minutes = fields.Float()
bake_setpoint_temp = fields.Float(string='Bake Setpoint °C')
# Label intentionally has no unit suffix — the unit follows the
# company's `x_fc_default_temp_uom` setting and is surfaced via the
# adjacent `bake_setpoint_temp_uom_display` compute. Hardcoding °C
# in the label was the most visible "Celsius leaks everywhere"
# offender flagged 2026-05-10.
bake_setpoint_temp = fields.Float(string='Bake Setpoint')
bake_setpoint_temp_uom_display = fields.Char(
string='Unit',
compute='_compute_bake_setpoint_temp_uom_display',
help='Temperature unit pulled live from Settings → Fusion Plating → '
'Units of Measure. Updates everywhere the moment the admin '
'flips Fahrenheit ↔ Celsius.',
)
bake_actual_duration = fields.Float(string='Bake Actual Minutes')
bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref')
@api.depends_context('company')
def _compute_bake_setpoint_temp_uom_display(self):
sym = '°F' if (self.env.company.x_fc_default_temp_uom or 'F') == 'F' else '°C'
for rec in self:
rec.bake_setpoint_temp_uom_display = sym
# ------------------------------------------------------------------
# Recipe-related (Task 1.6)
# ------------------------------------------------------------------
@@ -365,11 +383,28 @@ class FpJobStep(models.Model):
return True
def button_finish(self):
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
# Quantity gate: refuses if parts still parked AND there's
# a downstream step to move them into. Last runnable step
# is exempt — parts finishing there complete in place
# (qty_done reconciliation at job close is the catch-net).
if not skip_qty_gate and step.qty_at_step > 0:
has_downstream = step.job_id.step_ids.filtered(
lambda s: s.sequence > step.sequence
and s.state in ('pending', 'ready')
)
if has_downstream:
raise UserError(_(
"Step '%(name)s' still has %(n)d part(s) "
"parked — move them to the next step before "
"finishing. Use the row's 'Complete 1 → Next' "
"or 'Move…' button."
) % {'name': step.name, 'n': step.qty_at_step})
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
@@ -382,3 +417,138 @@ class FpJobStep(models.Model):
# Sum of all interval durations becomes duration_actual
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
return True
# ===== Manager-only overrides ===========================================
# Used when an operator skipped or cancelled a step in error, or when
# the actual shop-floor work happened outside the ERP and the manager
# needs to retroactively mark the step complete. Both actions are
# group-gated and post a clear audit entry to the step's chatter.
def button_manager_force_complete(self):
"""Force any non-done state straight to 'done'. Stamps the first-
start / first-finish audit fields if blank so the timeline isn't
broken, and closes any timelog still left open."""
if not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'):
raise AccessError(_(
'Only Plating Manager+ can force-complete a step.'
))
for step in self:
if step.state == 'done':
raise UserError(_(
"Step '%s' is already done."
) % step.name)
prev_state = step.state
now = fields.Datetime.now()
# Close any open timelogs first — labour already incurred
# stays in the audit even when we shortcut to done.
open_log = step.time_log_ids.filtered(
lambda l: not l.date_finished
)
if open_log:
open_log.write({'date_finished': now, 'state': 'stopped'})
vals = {'state': 'done'}
if not step.date_started:
vals['date_started'] = now
vals['started_by_user_id'] = self.env.user.id
if not step.date_finished:
vals['date_finished'] = now
vals['finished_by_user_id'] = self.env.user.id
step.write(vals)
step.message_post(body=_(
'Step force-completed by %s (was %s).'
) % (self.env.user.name, prev_state))
return True
def button_manager_reset_to_ready(self):
"""Reset any non-ready step back to 'ready' so the operator can
run it normally. Audited via chatter.
Side-effects, depending on the previous state:
- in_progress / paused → close any open timelog (mirrors
button_cancel) so labour already logged stays in the audit.
- done → also clear date_finished + finished_by_user_id so the
next button_finish writes fresh first-finish stamps instead
of preserving stale ones.
date_started + started_by_user_id are preserved across resets —
they record the first start ever (audit), and duration_actual is
computed from the sum of timelogs, not (finish - start), so the
elapsed math remains correct."""
if not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'):
raise AccessError(_(
'Only Plating Manager+ can reset a step state.'
))
now = fields.Datetime.now()
for step in self:
if step.state == 'ready':
raise UserError(_(
"Step '%s' is already in Ready state."
) % step.name)
prev_state = step.state
vals = {'state': 'ready'}
# Close any still-open timelog (defensive — usually only
# in_progress/paused will have one).
open_log = step.time_log_ids.filtered(
lambda l: not l.date_finished
)
if open_log:
open_log.write({'date_finished': now, 'state': 'stopped'})
# If the step had been completed, wipe the finish stamps so
# the next Finish records fresh audit values. Skip this for
# in_progress / paused / skipped / cancelled / pending — they
# either have no finish stamp or shouldn't have one cleared.
if step.state == 'done':
vals['date_finished'] = False
vals['finished_by_user_id'] = False
step.write(vals)
step.message_post(body=_(
'Step state reset to Ready by %s (was %s).'
) % (self.env.user.name, prev_state))
return True
def action_complete_one_to_next(self):
"""One-piece flow shortcut: records move(qty=1) from this
step to the next pending/ready step, drains qty_at_step by 1.
If the drain takes qty_at_step to 0, auto-finishes the source
and starts the destination step (via action_finish_and_advance).
"""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' must be in progress to complete a part."
) % self.name)
if self.qty_at_step < 1:
raise UserError(_(
"No parts parked at step '%s' — nothing to complete."
) % self.name)
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
raise UserError(_(
"Step '%s' is the last runnable step on the job — "
"no downstream step to move into. Finish the step "
"instead (it will close out the job)."
) % self.name)
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
# qty_at_step is computed from moves; force re-read before
# checking whether this was the last part.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step == 0:
return self.action_finish_and_advance()
return True

View File

@@ -35,7 +35,7 @@
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
<h1><field name="display_name" readonly="1"/></h1>
</div>
<group>
<group>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.5.4.0',
'version': '19.0.5.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """
@@ -27,6 +27,7 @@ Includes Fischerscope thickness measurement data capture.
'fusion_plating_portal',
'fusion_plating_batch',
'fusion_plating_configurator',
'fusion_plating_logistics',
'sale_management',
],
'data': [

View File

@@ -7,3 +7,4 @@ from . import fp_thickness_reading
from . import fp_certificate
from . import res_config_settings
from . import res_partner
from . import fp_delivery

View File

@@ -0,0 +1,59 @@
# -*- 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 or delivery.id,
'j': job.name,
'n': len(draft_certs),
'types': ', '.join(sorted(set(
draft_certs.mapped('certificate_type')
))),
})
return super().action_mark_delivered()

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.20.6',
'version': '19.0.8.20.1',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -20,10 +20,10 @@ Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of
the migration spec dated 2026-04-25) to the rest of the Fusion Plating
module family — configurator, portal, logistics, quality, certificates.
As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only
fulfilment path. SO confirm always creates fp.job records here. The
former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0
once the legacy fallback became unreachable.
Coexists with fusion_plating_bridge_mrp during the migration period.
Activate native jobs via the x_fc_use_native_jobs settings flag (default:
False). When False, SO confirm continues to create mrp.production records
through bridge_mrp. When True, SO confirm creates fp.job records here.
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
OWL/controller stack (job_process_tree, job_plant_overview,
@@ -57,6 +57,7 @@ full design rationale and §6.2 of the implementation plan for task list.
# so the statusbar's m2o has its targets available at view-render time).
'data/fp_workflow_state_data.xml',
'views/fp_workflow_state_views.xml',
'views/res_config_settings_views.xml',
'views/fp_job_step_quick_look_views.xml',
'views/fp_job_form_inherit.xml',
'views/fp_job_quality_buttons.xml',
@@ -66,6 +67,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
'views/res_users_views.xml',
'wizards/fp_job_step_move_wizard_views.xml',
'wizards/fp_job_step_input_wizard_views.xml',
'report/report_fp_job_sticker.xml',

View File

@@ -61,8 +61,8 @@
<field name="code">shipped</field>
<field name="sequence">60</field>
<field name="color">success</field>
<field name="trigger_default_kinds">ship</field>
<field name="description">Shipment confirmed (BOL or carrier pickup). Customer can be notified.</field>
<field name="trigger_on_delivery_state" eval="True"/>
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
</record>
<record id="workflow_state_done" model="fp.job.workflow.state">

View File

@@ -106,6 +106,8 @@ class FpJob(models.Model):
'step_ids.recipe_node_id.default_kind',
'step_ids.recipe_node_id.triggers_workflow_state_id',
'quality_hold_count',
'delivery_id',
'delivery_id.state',
)
def _compute_workflow_state_id(self):
WS = self.env['fp.job.workflow.state']
@@ -137,6 +139,210 @@ class FpJob(models.Model):
timelog_count = fields.Integer(compute='_compute_smart_counts')
portal_job_count = fields.Integer(compute='_compute_smart_counts')
# ------------------------------------------------------------------
# Milestone cascade (Phase 1) — drives the header-button replacement
# that fires when every recipe step reaches 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
)
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'})
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. '
'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.',
)
@api.depends(
'all_steps_terminal',
'state',
'delivery_id',
'delivery_id.state',
)
def _compute_next_milestone_action(self):
"""Resolve 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 (delivered) → 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_id
or job.delivery_id.state == 'draft'):
job.next_milestone_action = 'schedule_delivery'
elif job.delivery_id.state in ('scheduled', 'in_transit'):
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))
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 %(j)s '
'(next=%(a)s).'
) % {
'j': self.name,
'a': 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."""
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 linked delivery if it's still in draft state.
Falls back to the delivery list filtered to this job's
delivery if the state isn't draft (defensive)."""
self.ensure_one()
if self.delivery_id and self.delivery_id.state == 'draft':
return {
'type': 'ir.actions.act_window',
'name': _('Schedule Delivery — %s') % self.name,
'res_model': 'fusion.plating.delivery',
'res_id': self.delivery_id.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):
"""Call action_mark_delivered on the linked delivery if it's
in scheduled / in_transit. Posts to job chatter on success."""
self.ensure_one()
if (not self.delivery_id
or self.delivery_id.state not in ('scheduled', 'in_transit')):
raise UserError(_(
'No scheduled or in-transit delivery to mark shipped '
'for %s.'
) % self.name)
self.delivery_id.action_mark_delivered()
self.message_post(body=_(
'Delivery %s marked shipped via milestone cascade.'
) % self.delivery_id.name)
return True
@api.depends(
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
'step_ids.time_log_ids', 'origin', 'partner_id',
@@ -374,6 +580,15 @@ class FpJob(models.Model):
'fusion_plating_jobs.action_report_fp_job_traveller'
).report_action(self)
def action_print_sticker(self):
"""Print the 6x4" job-box identification sticker (logo + WO# + QR
+ part / customer / thickness / notes). Used at receiving and at
every move so the box is always identifiable on the floor."""
self.ensure_one()
return self.env.ref(
'fusion_plating_jobs.action_report_fp_job_sticker'
).report_action(self)
def action_print_wo_detail(self):
"""Print the Steelhead-style Work Order Detail PDF — chronological
chain-of-custody + per-step inputs + Certified By page. Use this
@@ -1285,93 +1500,102 @@ class FpJob(models.Model):
)
def _fp_create_certificates(self):
"""Trigger cert auto-create on job done.
"""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.
Pre-populates ALL the fields a CoC issuer needs so Tom can hit
Issue without filling 6 fields first:
- partner_id from job
- spec_reference from coating (required by action_issue)
- part_number from part_catalog
- quantity_shipped from job qty (minus scrap)
- po_number from sale_order
- sale_order_id link
- x_fc_job_id link if the field exists
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.
Idempotent — if a cert already exists for this job, skip
(prevents dupes when button_mark_done is re-run after a
manager bypass).
Replaces the single-CoC implementation: now honours
part.certificate_requirement (coc / coc_thickness / none /
inherit) and partner-level send_coc / send_thickness_report
flags. Closes spec gap C-G1.
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
return
Cert = self.env['fp.certificate'].sudo()
# Idempotency: don't double-create on retry.
existing_dom = []
if 'x_fc_job_id' in Cert._fields:
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 existing_dom:
existing = Cert.search(existing_dom, limit=1)
if existing:
_logger.info(
'Job %s: cert %s already exists, skipping auto-create',
self.name, existing.name,
required = self._resolve_required_cert_types()
if not required:
return
has_job_link = 'x_fc_job_id' in Cert._fields
coating = self.coating_config_id
for cert_type in sorted(required):
# Idempotency per type.
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),
)
return
try:
vals = {'partner_id': self.partner_id.id}
if 'certificate_type' in Cert._fields:
vals['certificate_type'] = 'coc'
if 'state' in Cert._fields:
vals['state'] = 'draft'
# Job + SO links.
if 'x_fc_job_id' in Cert._fields:
vals['x_fc_job_id'] = self.id
elif 'job_id' in Cert._fields:
vals['job_id'] = self.id
if 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id
# Pre-fill from coating: the spec_reference is what action_issue
# blocks on — without this every cert needs a manual edit.
coating = self.coating_config_id
if coating and 'spec_reference' in Cert._fields \
and getattr(coating, 'spec_reference', False):
vals['spec_reference'] = coating.spec_reference
# Pre-fill part_number from the part catalog if we have one.
if 'part_number' in Cert._fields and self.part_catalog_id:
vals['part_number'] = self.part_catalog_id.part_number or ''
# Quantity shipped = job qty minus scrap. AS9100 wants the
# actual count that left the shop, not the order count.
if 'quantity_shipped' in Cert._fields:
vals['quantity_shipped'] = int(
(self.qty_done or self.qty or 0) - (self.qty_scrapped or 0)
else:
continue # can't safely identify — skip
if Cert.search_count(existing_dom):
continue
try:
vals = {
'partner_id': self.partner_id.id,
'certificate_type': cert_type,
}
if 'state' in Cert._fields:
vals['state'] = 'draft'
if has_job_link:
vals['x_fc_job_id'] = self.id
elif 'job_id' in Cert._fields:
vals['job_id'] = self.id
if 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id
# spec_reference is what action_issue blocks on.
if coating and 'spec_reference' in Cert._fields \
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 'x_fc_po_number' in self.sale_order_id._fields:
vals['po_number'] = (
self.sale_order_id.x_fc_po_number or ''
)
if 'customer_job_no' in Cert._fields \
and self.sale_order_id \
and 'x_fc_customer_job_number' \
in self.sale_order_id._fields:
vals['customer_job_no'] = (
self.sale_order_id.x_fc_customer_job_number or ''
)
if 'process_description' in Cert._fields and coating:
vals['process_description'] = coating.name or ''
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals)
self.message_post(body=Markup(_(
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer '
'should hit Issue when ready to ship.'
)) % {
't': dict(
Cert._fields['certificate_type'].selection
).get(cert_type, cert_type),
'n': cert.name,
})
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create cert (%s): %s",
self.name, cert_type, e,
)
# PO number from the source SO.
if 'po_number' in Cert._fields and self.sale_order_id \
and 'x_fc_po_number' in self.sale_order_id._fields:
vals['po_number'] = self.sale_order_id.x_fc_po_number or ''
# Customer job# → cert label (helps customer search).
if 'customer_job_no' in Cert._fields and self.sale_order_id \
and 'x_fc_customer_job_number' in self.sale_order_id._fields:
vals['customer_job_no'] = (
self.sale_order_id.x_fc_customer_job_number or ''
)
# Process description from coating name.
if 'process_description' in Cert._fields and coating:
vals['process_description'] = coating.name or ''
# Job # for shop-side reference.
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals)
self.message_post(body=Markup(_(
'CoC <b>%s</b> auto-created (draft). Issuer should hit '
'the Issue button on the certificate when ready to ship.'
)) % cert.name)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create cert: %s", self.name, e,
)
self.message_post(body=_(
'Cert auto-create (%(t)s) failed: %(e)s. '
'Create manually.'
) % {'t': cert_type, 'e': e})
class FpJobStep(models.Model):

View File

@@ -367,16 +367,6 @@ class FpJobStep(models.Model):
if cr_action:
return cr_action
# Racking step routing — same idea as Contract Review. If the
# operator clicks Finish on a Racking step but the linked
# racking inspection isn't done yet, route them straight to
# the inspection form instead of throwing a "find the smart
# button" error message. They complete the line check-off,
# mark Done, and re-click Finish & Next to advance.
ri_action = self._fp_racking_inspection_redirect()
if ri_action:
return ri_action
# Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being
@@ -386,6 +376,11 @@ class FpJobStep(models.Model):
and self._fp_has_uncaptured_step_inputs()):
return self._fp_open_input_wizard(advance_after=True)
# Auto-move shim: for qty_at_step==1 + downstream step,
# silently record a move(qty=1) so the qty gate in
# button_finish passes. Raises for qty>1 (operator must use
# Complete 1 → Next or Move…). Last step is always allowed.
self._fp_record_one_piece_auto_move()
self.button_finish()
next_step = self._fp_next_runnable_step()
if next_step:
@@ -641,34 +636,15 @@ class FpJobStep(models.Model):
def _fp_open_contract_review(self):
"""Auto-create the QA-005 form for this step's part if missing,
return the act_window pointing at it. Called from button_start
on Contract Review steps.
Returns None when the review is already satisfied (state
'complete' or 'dismissed') — letting button_start fall through
to the standard path so the step starts directly, without an
unnecessary detour through an already-signed form. This mirrors
the Finish & Next redirect behaviour: once contract review is
cleared for a part, neither Start nor Finish stops to ask
about it again.
Also short-circuits when the customer doesn't require contract
review and via the manager-bypass context flag, to keep entry
and finish gates in lockstep.
"""
on Contract Review steps."""
self.ensure_one()
if self.env.context.get('fp_skip_contract_review_gate'):
return None
part = self._fp_resolve_contract_review_part()
if not part:
return None
if not part.partner_id.x_fc_contract_review_required:
return None
Review = self.env.get('fp.contract.review')
if Review is None:
return None # quality module not installed — skip
review = part.x_fc_contract_review_id
if review and review.state in ('complete', 'dismissed'):
return None # already satisfied — fall through to normal start
if not review:
review = Review.sudo().create({
'part_id': part.id,
@@ -796,46 +772,6 @@ class FpJobStep(models.Model):
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_racking_inspection_redirect(self):
"""Return an act_window opening the linked racking inspection
form, or False to indicate "no redirect needed".
Mirrors ``_fp_contract_review_redirect``. Triggers when:
* this step is a Racking step (matched by ``_fp_is_racking_step``)
* the linked ``fp.racking.inspection`` exists and is NOT yet in
a terminal state (``done`` / ``discrepancy_flagged``)
When the inspection is already terminal — or doesn't exist at
all — returns False so action_finish_and_advance falls through
to the normal finish path. The hard gate
(``_fp_check_racking_inspection_complete``) still fires from
``button_finish`` for any caller that bypasses the redirect.
Manager bypass via ``fp_skip_racking_inspection_gate=True``.
"""
self.ensure_one()
if self.env.context.get('fp_skip_racking_inspection_gate'):
return False
if not self._fp_is_racking_step():
return False
if 'fp.racking.inspection' not in self.env:
return False
ri = self.job_id.racking_inspection_id
if not ri:
# No inspection record at all — let the soft gate handle
# this with a chatter warning, don't redirect.
return False
if ri.state in ('done', 'discrepancy_flagged'):
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.racking.inspection',
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_check_racking_inspection_complete(self):
"""Soft gate — block button_finish on a Racking step until the
linked inspection is in a terminal state. discrepancy_flagged
@@ -1008,51 +944,32 @@ class FpJobStep(models.Model):
"""Return an ir.actions.act_window opening the part's QA-005
Contract Review form, or False to indicate "no redirect needed".
Triggers when ALL of these are true:
* the step is a Contract Review step (matched via
``_fp_is_contract_review_step`` — name OR template kind OR
node kind, same as the finish-time gate),
* the customer requires contract review
(``partner.x_fc_contract_review_required = True``), AND
* the linked part either has no review yet OR the review is
still in a non-terminal state (draft / assistant_review /
manager_review).
Triggers when:
* the recipe node is flagged default_kind='contract_review', AND
* the linked part has no review yet OR the review is still in
a non-terminal state (draft / assistant_review / manager_review).
Once the review reaches state 'complete' or 'dismissed' the
step is allowed to finish through the normal path. This is how
Finish & Next moves on to the next step automatically once the
contract review is already satisfied for that part — including
when the review was completed on a previous order.
Once the review reaches state 'complete' or 'dismissed' the step
is allowed to finish through the normal path, which is how the
operator clears the contract-review gate after signing QA-005.
Resolution mirrors ``_fp_check_contract_review_complete`` so a
single source of truth governs both ENTRY (this redirect) and
FINISH (the gate) — they always agree on whether a step is a
contract review and which part it's bound to.
Soft-fail: if no part can be resolved we fall through to the
standard wizard rather than blocking the operator.
Soft-fail: if the job has no part_catalog_id we cannot route to
a per-part review, so we fall through to the standard wizard
rather than blocking the operator.
"""
self.ensure_one()
# Manager bypass — same context flag the gate honours.
if self.env.context.get('fp_skip_contract_review_gate'):
node = self.recipe_node_id
if not node or node.default_kind != 'contract_review':
return False
if not self._fp_is_contract_review_step():
return False
part = self._fp_resolve_contract_review_part() \
or self.job_id.part_catalog_id
part = self.job_id.part_catalog_id
if not part:
_logger.warning(
"Contract-review step '%s' on job %s has no part "
"cannot redirect to QA-005 form, falling through to "
"Contract-review step '%s' on job %s has no part_catalog_id "
"cannot redirect to QA-005 form, falling through to "
"standard wizard.",
self.name, self.job_id.name,
)
return False
# Customer flag check — when the customer doesn't require
# contract review, the redirect doesn't fire and the step
# finishes through the normal path. Matches the gate's policy.
if not part.partner_id.x_fc_contract_review_required:
return False
review = part.x_fc_contract_review_id
if review and review.state in ('complete', 'dismissed'):
return False
@@ -1110,28 +1027,6 @@ class FpJobStep(models.Model):
related='recipe_node_id.collect_measurements',
readonly=True,
)
# Job context related fields — used by the quick-look modal so the
# operator can see which job / customer / part / qty this step
# belongs to without opening the parent job form. Related (not
# stored) so they always reflect the live job record.
quick_look_partner_id = fields.Many2one(
'res.partner', string='Customer',
related='job_id.partner_id', readonly=True,
)
quick_look_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
related='job_id.part_catalog_id', readonly=True,
)
quick_look_qty = fields.Float(
string='Order Qty',
related='job_id.qty', readonly=True,
)
quick_look_instruction_attachment_ids = fields.Many2many(
'ir.attachment',
string='Instruction Images',
related='recipe_node_id.instruction_attachment_ids',
readonly=True,
)
quick_look_prompt_ids = fields.Many2many(
'fusion.plating.process.node.input',
string='Prompts',
@@ -1197,3 +1092,43 @@ class FpJobStep(models.Model):
'target': 'new',
'name': self.name,
}
def _fp_record_one_piece_auto_move(self):
"""Decide whether to silently record a move(qty=1) before
the step finishes. Five cases:
- qty_at_step == 0: nothing to do (parts already moved).
- last runnable step: parts complete in place; no move.
- qty_at_step == 1 + downstream: record move(1).
- qty_at_step > 1 + downstream: raise.
- qty_at_step > 1 + last step: allow (parts complete in
place; qty_done auto-tick is Phase 2).
Called from action_finish_and_advance just before
button_finish.
"""
self.ensure_one()
qty = self.qty_at_step
if qty <= 0:
return False
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
return False
if qty > 1:
raise UserError(_(
"Step '%s' still has %d parts here — use the row's "
"'Complete 1 → Next' button (for one-by-one flow) "
"or the 'Move…' wizard (for batched flow) to drain "
"the step before finishing."
) % (self.name, qty))
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
return True

View File

@@ -129,6 +129,18 @@ class FpJobWorkflowState(models.Model):
'is in done/skipped state. Used for the "Done" milestone.',
)
trigger_on_delivery_state = fields.Boolean(
string='Trigger on Delivery Delivered',
default=False,
help='Special trigger — passes once the fusion.plating.delivery '
'linked to the job (job.delivery_id) reaches state="delivered". '
'Used for the Shipped milestone in lieu of recipe-side '
'default_kind="ship" tagging. Shipping is logistics, not '
'manufacturing — keeping the trigger off the recipe lets us '
'route deliveries (split shipments, RMA reverse-flow, '
'customer pickup) independently from plating steps.',
)
block_when_quality_hold = fields.Boolean(
string='Blocked by Quality Hold',
default=False,
@@ -180,6 +192,12 @@ class FpJobWorkflowState(models.Model):
return False
return all(s.state in ('done', 'skipped') for s in non_cancelled)
# Special trigger: linked delivery has been marked delivered
if self.trigger_on_delivery_state:
return bool(
job.delivery_id and job.delivery_id.state == 'delivered'
)
# Special trigger: first wet step started
if self.trigger_first_step_started:
wet_kinds = ('wet', 'bake', 'mask', 'rack')

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_fp_job_extensions
from . import test_fp_job_milestone_cascade

View File

@@ -0,0 +1,591 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Milestone cascade Phase 1 tests.
Covers:
- all_steps_terminal (Task 2)
- _resolve_required_cert_types (Task 3)
- _fp_create_certificates (Task 4)
- next_milestone_action (Task 5)
- action_advance_next_milestone dispatcher (Task 6)
- action_mark_delivered cert gate (Task 8)
See docs/superpowers/plans/2026-05-12-job-milestone-cascade.md.
"""
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)
# ---------------- Task 3: _resolve_required_cert_types -----------
def _make_part(self, certificate_requirement='inherit'):
return self.env['fp.part.catalog'].create({
'name': 'PartA',
'part_number': 'PN-001-%s' % certificate_requirement,
'partner_id': self.partner.id,
'certificate_requirement': certificate_requirement,
})
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):
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())
# ---------------- 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)
# ---------------- 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'])
# default state is draft after create
self.assertNotEqual(job.state, 'done')
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', 'next_milestone_action',
])
self.assertEqual(job.next_milestone_action, 'issue_certs')
def test_next_milestone_schedule_delivery_when_no_certs(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', 'next_milestone_action',
])
self.assertEqual(job.next_milestone_action, 'schedule_delivery')
def test_next_milestone_closed_when_delivered(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'
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'delivered',
})
job.delivery_id = delivery.id
job.invalidate_recordset([
'all_steps_terminal', 'next_milestone_action',
])
self.assertEqual(job.next_milestone_action, 'closed')
# ---------------- 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_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_returns_form_when_draft(self):
job = self._make_job()
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'draft',
})
job.delivery_id = delivery.id
action = job._action_open_draft_delivery()
self.assertEqual(action['res_model'], 'fusion.plating.delivery')
self.assertEqual(action.get('res_id'), delivery.id)
self.assertEqual(action['view_mode'], 'form')
def test_open_draft_delivery_falls_back_to_list(self):
# Delivery not draft → returns list view filtered to this job.
job = self._make_job()
self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
'job_ref': job.name,
'state': 'delivered',
})
action = job._action_open_draft_delivery()
self.assertEqual(action['view_mode'], 'list,form')
self.assertIn(('job_ref', '=', job.name), action['domain'])
def test_mark_active_raises_without_active_delivery(self):
from odoo.exceptions import UserError
job = self._make_job()
with self.assertRaises(UserError):
job._action_mark_active_delivery_delivered()
# ---------------- 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() # creates 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):
"""With fp_skip_cert_gate=True the gate doesn't raise. Downstream
super() chain (notifications, invoicing) may still raise for
their own reasons — out of scope for this test."""
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',
})
try:
delivery.with_context(
fp_skip_cert_gate=True,
).action_mark_delivered()
except Exception as e:
# Cert-gate message must NOT appear. Anything else is fine.
self.assertNotIn('draft certificate', str(e))
def test_mark_delivered_passes_when_cert_issued(self):
"""Issuing the cert clears the gate. Downstream chain errors
are accepted (delivery PDF render etc. — see test above)."""
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
cert.spec_reference = 'AMS 2404'
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',
})
try:
delivery.action_mark_delivered()
except Exception as e:
self.assertNotIn('draft certificate', str(e))
class TestQtyGate(TransactionCase):
"""Step-level quantity gate + partial-qty handling.
Covers:
- button_finish blocks when qty_at_step > 0 AND downstream
steps exist (mid-recipe)
- manager bypass via fp_skip_qty_gate=True
- last-runnable-step exemption (qty_at_step > 0 allowed)
- action_complete_one_to_next (Task 3)
- auto-move shim on action_finish_and_advance (Task 4)
- display_name rename (Task 5)
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
cls.product = cls.env['product.product'].create({
'name': 'QtyWidget',
})
def _make_job(self, qty=3, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': qty,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def _make_step(self, job, name='Step', sequence=10, state='pending'):
return self.env['fp.job.step'].create({
'job_id': job.id,
'name': name,
'sequence': sequence,
'state': state,
})
def _make_two_step_chain(self, qty=3):
"""Create a job with two steps; the first is in_progress
with `qty` parts parked, the second is ready."""
from odoo import fields
job = self._make_job(qty=qty)
step1 = self._make_step(
job, name='Plate', sequence=10, state='in_progress',
)
step2 = self._make_step(
job, name='Bake', sequence=20, state='ready',
)
step1.date_started = fields.Datetime.now()
return job, step1, step2
# ---------------- button_finish gate ----------------------------
def test_button_finish_blocks_when_qty_at_step(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
with self.assertRaises(UserError) as exc:
step1.button_finish()
self.assertIn('parts parked', str(exc.exception).replace(
'part(s) parked', 'parts parked'))
def test_button_finish_bypass(self):
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
step1.with_context(fp_skip_qty_gate=True).button_finish()
self.assertEqual(step1.state, 'done')
def test_button_finish_allows_last_step_with_qty(self):
"""Last runnable step is exempt — parts complete in place."""
from odoo import fields
job = self._make_job(qty=5)
last = self._make_step(
job, name='FinalInspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
self.assertEqual(last.qty_at_step, 5)
last.button_finish()
self.assertEqual(last.state, 'done')
def test_button_finish_passes_when_qty_zero(self):
"""qty_at_step==0 (already moved out) → no gate fires."""
job, step1, step2 = self._make_two_step_chain(qty=2)
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 0)
step1.button_finish()
self.assertEqual(step1.state, 'done')
# ---------------- action_complete_one_to_next -------------------
def test_complete_one_to_next_records_move(self):
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
step1.action_complete_one_to_next()
moves = self.env['fp.job.step.move'].search([
('from_step_id', '=', step1.id),
])
self.assertEqual(len(moves), 1)
self.assertEqual(moves.qty_moved, 1)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.state, 'in_progress')
self.assertEqual(step1.qty_at_step, 2)
def test_complete_one_to_next_auto_finishes_on_last(self):
job, step1, step2 = self._make_two_step_chain(qty=1)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 1)
step1.action_complete_one_to_next()
self.assertEqual(step1.state, 'done')
self.assertEqual(step2.state, 'in_progress')
def test_complete_one_to_next_blocks_when_empty(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=2)
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
with self.assertRaises(UserError) as exc:
step1.action_complete_one_to_next()
self.assertIn('nothing to complete', str(exc.exception))
def test_complete_one_to_next_blocks_when_no_next_step(self):
from odoo.exceptions import UserError
from odoo import fields
job = self._make_job(qty=3)
last = self._make_step(
job, name='Inspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
with self.assertRaises(UserError) as exc:
last.action_complete_one_to_next()
self.assertIn('last runnable step', str(exc.exception))
def test_complete_one_to_next_blocks_when_not_in_progress(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.state = 'pending'
with self.assertRaises(UserError) as exc:
step1.action_complete_one_to_next()
self.assertIn('must be in progress', str(exc.exception))
# ---------------- auto-move shim on Finish & Next ---------------
def test_finish_and_advance_auto_move_for_qty_1(self):
job, step1, step2 = self._make_two_step_chain(qty=1)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 1)
step1.action_finish_and_advance()
moves = self.env['fp.job.step.move'].search([
('from_step_id', '=', step1.id),
])
self.assertEqual(len(moves), 1)
self.assertEqual(moves.qty_moved, 1)
self.assertEqual(step1.state, 'done')
self.assertEqual(step2.state, 'in_progress')
def test_finish_and_advance_blocks_for_qty_gt_1(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
with self.assertRaises(UserError) as exc:
step1.action_finish_and_advance()
self.assertIn("Complete 1", str(exc.exception))
self.assertEqual(step1.state, 'in_progress')
def test_finish_and_advance_passes_for_qty_0(self):
job, step1, step2 = self._make_two_step_chain(qty=2)
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
before = self.env['fp.job.step.move'].search_count([
('from_step_id', '=', step1.id),
])
step1.action_finish_and_advance()
after = self.env['fp.job.step.move'].search_count([
('from_step_id', '=', step1.id),
])
self.assertEqual(after, before)
self.assertEqual(step1.state, 'done')
def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
from odoo import fields
job = self._make_job(qty=5)
last = self._make_step(
job, name='FinalInspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
self.assertEqual(last.qty_at_step, 5)
before = self.env['fp.job.step.move'].search_count([])
last.action_finish_and_advance()
after = self.env['fp.job.step.move'].search_count([])
self.assertEqual(after, before)
self.assertEqual(last.state, 'done')
# ---------------- display_name rename ----------------------------
def test_display_name_format(self):
job = self._make_job(qty=1)
self.assertTrue(job.name.startswith('WH/JOB/'))
self.assertTrue(job.display_name.startswith('Work Order # '))
suffix = job.name.rsplit('/', 1)[-1]
self.assertEqual(job.display_name, 'Work Order # %s' % suffix)
def test_display_name_no_slash_passthrough(self):
"""Manually-named jobs without the sequence prefix display
as-is (no rewrite)."""
job = self._make_job(qty=1)
job.name = 'SmokeJob42'
job.invalidate_recordset(['display_name'])
self.assertEqual(job.display_name, 'SmokeJob42')
# ---------------- Move wizard zero-qty regression ----------------
def test_move_wizard_blocks_zero_qty(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=2)
wiz = self.env['fp.job.step.move.wizard'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'qty_moved': 0,
'transfer_type': 'step',
})
with self.assertRaises(UserError) as exc:
wiz.action_commit()
self.assertIn('at least 1', str(exc.exception))

View File

@@ -35,11 +35,37 @@
string="Finish &amp; Next"
class="btn-primary"
icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress')"/>
<button name="action_print_traveller" type="object"
string="Print Traveller"
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
<!-- Milestone cascade (Phase 1). All four share the same
dispatcher; 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'"/>
<field name="all_steps_terminal" invisible="1"/>
<field name="next_milestone_action" invisible="1"/>
<button name="action_print_sticker" type="object"
string="Print Sticker"
class="btn-secondary"
icon="fa-print"
icon="fa-tag"
invisible="state == 'draft'"/>
<button name="action_print_wo_detail" type="object"
string="Print WO Detail"
@@ -141,6 +167,14 @@
string="Pause" icon="fa-pause"
class="btn-link text-warning"
invisible="state != 'in_progress'"/>
<!-- Streaming flow: complete 1 part at a time,
move to next step. Hidden when nothing is
parked or the step isn't actively running.
Auto-finishes when qty_at_step drains to 0. -->
<button name="action_complete_one_to_next" type="object"
string="Complete 1 → Next" icon="fa-forward"
class="btn-link text-success"
invisible="state != 'in_progress' or qty_at_step &lt; 1"/>
<button name="action_open_input_wizard" type="object"
string="Record" icon="fa-pencil-square-o"
class="btn-link"
@@ -229,22 +263,6 @@
<field name="quality_hold_count" widget="statinfo"
string="Holds"/>
</button>
<button name="action_view_racking_inspection" type="object"
class="oe_stat_button" icon="fa-clipboard-check">
<div class="o_stat_info">
<field name="racking_inspection_state"
widget="badge"
class="o_stat_value"
decoration-success="racking_inspection_state == 'done'"
decoration-info="racking_inspection_state == 'inspecting'"
decoration-warning="racking_inspection_state == 'discrepancy_flagged'"
decoration-muted="racking_inspection_state == 'draft'"
invisible="not racking_inspection_state"/>
<span class="o_stat_value"
invisible="racking_inspection_state"></span>
<span class="o_stat_text">Racking Insp.</span>
</div>
</button>
<button name="action_view_certificates" type="object"
class="oe_stat_button" icon="fa-certificate"
invisible="certificate_count == 0">

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
"name": "Fusion Whitelabels",
"version": "19.0.1.4.5",
"version": "19.0.1.5.0",
"category": "Website",
"summary": "Replace Odoo frontend promotional branding with Nexa Systems whitelabeling.",
"description": """
@@ -12,10 +12,13 @@
- Removes "Connect with your software" portal promotions.
- Replaces global "Powered by Odoo" website/footer promotions with Nexa Systems credit.
- Removes login-page "Powered by Odoo" footer link.
- Replaces "Powered by Odoo" footer in transactional email notifications
(mail.mail_notification_layout + mail.mail_notification_light) with
Nexa Systems credit.
""",
"author": "Fusion",
"license": "LGPL-3",
"depends": ["portal", "sale", "purchase", "website", "website_sale"],
"depends": ["mail", "portal", "sale", "purchase", "website", "website_sale"],
"data": [
"views/fusion_whitelabels_templates.xml",
],

View File

@@ -36,4 +36,34 @@
</xpath>
</template>
<template id="fusion_whitelabels_mail_notification_layout"
inherit_id="mail.mail_notification_layout" priority="999">
<xpath expr="//div[@t-if='show_footer' and contains(@style, 'color: #555555')]" position="replace">
<div t-if="show_footer" style="color: #555555; font-size:11px;">
Designed by <a target="_blank" href="https://nexasystems.ca"
rel="noopener noreferrer"
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Nexa Systems</a>
<span t-if="show_unfollow" id="mail_unfollow">
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
</span>
</div>
</xpath>
</template>
<template id="fusion_whitelabels_mail_notification_light"
inherit_id="mail.mail_notification_light" priority="999">
<xpath expr="//tr[td/span[@id='mail_unfollow']]" position="replace">
<tr>
<td align="center" style="min-width: 590px;">
Designed by <a target="_blank" href="https://nexasystems.ca"
rel="noopener noreferrer"
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Nexa Systems</a>
<span t-if="show_unfollow" id="mail_unfollow">
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
</span>
</td>
</tr>
</xpath>
</template>
</odoo>