# 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 `

` 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 --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 /tmp/.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 | `

` 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 `

` block in the sheet header that currently binds `name`: Search anchor: ```xml

``` Replace with: ```xml

``` 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