From 9e39e41b0d9fa80a5ad57fc751bdfade32f29601 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 11 May 2026 23:10:58 -0400 Subject: [PATCH] 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) --- ...-05-12-step-qty-gate-and-display-rename.md | 969 ++++++++++++++++++ 1 file changed, 969 insertions(+) create mode 100644 fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md diff --git a/fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md b/fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md new file mode 100644 index 00000000..0e3510de --- /dev/null +++ b/fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md @@ -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 `

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