Files
Odoo-Modules/fusion-plating/docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
gsinghpal 9e39e41b0d docs: step qty gate + display rename implementation plan
7-task plan: display rename (compute + view), qty gate on
button_finish with last-step exemption, action_complete_one_to_next
row button, auto-move shim on Finish & Next, view additions,
end-to-end smoke test, and repo sync-back.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:10:58 -04:00

42 KiB
Raw Blame History

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>.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/00011Work 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_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:

    @api.depends('name')
    def _compute_display_name(self):
        """Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
        human-facing surface (form header, breadcrumbs, M2O dropdowns,
        smart-button titles, error messages). The DB `name` is unchanged
        so existing certs / deliveries / chatter references don't break.
        """
        for job in self:
            if job.name and '/' in job.name:
                suffix = job.name.rsplit('/', 1)[-1]
                job.display_name = _('Work Order # %s') % suffix
            else:
                job.display_name = job.name or ''

Use a patch script with anchor-based string replacement. The anchor should be unique enough to find exactly one insertion site — pick a stable nearby field declaration (e.g. the state field's closing ) if it's unique).

  • Step 3: Bind display_name in the form header

In fp_job_form_inherit.xml, find the <h1> block in the sheet header that currently binds name:

Search anchor:

<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.619.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 TestQtyGate test 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_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:


    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_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:

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 &lt; 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 15 map to Tasks 1, 2, 3, 4, 5. State diagram entries are each covered by a dedicated test. Out-of-scope items (qty_done auto-tick, per-step scrap, cert PDF audit) are explicitly NOT in any task.
  • Placeholder scan: Two <JOB_ID> placeholders in Task 6 are cross-step substitutions (the engineer reads the value from Step 1's output). All code blocks are complete; no "TBD" or "...similar to..." references.
  • Type consistency: action_complete_one_to_next / _fp_record_one_piece_auto_move / button_finish all reference the same field names (qty_at_step, state, sequence, job_id, step_ids). The auto-move-shim's call site in action_finish_and_advance matches the helper's signature (no arguments, returns bool that the caller ignores). Test TestQtyGate.setUpClass matches the test method's self.partner, self.product references.
  • Field invalidation: Every test that creates a Move and then checks qty_at_step calls invalidate_recordset(['qty_at_step']) first. Inside action_complete_one_to_next itself, the same invalidate is performed before the auto-finish check. The spec's "implementation notes" callout matches the tests.