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