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>
42 KiB
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
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:
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:
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:
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>.bakbefore 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
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_nametofp.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:
@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_namein the form header
In fp_job_form_inherit.xml, find the <h1> block in the sheet header that currently binds name:
Search anchor:
<h1><field name="name"/></h1>
Replace with:
<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
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):
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
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
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
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
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:
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:
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
TestQtyGatetest class skeleton + 3 gate tests
Append to test_fp_job_milestone_cascade.py:
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:
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):
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
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
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_nextmethod
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:
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:
# ---------------- 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
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
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_movehelper
Find the existing action_finish_and_advance method on fp.job.step (search for def action_finish_and_advance). It probably looks like:
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:
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:
def action_finish_and_advance(self):
self.ensure_one()
if self.state == 'in_progress':
self.button_finish()
Insert the helper call before button_finish:
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:
# ---------------- 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
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
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:
<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 />:
<!-- 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 < 1"/>
- Step 2: Add display_name + Move wizard regression tests
Append to TestQtyGate:
# ---------------- 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
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
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
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
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
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
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
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
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
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
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
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)
cd K:/Github/Odoo-Modules && git push origin main
Self-review notes
- Spec coverage: Architecture sections 1–5 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_finishall reference the same field names (qty_at_step,state,sequence,job_id,step_ids). The auto-move-shim's call site inaction_finish_and_advancematches the helper's signature (no arguments, returns bool that the caller ignores). TestTestQtyGate.setUpClassmatches the test method'sself.partner,self.productreferences. - Field invalidation: Every test that creates a Move and then checks
qty_at_stepcallsinvalidate_recordset(['qty_at_step'])first. Insideaction_complete_one_to_nextitself, the same invalidate is performed before the auto-finish check. The spec's "implementation notes" callout matches the tests.