Compare commits
7 Commits
eee2dcd615
...
b0070afc1b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0070afc1b | ||
|
|
9e39e41b0d | ||
|
|
f4c41de91c | ||
|
|
913311653f | ||
|
|
1c1f517847 | ||
|
|
b2592d70f8 | ||
|
|
03f14c2c40 |
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Job Milestone Cascade — Design Spec
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Status:** Approved for implementation (Phase 1)
|
||||
**Scope:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_logistics` (on entech)
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the per-step "Finish & Next" button on the `fp.job` form header with a single context-aware milestone-advance button. When all steps are done, the button cycles the manager through the remaining post-step lifecycle:
|
||||
|
||||
```
|
||||
Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped → (closed)
|
||||
```
|
||||
|
||||
Each click runs the existing downstream method (no new business logic invented). The button is **one place** the manager looks; the system always tells them what's next.
|
||||
|
||||
## Motivation (workflow gap audit)
|
||||
|
||||
End-to-end audit found:
|
||||
|
||||
- **G1.** `fp.job.state` and `fp.job.workflow_state_id` are two parallel state machines that drift.
|
||||
- **G2.** No auto-fire of `button_mark_done` when all steps complete. The cascade (delivery / cert / notification) hangs off a manual click that has no UI surface after Finish & Next becomes a no-op.
|
||||
- **G3.** Delivery + cert creation only happen via `button_mark_done`.
|
||||
- **G4.** Invoice timing is strategy-dependent; no `on_job_done` strategy.
|
||||
- **G5.** Certificate auto-creation is best-effort and only spawns CoC. Thickness Report cert is never auto-created even when the part / partner requires it.
|
||||
- **G6.** No "next action" surface on the job header.
|
||||
|
||||
Phase 1 closes **G2 and G6 directly**, makes meaningful progress on **G5**, and lays groundwork for G3/G4. G1 is explicitly deferred.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Ship in recipe vs separate | **Separate (Option C — Hybrid)** | Recipes = manufacturing; deliveries = logistics. Surface "next" on the job header so manager doesn't have to navigate. Supports split shipments naturally. |
|
||||
| Cert gate strictness on Mark Shipped | **Hard block** (with manager bypass via context key) | AS9100 / Nadcap compliance — no shipping without paperwork. |
|
||||
| Per-cert vs bulk issuance | **Per-cert** | Each cert (CoC vs Thickness Report) needs its own compliance review. |
|
||||
| No-cert-required jobs | Skip Issue Certs, go straight to Schedule Delivery | Commercial customers don't need to click a button that has nothing to do. |
|
||||
| Migration of existing data | **None — dev stage** | No production jobs to preserve. Just rewrite the `Shipped` state seed XML; `-u` reloads it. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### New compute fields on `fp.job`
|
||||
|
||||
```python
|
||||
all_steps_terminal = fields.Boolean(
|
||||
compute='_compute_all_steps_terminal', store=True,
|
||||
help='True ⇔ at least one step exists AND every step is in '
|
||||
'done/skipped/cancelled.',
|
||||
)
|
||||
|
||||
next_milestone_action = fields.Selection([
|
||||
('mark_done', 'Mark Job Done'),
|
||||
('issue_certs', 'Issue Certs'),
|
||||
('schedule_delivery', 'Schedule Delivery'),
|
||||
('mark_shipped', 'Mark Shipped'),
|
||||
('closed', 'Closed'),
|
||||
], compute='_compute_next_milestone_action')
|
||||
|
||||
next_milestone_label = fields.Char(
|
||||
compute='_compute_next_milestone_action',
|
||||
help='Human label for the next-action button — read by the view.',
|
||||
)
|
||||
```
|
||||
|
||||
`_compute_next_milestone_action` resolution order (top wins):
|
||||
|
||||
```
|
||||
1. NOT all_steps_terminal → None (the existing Finish & Next stays)
|
||||
2. state != 'done' → mark_done
|
||||
3. ANY required cert in state='draft' → issue_certs
|
||||
4. NO delivery, OR delivery in state='draft' → schedule_delivery
|
||||
5. delivery.state in scheduled/in_transit → mark_shipped
|
||||
6. otherwise → closed
|
||||
```
|
||||
|
||||
### Dispatcher action
|
||||
|
||||
```python
|
||||
def action_advance_next_milestone(self):
|
||||
"""Single entry point — branches on next_milestone_action and
|
||||
delegates to the existing method. Never invents new business logic."""
|
||||
self.ensure_one()
|
||||
handlers = {
|
||||
'mark_done': self.button_mark_done,
|
||||
'issue_certs': self._action_open_draft_certs,
|
||||
'schedule_delivery': self._action_open_draft_delivery,
|
||||
'mark_shipped': self._action_mark_active_delivery_delivered,
|
||||
}
|
||||
fn = handlers.get(self.next_milestone_action)
|
||||
if fn:
|
||||
return fn()
|
||||
return True
|
||||
```
|
||||
|
||||
**Helper methods** (each returns an Odoo action dict or calls the existing
|
||||
business-logic method):
|
||||
|
||||
- `_action_open_draft_certs` → returns an `ir.actions.act_window` opening
|
||||
the `fp.certificate` list view with domain
|
||||
`[('x_fc_job_id', '=', self.id), ('state', '=', 'draft')]` and
|
||||
`target='current'` so the manager works on the cert list, then uses the
|
||||
breadcrumb to return.
|
||||
- `_action_open_draft_delivery` → finds the first delivery in
|
||||
`state='draft'` for this job and returns an `ir.actions.act_window`
|
||||
opening that record's form in `target='current'`. Falls back to the
|
||||
delivery list view filtered to this job if no draft delivery exists.
|
||||
- `_action_mark_active_delivery_delivered` → finds the first delivery in
|
||||
`state in ('scheduled', 'in_transit')`, calls `action_mark_delivered`
|
||||
on it directly (no UI navigation — the cascade just *does* the thing).
|
||||
Posts to job chatter on success.
|
||||
|
||||
`target='current'` is chosen everywhere because the manager is working
|
||||
on the cascade as a multi-step process; a popup would lose breadcrumb
|
||||
context. The existing job-form breadcrumb survives, so they can navigate
|
||||
back when done.
|
||||
|
||||
### New trigger on `fp.job.workflow.state`
|
||||
|
||||
```python
|
||||
trigger_on_delivery_state = fields.Boolean(
|
||||
string='Trigger on Delivery Delivered',
|
||||
help='When True, this state passes once at least one '
|
||||
'fusion.plating.delivery linked to the job reaches '
|
||||
'state="delivered". Use for the Shipped milestone in '
|
||||
'lieu of recipe-side default_kind="ship" tagging.',
|
||||
)
|
||||
```
|
||||
|
||||
`fp.job.workflow.state._fp_is_passed_for_job(job)` gains:
|
||||
|
||||
```python
|
||||
if self.trigger_on_delivery_state:
|
||||
return any(d.state == 'delivered' for d in job.delivery_ids)
|
||||
```
|
||||
|
||||
`fp.job._compute_workflow_state_id`'s `@api.depends` extends to include `delivery_ids.state`.
|
||||
|
||||
### Cert auto-create hardening
|
||||
|
||||
Add to `fp.job`:
|
||||
|
||||
```python
|
||||
def _resolve_required_cert_types(self):
|
||||
"""Return the set of cert types this job must produce.
|
||||
Reads the part's certificate_requirement; falls back to the
|
||||
customer's send_coc / send_thickness_report flags when the part
|
||||
is set to 'inherit'."""
|
||||
req = (self.part_catalog_id and
|
||||
self.part_catalog_id.certificate_requirement) or 'inherit'
|
||||
if req == 'inherit':
|
||||
types = set()
|
||||
if self.partner_id.x_fc_send_coc:
|
||||
types.add('coc')
|
||||
if self.partner_id.x_fc_send_thickness_report:
|
||||
types.add('thickness_report')
|
||||
return types
|
||||
return {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
}.get(req, {'coc'})
|
||||
```
|
||||
|
||||
`_fp_create_certificates` is rewritten to loop over the resolved set and create one draft `fp.certificate` per type, idempotent per type (checks `x_fc_job_id` + `certificate_type` before creating).
|
||||
|
||||
### Cert gate on Mark Shipped
|
||||
|
||||
`fusion.plating.delivery.action_mark_delivered` gains a gate:
|
||||
|
||||
```python
|
||||
def action_mark_delivered(self):
|
||||
skip_cert = self.env.context.get('fp_skip_cert_gate')
|
||||
for delivery in self:
|
||||
if not skip_cert and delivery.job_ref:
|
||||
job = self.env['fp.job'].search(
|
||||
[('name', '=', delivery.job_ref)], limit=1)
|
||||
if job:
|
||||
draft_certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
if draft_certs:
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery %(d)s shipped — '
|
||||
'job %(j)s still has %(n)d draft certificate(s). '
|
||||
'Issue them first, or override via '
|
||||
'fp_skip_cert_gate=True context key.'
|
||||
) % {
|
||||
'd': delivery.name,
|
||||
'j': job.name,
|
||||
'n': len(draft_certs),
|
||||
})
|
||||
return super().action_mark_delivered()
|
||||
```
|
||||
|
||||
Lives in `fusion_plating_certificates/models/fp_delivery.py` (so the gate ships with the certs module — no coupling to logistics).
|
||||
|
||||
### View changes
|
||||
|
||||
In `fusion_plating_jobs/views/fp_job_form_inherit.xml`:
|
||||
|
||||
1. **Hide existing Finish & Next** when `all_steps_terminal`:
|
||||
|
||||
```xml
|
||||
<button name="action_finish_current_step" type="object"
|
||||
string="Finish & Next" class="btn-primary" icon="fa-arrow-right"
|
||||
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
|
||||
```
|
||||
|
||||
2. **Add four mutually-exclusive milestone buttons.** Each binds to `action_advance_next_milestone` but with a hardcoded label so users don't see a generic button. Visibility is gated on `next_milestone_action`:
|
||||
|
||||
```xml
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Job Done" class="btn-success" icon="fa-check-circle"
|
||||
invisible="next_milestone_action != 'mark_done'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Issue Certs" class="btn-primary" icon="fa-certificate"
|
||||
invisible="next_milestone_action != 'issue_certs'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Schedule Delivery" class="btn-primary" icon="fa-truck"
|
||||
invisible="next_milestone_action != 'schedule_delivery'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Shipped" class="btn-success" icon="fa-paper-plane"
|
||||
invisible="next_milestone_action != 'mark_shipped'"/>
|
||||
```
|
||||
|
||||
`next_milestone_action == 'closed'` shows nothing (terminal).
|
||||
|
||||
3. **Hide invisible field** — register `<field name="next_milestone_action" invisible="1"/>` and `<field name="all_steps_terminal" invisible="1"/>` so the view can reference them in `invisible=` expressions.
|
||||
|
||||
### Data change — Shipped workflow state seed
|
||||
|
||||
In `fusion_plating_jobs/data/fp_workflow_state_data.xml`, replace the `Shipped` state record:
|
||||
|
||||
```xml
|
||||
<record id="workflow_state_shipped" model="fp.job.workflow.state">
|
||||
<field name="name">Shipped</field>
|
||||
<field name="code">shipped</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="color">success</field>
|
||||
<field name="trigger_on_delivery_state" eval="True"/>
|
||||
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
Keep `noupdate="1"` on the wrapping `<data>` block since shops may further customise. In dev, `-u fusion_plating_jobs` re-applies it on fresh DBs.
|
||||
|
||||
## State transition cascade (visual)
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Steps still running │ ← Finish & Next visible
|
||||
└──────────┬───────────┘
|
||||
▼ last step done
|
||||
┌──────────────────────┐
|
||||
│ Mark Job Done │ ← button cascade starts
|
||||
└──────────┬───────────┘
|
||||
▼ button_mark_done (gates + create delivery + cert)
|
||||
┌────────────────────────────┴─────────────────────────────┐
|
||||
│ │
|
||||
any draft cert? no required certs
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────┐ (skip to next)
|
||||
│ Issue Certs│
|
||||
└─────┬──────┘
|
||||
▼ all certs issued
|
||||
┌─────────────────┐
|
||||
│ Schedule Deliv. │
|
||||
└─────┬───────────┘
|
||||
▼ delivery scheduled
|
||||
┌─────────────┐
|
||||
│ Mark Shipped │ ← gates on issued certs (cert module)
|
||||
└─────┬────────┘
|
||||
▼ delivery.action_mark_delivered
|
||||
(workflow_state → Shipped via the new trigger;
|
||||
invoice fires if strategy='on_delivery')
|
||||
│
|
||||
▼
|
||||
Closed
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_jobs/models/fp_job.py` | Add `all_steps_terminal`, `next_milestone_action`, `next_milestone_label` compute fields. Add `action_advance_next_milestone` dispatcher + 3 helper methods. Add `_resolve_required_cert_types`. Rewrite `_fp_create_certificates` to loop over resolved types. Extend `@api.depends` on `_compute_workflow_state_id` to include `delivery_ids.state`. |
|
||||
| `fusion_plating_jobs/models/fp_job_workflow_state.py` | Add `trigger_on_delivery_state` Boolean. Extend `_fp_is_passed_for_job` with delivery-state branch. |
|
||||
| `fusion_plating_jobs/data/fp_workflow_state_data.xml` | Rewrite `Shipped` state seed: drop `trigger_default_kinds='ship'`, add `trigger_on_delivery_state=True`. |
|
||||
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Hide `Finish & Next` when `all_steps_terminal`. Add 4 milestone buttons. Add invisible field declarations. |
|
||||
| `fusion_plating_certificates/models/fp_delivery.py` | Inherit `fusion.plating.delivery`; override `action_mark_delivered` to gate on draft certs. Manager bypass via `fp_skip_cert_gate=True`. |
|
||||
| `fusion_plating_certificates/__init__.py` / `models/__init__.py` | Register the new `fp_delivery.py` if needed. |
|
||||
|
||||
Manifest versions to bump:
|
||||
- `fusion_plating_jobs`
|
||||
- `fusion_plating_certificates`
|
||||
|
||||
## Out of scope (Phase 2+)
|
||||
|
||||
- **Send Certs to Customer button** — wrap `action_send_to_customer` per cert into the cascade after Mark Shipped. Existing `fp_notification_trigger` hooks already handle ship-time customer email; needs integration design.
|
||||
- **`on_job_done` invoice strategy** — currently invoices fire at SO confirm or delivery delivered. A "fire at job done" option is desirable for cash-up-front shops; needs strategy-pattern extension in `fusion_plating_invoicing/models/sale_order.py`.
|
||||
- **`fp.job.state` ↔ `workflow_state_id` reconciliation (G1)** — pick one source of truth, drop or compute the other. Larger refactor; defer until Phase 1 lands and we see how the cascade affects state-machine readability.
|
||||
|
||||
## Implementation notes / gotchas
|
||||
|
||||
- `next_milestone_action` is **not stored** — recompute on every access. Cheap (4 boolean checks). Avoids dependency-tracking complexity when delivery state changes.
|
||||
- The cascade reads `delivery_ids` on `fp.job`. Confirm this field exists (related/computed) before relying on it. Fallback: search `fusion.plating.delivery` by `job_ref == self.name`.
|
||||
- The cert gate in `action_mark_delivered` lives in the certs module so logistics doesn't depend on certs (currently logistics is upstream of certs in the dependency graph — verify).
|
||||
- View buttons share the same `name="action_advance_next_milestone"` but Odoo distinguishes them by their `string=` attribute in the rendered DOM — this is the standard Odoo pattern for context-aware buttons (see `sale.order` action buttons).
|
||||
- All four buttons are inside the header; users won't see more than one at a time thanks to the `invisible=` filters.
|
||||
@@ -0,0 +1,294 @@
|
||||
# Step Quantity Gate, Partial-Qty Handling, and Job Display Rename
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** `fusion_plating`, `fusion_plating_jobs` (on entech)
|
||||
|
||||
## Goal
|
||||
|
||||
Three coupled shop-floor corrections on `fp.job` / `fp.job.step`:
|
||||
|
||||
1. **Display rename:** show `Work Order # 00011` everywhere a job appears to humans, while keeping `name = "WH/JOB/00011"` as the stable DB identifier.
|
||||
2. **Quantity gate on `button_finish`:** prevent a step from being marked Done while parts are still parked at it. The current implementation has no quantity check, which is how an operator can produce the "all steps Done, qty_done=0" state visible in production.
|
||||
3. **Partial-quantity flow:** add a per-row "Complete 1 → Next" action so streaming (large parts moving one-by-one through the same step) is a single click per part. Keep the Move wizard for batched (sub-batch) flow. Keep "Finish & Next" working for the 1-of-1 case via a transparent auto-move shim.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current state observed in production (job `WH/JOB/00011`, `qty=1`, `qty_done=0`, 11 steps all `Done`) shows the data integrity problem: `fp.job.step.button_finish()` checks only `state == 'in_progress'`. No quantity validation. The user can click Finish on every step regardless of whether parts physically moved through. The job-level `button_mark_done` catches the qty discrepancy at the very end, but by then the per-step audit trail is already a fiction.
|
||||
|
||||
Real shop floors run three flows on the same job model:
|
||||
|
||||
| Flow | Example | Operator UX needed |
|
||||
|---|---|---|
|
||||
| **1-of-1** | One large valve body, qty=1 | One click: Finish & Next (auto-moves the 1 part) |
|
||||
| **Streaming** | 10 large parts going one-by-one through the same plating tank | One click per part: Complete 1 → Next |
|
||||
| **Batched** | 50 small parts going through in groups of 10 | Move wizard for each chunk, then Finish |
|
||||
|
||||
The data model (`fp.job.step.move` records, `qty_at_step` compute) already supports all three. What's missing is the gate plus a first-class shortcut for streaming.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Job rename mechanism | Override `display_name` via compute; leave `name` untouched | DB identifier stable; old references in chatter/certs/deliveries don't break; rollback is one line |
|
||||
| Quantity gate scope | `qty_at_step > 0` blocks `button_finish` | Catches the bug at the right layer; manager bypass via context |
|
||||
| Partial qty UX | Move-driven (Option A from brainstorming) | Maps cleanly to all three flows with one click per natural unit of work |
|
||||
| Streaming shortcut | New `action_complete_one_to_next` row button | First-class action for the one-by-one case; no wizard ceremony |
|
||||
| 1-of-1 shortcut | Auto-move shim on existing `action_finish_current_step` + `action_finish_and_advance` | Keeps the single-click UX; transparently records the move |
|
||||
| Move wizard zero-qty | Already guarded (`qty_moved <= 0` raises) | Verify with a test; no code change needed |
|
||||
| Manager force-complete | Stays bypass-by-design (already skips `button_finish`) | Manager use-case is "this step was done outside ERP" — no qty in ERP to validate |
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. `fp.job.display_name` compute
|
||||
|
||||
Single override on `fp.job`. No model change beyond adding a computed method.
|
||||
|
||||
```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 ''
|
||||
```
|
||||
|
||||
View change: the form `<h1>` binds `display_name` instead of `name`. Everywhere else Odoo uses `display_name` automatically — M2O widgets, kanban titles, list views, breadcrumbs.
|
||||
|
||||
### 2. Quantity gate on `fp.job.step.button_finish`
|
||||
|
||||
The gate only fires when there's a *downstream* step parts could move into. The **last runnable step** of a recipe is allowed to finish with parts here — they complete the recipe in place. (`qty_done` reconciliation at job close is unchanged for Phase 1; see Out of Scope.)
|
||||
|
||||
```python
|
||||
def button_finish(self):
|
||||
"""[existing docstring extended]
|
||||
|
||||
Quantity gate (new): refuses if qty_at_step > 0 AND there is at
|
||||
least one downstream pending/ready step. The last runnable step
|
||||
is exempt — parts finishing in place are valid. Manager bypass
|
||||
via context key fp_skip_qty_gate=True.
|
||||
"""
|
||||
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
|
||||
for step in self:
|
||||
if step.state != 'in_progress':
|
||||
raise UserError(...) # existing
|
||||
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})
|
||||
# No downstream step: this is the last runnable step.
|
||||
# Parts finishing here become "done" with the recipe.
|
||||
# ...remainder unchanged
|
||||
```
|
||||
|
||||
### 3. New `fp.job.step.action_complete_one_to_next`
|
||||
|
||||
```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 step and
|
||||
starts the destination step (delegates to action_finish_and_advance,
|
||||
which already handles auto-start)."""
|
||||
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 deciding
|
||||
# whether this was the last part. Without invalidate the cache says
|
||||
# "still 1 parked" and the auto-finish never fires.
|
||||
self.invalidate_recordset(['qty_at_step'])
|
||||
if self.qty_at_step == 0:
|
||||
return self.action_finish_and_advance()
|
||||
return True
|
||||
```
|
||||
|
||||
### 4. Auto-move shim on `action_finish_current_step` + `action_finish_and_advance`
|
||||
|
||||
Both methods finish "the current step" and (for the former) "auto-start the next". The shim adds:
|
||||
|
||||
- **Before finishing:** if `qty_at_step == 1` AND there's a next pending/ready step → record a `move(qty=1)` to the next step, then proceed.
|
||||
- **If `qty_at_step > 1`:** raise with a friendly message pointing at "Complete 1 → Next" or "Move…".
|
||||
- **If `qty_at_step == 0`:** proceed as today (the parts already moved via Move wizard or Complete 1 → Next).
|
||||
|
||||
The shim lives in `action_finish_and_advance` (on `fp.job.step`); `action_finish_current_step` (on `fp.job`) calls it, so it inherits the shim. Single point of behaviour.
|
||||
|
||||
```python
|
||||
def _fp_record_one_piece_auto_move(self):
|
||||
"""Helper called from action_finish_and_advance. Decides whether
|
||||
to silently record a move(qty=1) before the step finishes. Three
|
||||
cases:
|
||||
- qty_at_step == 0: nothing to do (parts already moved manually).
|
||||
- qty_at_step == 1 + downstream step exists: record move(1).
|
||||
- qty_at_step == 1 + no downstream (last step): no move; parts
|
||||
complete in place.
|
||||
- qty_at_step > 1 + downstream exists: raise (operator must use
|
||||
Complete 1 → Next or Move… to drain the step).
|
||||
- qty_at_step > 1 + no downstream (last step): allow; parts
|
||||
all complete in place. (qty_done auto-tick is Phase 2.)
|
||||
"""
|
||||
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 here complete in place. The
|
||||
# button_finish gate already permits this case; just allow.
|
||||
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 and next_step exists → record the 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
|
||||
```
|
||||
|
||||
Wired into `action_finish_and_advance` immediately before the existing finish logic:
|
||||
|
||||
```python
|
||||
def action_finish_and_advance(self):
|
||||
self.ensure_one()
|
||||
if self.state == 'in_progress':
|
||||
self._fp_record_one_piece_auto_move() # may raise on qty>1
|
||||
# ...rest unchanged (button_finish + auto-start next)
|
||||
```
|
||||
|
||||
### 5. View additions
|
||||
|
||||
In `fp_job_form_inherit.xml` (embedded step list):
|
||||
|
||||
```xml
|
||||
<!-- Complete 1 part and advance — streaming flow (large parts
|
||||
going one-by-one through the same step). Hidden when there's
|
||||
nothing parked or the step isn't actively running. -->
|
||||
<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"/>
|
||||
```
|
||||
|
||||
Placed in the row's button column, after "Pause" and before "Move…". The header `Finish & Next` button is unchanged in markup — the auto-move/qty-gate logic is entirely behind the existing button.
|
||||
|
||||
In the form header `<sheet>` block, change the `<h1>` to bind `display_name`:
|
||||
|
||||
```xml
|
||||
<h1><field name="display_name"/></h1>
|
||||
```
|
||||
|
||||
`qty_at_step` is already a list column on the embedded step list (visible as "Qty Here"). No change needed for visibility — the existing field declaration is sufficient for the `invisible=` expression.
|
||||
|
||||
## State transition diagram
|
||||
|
||||
```
|
||||
Before this work:
|
||||
in_progress ──button_finish──> done (no qty check)
|
||||
|
||||
After:
|
||||
any step, qty_at_step==0 ──button_finish──> done
|
||||
mid-recipe step, qty_at_step==1 ──Finish & Next──> [auto-move(1)] ──> done
|
||||
mid-recipe step, qty_at_step==1 ──Complete 1→Next──> [move(1)] ──> done + start_next
|
||||
mid-recipe step, qty_at_step>1 ──Complete 1→Next──> [move(1)] (stays in_progress)
|
||||
mid-recipe step, qty_at_step>1 ──Finish & Next──> ❌ UserError (use shortcuts)
|
||||
LAST recipe step, qty_at_step>0 ──Finish & Next──> done (no move; parts complete in place)
|
||||
```
|
||||
|
||||
"Mid-recipe step" = at least one downstream step is pending/ready. "LAST recipe step" = no downstream step in pending/ready state (either truly last, or all later steps are skipped/cancelled).
|
||||
|
||||
## Test plan
|
||||
|
||||
New class `TestQtyGate` in `tests/test_fp_job_milestone_cascade.py`:
|
||||
|
||||
| Test | Scenario | Expected |
|
||||
|---|---|---|
|
||||
| `test_button_finish_blocks_when_qty_at_step` | qty_at_step=3, click Finish | `UserError("still 3 parts parked")` |
|
||||
| `test_button_finish_bypass` | `fp_skip_qty_gate=True` context | state→done |
|
||||
| `test_complete_one_to_next_records_move` | qty=3 → click | move(qty=1) created, qty_at_step=2, state still in_progress |
|
||||
| `test_complete_one_to_next_auto_finishes_on_last` | qty=1 → click | move(qty=1), source state→done, next step started |
|
||||
| `test_complete_one_to_next_blocks_when_empty` | qty=0 | `UserError("nothing to complete")` |
|
||||
| `test_complete_one_to_next_blocks_when_no_next_step` | last step | `UserError("last runnable step")` |
|
||||
| `test_complete_one_to_next_blocks_when_not_in_progress` | state=pending | `UserError("must be in progress")` |
|
||||
| `test_finish_and_advance_auto_move_for_qty_1` | running step, qty_at_step=1 | move(qty=1) recorded, then finish + auto-start next |
|
||||
| `test_finish_and_advance_blocks_for_qty_gt_1` | running step, qty_at_step=3 | `UserError("use Complete 1 → Next or Move")` |
|
||||
| `test_finish_and_advance_passes_for_qty_0` | qty=0 (already moved) | finish proceeds, no extra move |
|
||||
| `test_button_finish_allows_last_step_with_qty` | last runnable step, qty_at_step=3, click Finish | state→done; no UserError; no move recorded |
|
||||
| `test_finish_and_advance_allows_last_step_with_qty_gt_1` | last runnable step, qty_at_step=5 | state→done; no auto-move; no UserError |
|
||||
| `test_display_name_format` | name=`WH/JOB/00099` | display_name=`Work Order # 00099` |
|
||||
| `test_display_name_no_slash_passthrough` | name=`SmokeJob` | display_name=`SmokeJob` |
|
||||
| `test_move_wizard_blocks_zero_qty` | wizard.qty_moved=0 → commit | `UserError("at least 1")` |
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_jobs/models/fp_job.py` | Add `_compute_display_name` override. |
|
||||
| `fusion_plating/models/fp_job_step.py` | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move` invoked from `action_finish_and_advance`. |
|
||||
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Header `<h1>` → `display_name`; per-row "Complete 1 → Next" button. |
|
||||
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | New `TestQtyGate` class with the 13 tests above. |
|
||||
| `fusion_plating_jobs/__manifest__.py` | Version bump. |
|
||||
| `fusion_plating/__manifest__.py` | Version bump (touches `fp_job_step.py`). |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Auto-tick `job.qty_done` when last step finishes.** Currently `qty_done` is operator-entered before the job-level "Mark Job Done" button. A future improvement: when the last runnable step finishes with `qty_at_step > 0`, automatically bump `job.qty_done` by that count. Skipped from Phase 1 because (a) the existing job-level qty-reconciliation gate already catches mismatches and (b) it requires capturing pre-finish `qty_at_step` into the existing-but-unused `qty_at_step_finish` field, which expands scope.
|
||||
- **Per-step scrap tracking** — currently scrap is captured at the *job* level (`qty_scrapped`). Per-step scrap (which step did each scrap event happen at?) is a real shop-floor desire but a bigger data-model change; future spec.
|
||||
- **Auto-finish on Move wizard's last move** — when the Move wizard records a move that drops `qty_at_step` to 0, it could optionally auto-finish the source step. Skipped because the Move wizard is already explicit (operator chose a qty); an extra confirmation step adds value. Can reconsider if the manual Finish click after a manual Move becomes a friction complaint.
|
||||
- **Display name in CoC / cert PDFs** — `display_name` automatically threads through Odoo's M2O rendering, but the CoC PDF template may hardcode `name` in places. Audit pass in a follow-up if/when shop reports the new label needs to land on customer-facing paperwork.
|
||||
|
||||
## Implementation notes / gotchas
|
||||
|
||||
- `qty_at_step` is `compute=False, store=False`. After creating a Move in `action_complete_one_to_next`, the in-memory cache still holds the pre-move value. Always call `invalidate_recordset(['qty_at_step'])` before reading it to decide auto-finish.
|
||||
- The Move wizard's existing zero-qty guard lives in `action_commit` (raises `UserError`). The new `action_complete_one_to_next` doesn't go through the wizard, so it has its own `qty_at_step < 1` check (gates differently — refuses when nothing to move, vs. refusing when qty entered is 0). Both surfaces are now protected.
|
||||
- `display_name` is a magic field in Odoo — overriding its compute is the supported pattern. Odoo's M2O widget, breadcrumb, and `name_get` API all route through it. No additional wiring needed.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.13.13',
|
||||
'version': '19.0.18.15.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -66,6 +66,22 @@ class FpJob(models.Model):
|
||||
default=lambda self: _('New'),
|
||||
index=True,
|
||||
)
|
||||
|
||||
@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 ''
|
||||
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# cancelled
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
@@ -109,10 +109,28 @@ class FpJobStep(models.Model):
|
||||
default='um',
|
||||
)
|
||||
dwell_time_minutes = fields.Float()
|
||||
bake_setpoint_temp = fields.Float(string='Bake Setpoint °C')
|
||||
# Label intentionally has no unit suffix — the unit follows the
|
||||
# company's `x_fc_default_temp_uom` setting and is surfaced via the
|
||||
# adjacent `bake_setpoint_temp_uom_display` compute. Hardcoding °C
|
||||
# in the label was the most visible "Celsius leaks everywhere"
|
||||
# offender flagged 2026-05-10.
|
||||
bake_setpoint_temp = fields.Float(string='Bake Setpoint')
|
||||
bake_setpoint_temp_uom_display = fields.Char(
|
||||
string='Unit',
|
||||
compute='_compute_bake_setpoint_temp_uom_display',
|
||||
help='Temperature unit pulled live from Settings → Fusion Plating → '
|
||||
'Units of Measure. Updates everywhere the moment the admin '
|
||||
'flips Fahrenheit ↔ Celsius.',
|
||||
)
|
||||
bake_actual_duration = fields.Float(string='Bake Actual Minutes')
|
||||
bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref')
|
||||
|
||||
@api.depends_context('company')
|
||||
def _compute_bake_setpoint_temp_uom_display(self):
|
||||
sym = '°F' if (self.env.company.x_fc_default_temp_uom or 'F') == 'F' else '°C'
|
||||
for rec in self:
|
||||
rec.bake_setpoint_temp_uom_display = sym
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recipe-related (Task 1.6)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -365,11 +383,28 @@ class FpJobStep(models.Model):
|
||||
return True
|
||||
|
||||
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 into. Last runnable step
|
||||
# is exempt — parts finishing there complete in place
|
||||
# (qty_done reconciliation at job close is the catch-net).
|
||||
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)
|
||||
@@ -382,3 +417,138 @@ class FpJobStep(models.Model):
|
||||
# Sum of all interval durations becomes duration_actual
|
||||
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||
return True
|
||||
|
||||
|
||||
# ===== Manager-only overrides ===========================================
|
||||
# Used when an operator skipped or cancelled a step in error, or when
|
||||
# the actual shop-floor work happened outside the ERP and the manager
|
||||
# needs to retroactively mark the step complete. Both actions are
|
||||
# group-gated and post a clear audit entry to the step's chatter.
|
||||
|
||||
def button_manager_force_complete(self):
|
||||
"""Force any non-done state straight to 'done'. Stamps the first-
|
||||
start / first-finish audit fields if blank so the timeline isn't
|
||||
broken, and closes any timelog still left open."""
|
||||
if not self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'):
|
||||
raise AccessError(_(
|
||||
'Only Plating Manager+ can force-complete a step.'
|
||||
))
|
||||
for step in self:
|
||||
if step.state == 'done':
|
||||
raise UserError(_(
|
||||
"Step '%s' is already done."
|
||||
) % step.name)
|
||||
prev_state = step.state
|
||||
now = fields.Datetime.now()
|
||||
# Close any open timelogs first — labour already incurred
|
||||
# stays in the audit even when we shortcut to done.
|
||||
open_log = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished
|
||||
)
|
||||
if open_log:
|
||||
open_log.write({'date_finished': now, 'state': 'stopped'})
|
||||
vals = {'state': 'done'}
|
||||
if not step.date_started:
|
||||
vals['date_started'] = now
|
||||
vals['started_by_user_id'] = self.env.user.id
|
||||
if not step.date_finished:
|
||||
vals['date_finished'] = now
|
||||
vals['finished_by_user_id'] = self.env.user.id
|
||||
step.write(vals)
|
||||
step.message_post(body=_(
|
||||
'Step force-completed by %s (was %s).'
|
||||
) % (self.env.user.name, prev_state))
|
||||
return True
|
||||
|
||||
def button_manager_reset_to_ready(self):
|
||||
"""Reset any non-ready step back to 'ready' so the operator can
|
||||
run it normally. Audited via chatter.
|
||||
|
||||
Side-effects, depending on the previous state:
|
||||
- in_progress / paused → close any open timelog (mirrors
|
||||
button_cancel) so labour already logged stays in the audit.
|
||||
- done → also clear date_finished + finished_by_user_id so the
|
||||
next button_finish writes fresh first-finish stamps instead
|
||||
of preserving stale ones.
|
||||
|
||||
date_started + started_by_user_id are preserved across resets —
|
||||
they record the first start ever (audit), and duration_actual is
|
||||
computed from the sum of timelogs, not (finish - start), so the
|
||||
elapsed math remains correct."""
|
||||
if not self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'):
|
||||
raise AccessError(_(
|
||||
'Only Plating Manager+ can reset a step state.'
|
||||
))
|
||||
now = fields.Datetime.now()
|
||||
for step in self:
|
||||
if step.state == 'ready':
|
||||
raise UserError(_(
|
||||
"Step '%s' is already in Ready state."
|
||||
) % step.name)
|
||||
prev_state = step.state
|
||||
vals = {'state': 'ready'}
|
||||
|
||||
# Close any still-open timelog (defensive — usually only
|
||||
# in_progress/paused will have one).
|
||||
open_log = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished
|
||||
)
|
||||
if open_log:
|
||||
open_log.write({'date_finished': now, 'state': 'stopped'})
|
||||
|
||||
# If the step had been completed, wipe the finish stamps so
|
||||
# the next Finish records fresh audit values. Skip this for
|
||||
# in_progress / paused / skipped / cancelled / pending — they
|
||||
# either have no finish stamp or shouldn't have one cleared.
|
||||
if step.state == 'done':
|
||||
vals['date_finished'] = False
|
||||
vals['finished_by_user_id'] = False
|
||||
|
||||
step.write(vals)
|
||||
step.message_post(body=_(
|
||||
'Step state reset to Ready by %s (was %s).'
|
||||
) % (self.env.user.name, prev_state))
|
||||
return True
|
||||
|
||||
|
||||
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 (via 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.
|
||||
self.invalidate_recordset(['qty_at_step'])
|
||||
if self.qty_at_step == 0:
|
||||
return self.action_finish_and_advance()
|
||||
return True
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
<h1><field name="display_name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.5.4.0',
|
||||
'version': '19.0.5.5.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
@@ -27,6 +27,7 @@ Includes Fischerscope thickness measurement data capture.
|
||||
'fusion_plating_portal',
|
||||
'fusion_plating_batch',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_plating_logistics',
|
||||
'sale_management',
|
||||
],
|
||||
'data': [
|
||||
|
||||
@@ -7,3 +7,4 @@ from . import fp_thickness_reading
|
||||
from . import fp_certificate
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
from . import fp_delivery
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Cert-aware extension of fusion.plating.delivery.
|
||||
|
||||
Hard-blocks action_mark_delivered when the linked job still has any
|
||||
draft certificate (CoC or Thickness Report). AS9100 / Nadcap
|
||||
compliance: parts can't ship without paperwork.
|
||||
|
||||
Manager bypass: pass context key `fp_skip_cert_gate=True` (matches
|
||||
the existing bypass convention on fp.job.button_mark_done).
|
||||
"""
|
||||
from odoo import _, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FusionPlatingDelivery(models.Model):
|
||||
_inherit = 'fusion.plating.delivery'
|
||||
|
||||
def action_mark_delivered(self):
|
||||
if not self.env.context.get('fp_skip_cert_gate'):
|
||||
Cert = self.env.get('fp.certificate')
|
||||
Job = self.env.get('fp.job')
|
||||
if Cert is not None and Job is not None:
|
||||
for delivery in self:
|
||||
if not delivery.job_ref:
|
||||
continue
|
||||
job = Job.search(
|
||||
[('name', '=', delivery.job_ref)], limit=1,
|
||||
)
|
||||
if not job:
|
||||
continue
|
||||
dom = [('state', '=', 'draft')]
|
||||
if 'x_fc_job_id' in Cert._fields:
|
||||
dom.append(('x_fc_job_id', '=', job.id))
|
||||
elif (job.sale_order_id
|
||||
and 'sale_order_id' in Cert._fields):
|
||||
dom.append((
|
||||
'sale_order_id', '=', job.sale_order_id.id,
|
||||
))
|
||||
else:
|
||||
continue
|
||||
draft_certs = Cert.search(dom)
|
||||
if draft_certs:
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery %(d)s shipped — job '
|
||||
'%(j)s still has %(n)d draft '
|
||||
'certificate(s) (%(types)s). Issue them '
|
||||
'first, or pass fp_skip_cert_gate=True '
|
||||
'context key to bypass.'
|
||||
) % {
|
||||
'd': delivery.name or delivery.id,
|
||||
'j': job.name,
|
||||
'n': len(draft_certs),
|
||||
'types': ', '.join(sorted(set(
|
||||
draft_certs.mapped('certificate_type')
|
||||
))),
|
||||
})
|
||||
return super().action_mark_delivered()
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.20.6',
|
||||
'version': '19.0.8.20.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -20,10 +20,10 @@ Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of
|
||||
the migration spec dated 2026-04-25) to the rest of the Fusion Plating
|
||||
module family — configurator, portal, logistics, quality, certificates.
|
||||
|
||||
As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only
|
||||
fulfilment path. SO confirm always creates fp.job records here. The
|
||||
former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0
|
||||
once the legacy fallback became unreachable.
|
||||
Coexists with fusion_plating_bridge_mrp during the migration period.
|
||||
Activate native jobs via the x_fc_use_native_jobs settings flag (default:
|
||||
False). When False, SO confirm continues to create mrp.production records
|
||||
through bridge_mrp. When True, SO confirm creates fp.job records here.
|
||||
|
||||
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
|
||||
OWL/controller stack (job_process_tree, job_plant_overview,
|
||||
@@ -57,6 +57,7 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
# so the statusbar's m2o has its targets available at view-render time).
|
||||
'data/fp_workflow_state_data.xml',
|
||||
'views/fp_workflow_state_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_job_step_quick_look_views.xml',
|
||||
'views/fp_job_form_inherit.xml',
|
||||
'views/fp_job_quality_buttons.xml',
|
||||
@@ -66,6 +67,7 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'views/fp_step_priority_views.xml',
|
||||
'views/jobs_in_shopfloor_menu.xml',
|
||||
'views/legacy_menu_hide.xml',
|
||||
'views/res_users_views.xml',
|
||||
'wizards/fp_job_step_move_wizard_views.xml',
|
||||
'wizards/fp_job_step_input_wizard_views.xml',
|
||||
'report/report_fp_job_sticker.xml',
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
<field name="code">shipped</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="color">success</field>
|
||||
<field name="trigger_default_kinds">ship</field>
|
||||
<field name="description">Shipment confirmed (BOL or carrier pickup). Customer can be notified.</field>
|
||||
<field name="trigger_on_delivery_state" eval="True"/>
|
||||
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
|
||||
</record>
|
||||
|
||||
<record id="workflow_state_done" model="fp.job.workflow.state">
|
||||
|
||||
@@ -106,6 +106,8 @@ class FpJob(models.Model):
|
||||
'step_ids.recipe_node_id.default_kind',
|
||||
'step_ids.recipe_node_id.triggers_workflow_state_id',
|
||||
'quality_hold_count',
|
||||
'delivery_id',
|
||||
'delivery_id.state',
|
||||
)
|
||||
def _compute_workflow_state_id(self):
|
||||
WS = self.env['fp.job.workflow.state']
|
||||
@@ -137,6 +139,210 @@ class FpJob(models.Model):
|
||||
timelog_count = fields.Integer(compute='_compute_smart_counts')
|
||||
portal_job_count = fields.Integer(compute='_compute_smart_counts')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Milestone cascade (Phase 1) — drives the header-button replacement
|
||||
# that fires when every recipe step reaches a terminal state. See
|
||||
# docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md.
|
||||
# ------------------------------------------------------------------
|
||||
all_steps_terminal = fields.Boolean(
|
||||
compute='_compute_all_steps_terminal',
|
||||
store=True,
|
||||
help='True ⇔ at least one step exists AND every step is in '
|
||||
'done/skipped/cancelled. Used to swap the per-step '
|
||||
'Finish & Next button for a milestone-advance button.',
|
||||
)
|
||||
|
||||
@api.depends('step_ids', 'step_ids.state')
|
||||
def _compute_all_steps_terminal(self):
|
||||
for job in self:
|
||||
if not job.step_ids:
|
||||
job.all_steps_terminal = False
|
||||
else:
|
||||
job.all_steps_terminal = all(
|
||||
s.state in ('done', 'skipped', 'cancelled')
|
||||
for s in job.step_ids
|
||||
)
|
||||
|
||||
def _resolve_required_cert_types(self):
|
||||
"""Set of cert types this job must produce.
|
||||
|
||||
Priority: part.certificate_requirement wins; 'inherit' falls
|
||||
back to partner-level send_coc / send_thickness_report flags.
|
||||
'none' returns empty (commercial customer, no paperwork).
|
||||
Unknown requirement codes default to {'coc'} as a safety net.
|
||||
"""
|
||||
self.ensure_one()
|
||||
req = (
|
||||
self.part_catalog_id
|
||||
and self.part_catalog_id.certificate_requirement
|
||||
) or 'inherit'
|
||||
if req == 'inherit':
|
||||
types = set()
|
||||
if self.partner_id.x_fc_send_coc:
|
||||
types.add('coc')
|
||||
if self.partner_id.x_fc_send_thickness_report:
|
||||
types.add('thickness_report')
|
||||
return types
|
||||
return {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
}.get(req, {'coc'})
|
||||
|
||||
next_milestone_action = fields.Selection(
|
||||
[
|
||||
('mark_done', 'Mark Job Done'),
|
||||
('issue_certs', 'Issue Certs'),
|
||||
('schedule_delivery', 'Schedule Delivery'),
|
||||
('mark_shipped', 'Mark Shipped'),
|
||||
('closed', 'Closed'),
|
||||
],
|
||||
compute='_compute_next_milestone_action',
|
||||
help='What the manager should click next once steps complete. '
|
||||
'Drives the milestone-advance buttons on the form header. '
|
||||
'False/empty while steps are still running.',
|
||||
)
|
||||
next_milestone_label = fields.Char(
|
||||
compute='_compute_next_milestone_action',
|
||||
help='Human label for the next-action button.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'all_steps_terminal',
|
||||
'state',
|
||||
'delivery_id',
|
||||
'delivery_id.state',
|
||||
)
|
||||
def _compute_next_milestone_action(self):
|
||||
"""Resolve next action in priority order:
|
||||
1. NOT all_steps_terminal → False (Finish & Next stays)
|
||||
2. state != 'done' → mark_done
|
||||
3. ANY required draft cert → issue_certs
|
||||
4. NO delivery or draft → schedule_delivery
|
||||
5. delivery scheduled/transit → mark_shipped
|
||||
6. otherwise (delivered) → closed
|
||||
"""
|
||||
labels = dict(self._fields['next_milestone_action'].selection)
|
||||
for job in self:
|
||||
if not job.all_steps_terminal:
|
||||
job.next_milestone_action = False
|
||||
job.next_milestone_label = ''
|
||||
continue
|
||||
if job.state != 'done':
|
||||
job.next_milestone_action = 'mark_done'
|
||||
elif job._fp_has_draft_required_certs():
|
||||
job.next_milestone_action = 'issue_certs'
|
||||
elif (not job.delivery_id
|
||||
or job.delivery_id.state == 'draft'):
|
||||
job.next_milestone_action = 'schedule_delivery'
|
||||
elif job.delivery_id.state in ('scheduled', 'in_transit'):
|
||||
job.next_milestone_action = 'mark_shipped'
|
||||
else:
|
||||
job.next_milestone_action = 'closed'
|
||||
job.next_milestone_label = labels.get(
|
||||
job.next_milestone_action, ''
|
||||
)
|
||||
|
||||
def _fp_has_draft_required_certs(self):
|
||||
"""True if at least one cert of a required type is still 'draft'.
|
||||
Returns False when no certs are required (commercial customers).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.certificate' not in self.env:
|
||||
return False
|
||||
required = self._resolve_required_cert_types()
|
||||
if not required:
|
||||
return False
|
||||
Cert = self.env['fp.certificate']
|
||||
dom = [
|
||||
('certificate_type', 'in', list(required)),
|
||||
('state', '=', 'draft'),
|
||||
]
|
||||
if 'x_fc_job_id' in Cert._fields:
|
||||
dom.append(('x_fc_job_id', '=', self.id))
|
||||
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
|
||||
dom.append(('sale_order_id', '=', self.sale_order_id.id))
|
||||
else:
|
||||
return False # can't link safely → don't block the cascade
|
||||
return bool(Cert.search_count(dom))
|
||||
|
||||
def action_advance_next_milestone(self):
|
||||
"""Single entry point bound to all four milestone header buttons.
|
||||
Branches on next_milestone_action and delegates to the existing
|
||||
business-logic method. Never invents new logic — just routes."""
|
||||
self.ensure_one()
|
||||
action_map = {
|
||||
'mark_done': self.button_mark_done,
|
||||
'issue_certs': self._action_open_draft_certs,
|
||||
'schedule_delivery': self._action_open_draft_delivery,
|
||||
'mark_shipped': self._action_mark_active_delivery_delivered,
|
||||
}
|
||||
fn = action_map.get(self.next_milestone_action)
|
||||
if not fn:
|
||||
raise UserError(_(
|
||||
'No milestone action available for job %(j)s '
|
||||
'(next=%(a)s).'
|
||||
) % {
|
||||
'j': self.name,
|
||||
'a': self.next_milestone_action or 'none',
|
||||
})
|
||||
return fn()
|
||||
|
||||
def _action_open_draft_certs(self):
|
||||
"""Open the cert list filtered to draft certs for this job.
|
||||
Manager reviews each in turn and clicks Issue per-cert."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Draft Certificates — %s') % self.name,
|
||||
'res_model': 'fp.certificate',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('x_fc_job_id', '=', self.id),
|
||||
('state', '=', 'draft'),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _action_open_draft_delivery(self):
|
||||
"""Open the linked delivery if it's still in draft state.
|
||||
Falls back to the delivery list filtered to this job's
|
||||
delivery if the state isn't draft (defensive)."""
|
||||
self.ensure_one()
|
||||
if self.delivery_id and self.delivery_id.state == 'draft':
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Schedule Delivery — %s') % self.name,
|
||||
'res_model': 'fusion.plating.delivery',
|
||||
'res_id': self.delivery_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Deliveries — %s') % self.name,
|
||||
'res_model': 'fusion.plating.delivery',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('job_ref', '=', self.name)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _action_mark_active_delivery_delivered(self):
|
||||
"""Call action_mark_delivered on the linked delivery if it's
|
||||
in scheduled / in_transit. Posts to job chatter on success."""
|
||||
self.ensure_one()
|
||||
if (not self.delivery_id
|
||||
or self.delivery_id.state not in ('scheduled', 'in_transit')):
|
||||
raise UserError(_(
|
||||
'No scheduled or in-transit delivery to mark shipped '
|
||||
'for %s.'
|
||||
) % self.name)
|
||||
self.delivery_id.action_mark_delivered()
|
||||
self.message_post(body=_(
|
||||
'Delivery %s marked shipped via milestone cascade.'
|
||||
) % self.delivery_id.name)
|
||||
return True
|
||||
|
||||
@api.depends(
|
||||
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
|
||||
'step_ids.time_log_ids', 'origin', 'partner_id',
|
||||
@@ -374,6 +580,15 @@ class FpJob(models.Model):
|
||||
'fusion_plating_jobs.action_report_fp_job_traveller'
|
||||
).report_action(self)
|
||||
|
||||
def action_print_sticker(self):
|
||||
"""Print the 6x4" job-box identification sticker (logo + WO# + QR
|
||||
+ part / customer / thickness / notes). Used at receiving and at
|
||||
every move so the box is always identifiable on the floor."""
|
||||
self.ensure_one()
|
||||
return self.env.ref(
|
||||
'fusion_plating_jobs.action_report_fp_job_sticker'
|
||||
).report_action(self)
|
||||
|
||||
def action_print_wo_detail(self):
|
||||
"""Print the Steelhead-style Work Order Detail PDF — chronological
|
||||
chain-of-custody + per-step inputs + Certified By page. Use this
|
||||
@@ -1285,93 +1500,102 @@ class FpJob(models.Model):
|
||||
)
|
||||
|
||||
def _fp_create_certificates(self):
|
||||
"""Trigger cert auto-create on job done.
|
||||
"""Auto-create one draft fp.certificate per type returned by
|
||||
_resolve_required_cert_types. Idempotent per type — re-running
|
||||
on a job that already has a CoC won't create another one.
|
||||
|
||||
Pre-populates ALL the fields a CoC issuer needs so Tom can hit
|
||||
Issue without filling 6 fields first:
|
||||
- partner_id from job
|
||||
- spec_reference from coating (required by action_issue)
|
||||
- part_number from part_catalog
|
||||
- quantity_shipped from job qty (minus scrap)
|
||||
- po_number from sale_order
|
||||
- sale_order_id link
|
||||
- x_fc_job_id link if the field exists
|
||||
Each cert is pre-populated with everything action_issue needs
|
||||
(partner, spec_reference, part_number, quantity_shipped, po,
|
||||
SO link, job link) so the manager just reviews and clicks Issue.
|
||||
|
||||
Idempotent — if a cert already exists for this job, skip
|
||||
(prevents dupes when button_mark_done is re-run after a
|
||||
manager bypass).
|
||||
Replaces the single-CoC implementation: now honours
|
||||
part.certificate_requirement (coc / coc_thickness / none /
|
||||
inherit) and partner-level send_coc / send_thickness_report
|
||||
flags. Closes spec gap C-G1.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.certificate' not in self.env:
|
||||
return
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
# Idempotency: don't double-create on retry.
|
||||
existing_dom = []
|
||||
if 'x_fc_job_id' in Cert._fields:
|
||||
existing_dom.append(('x_fc_job_id', '=', self.id))
|
||||
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
|
||||
existing_dom.append(('sale_order_id', '=', self.sale_order_id.id))
|
||||
if existing_dom:
|
||||
existing = Cert.search(existing_dom, limit=1)
|
||||
if existing:
|
||||
_logger.info(
|
||||
'Job %s: cert %s already exists, skipping auto-create',
|
||||
self.name, existing.name,
|
||||
required = self._resolve_required_cert_types()
|
||||
if not required:
|
||||
return
|
||||
has_job_link = 'x_fc_job_id' in Cert._fields
|
||||
coating = self.coating_config_id
|
||||
for cert_type in sorted(required):
|
||||
# Idempotency per type.
|
||||
existing_dom = [('certificate_type', '=', cert_type)]
|
||||
if has_job_link:
|
||||
existing_dom.append(('x_fc_job_id', '=', self.id))
|
||||
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
|
||||
existing_dom.append(
|
||||
('sale_order_id', '=', self.sale_order_id.id),
|
||||
)
|
||||
return
|
||||
try:
|
||||
vals = {'partner_id': self.partner_id.id}
|
||||
if 'certificate_type' in Cert._fields:
|
||||
vals['certificate_type'] = 'coc'
|
||||
if 'state' in Cert._fields:
|
||||
vals['state'] = 'draft'
|
||||
# Job + SO links.
|
||||
if 'x_fc_job_id' in Cert._fields:
|
||||
vals['x_fc_job_id'] = self.id
|
||||
elif 'job_id' in Cert._fields:
|
||||
vals['job_id'] = self.id
|
||||
if 'sale_order_id' in Cert._fields and self.sale_order_id:
|
||||
vals['sale_order_id'] = self.sale_order_id.id
|
||||
# Pre-fill from coating: the spec_reference is what action_issue
|
||||
# blocks on — without this every cert needs a manual edit.
|
||||
coating = self.coating_config_id
|
||||
if coating and 'spec_reference' in Cert._fields \
|
||||
and getattr(coating, 'spec_reference', False):
|
||||
vals['spec_reference'] = coating.spec_reference
|
||||
# Pre-fill part_number from the part catalog if we have one.
|
||||
if 'part_number' in Cert._fields and self.part_catalog_id:
|
||||
vals['part_number'] = self.part_catalog_id.part_number or ''
|
||||
# Quantity shipped = job qty minus scrap. AS9100 wants the
|
||||
# actual count that left the shop, not the order count.
|
||||
if 'quantity_shipped' in Cert._fields:
|
||||
vals['quantity_shipped'] = int(
|
||||
(self.qty_done or self.qty or 0) - (self.qty_scrapped or 0)
|
||||
else:
|
||||
continue # can't safely identify — skip
|
||||
if Cert.search_count(existing_dom):
|
||||
continue
|
||||
try:
|
||||
vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
'certificate_type': cert_type,
|
||||
}
|
||||
if 'state' in Cert._fields:
|
||||
vals['state'] = 'draft'
|
||||
if has_job_link:
|
||||
vals['x_fc_job_id'] = self.id
|
||||
elif 'job_id' in Cert._fields:
|
||||
vals['job_id'] = self.id
|
||||
if 'sale_order_id' in Cert._fields and self.sale_order_id:
|
||||
vals['sale_order_id'] = self.sale_order_id.id
|
||||
# spec_reference is what action_issue blocks on.
|
||||
if coating and 'spec_reference' in Cert._fields \
|
||||
and getattr(coating, 'spec_reference', False):
|
||||
vals['spec_reference'] = coating.spec_reference
|
||||
if 'part_number' in Cert._fields and self.part_catalog_id:
|
||||
vals['part_number'] = (
|
||||
self.part_catalog_id.part_number or ''
|
||||
)
|
||||
if 'quantity_shipped' in Cert._fields:
|
||||
vals['quantity_shipped'] = int(
|
||||
(self.qty_done or self.qty or 0)
|
||||
- (self.qty_scrapped or 0)
|
||||
)
|
||||
if 'po_number' in Cert._fields and self.sale_order_id \
|
||||
and 'x_fc_po_number' in self.sale_order_id._fields:
|
||||
vals['po_number'] = (
|
||||
self.sale_order_id.x_fc_po_number or ''
|
||||
)
|
||||
if 'customer_job_no' in Cert._fields \
|
||||
and self.sale_order_id \
|
||||
and 'x_fc_customer_job_number' \
|
||||
in self.sale_order_id._fields:
|
||||
vals['customer_job_no'] = (
|
||||
self.sale_order_id.x_fc_customer_job_number or ''
|
||||
)
|
||||
if 'process_description' in Cert._fields and coating:
|
||||
vals['process_description'] = coating.name or ''
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'%(t)s <b>%(n)s</b> auto-created (draft). Issuer '
|
||||
'should hit Issue when ready to ship.'
|
||||
)) % {
|
||||
't': dict(
|
||||
Cert._fields['certificate_type'].selection
|
||||
).get(cert_type, cert_type),
|
||||
'n': cert.name,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: failed to auto-create cert (%s): %s",
|
||||
self.name, cert_type, e,
|
||||
)
|
||||
# PO number from the source SO.
|
||||
if 'po_number' in Cert._fields and self.sale_order_id \
|
||||
and 'x_fc_po_number' in self.sale_order_id._fields:
|
||||
vals['po_number'] = self.sale_order_id.x_fc_po_number or ''
|
||||
# Customer job# → cert label (helps customer search).
|
||||
if 'customer_job_no' in Cert._fields and self.sale_order_id \
|
||||
and 'x_fc_customer_job_number' in self.sale_order_id._fields:
|
||||
vals['customer_job_no'] = (
|
||||
self.sale_order_id.x_fc_customer_job_number or ''
|
||||
)
|
||||
# Process description from coating name.
|
||||
if 'process_description' in Cert._fields and coating:
|
||||
vals['process_description'] = coating.name or ''
|
||||
# Job # for shop-side reference.
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'CoC <b>%s</b> auto-created (draft). Issuer should hit '
|
||||
'the Issue button on the certificate when ready to ship.'
|
||||
)) % cert.name)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: failed to auto-create cert: %s", self.name, e,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Cert auto-create (%(t)s) failed: %(e)s. '
|
||||
'Create manually.'
|
||||
) % {'t': cert_type, 'e': e})
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
|
||||
@@ -367,16 +367,6 @@ class FpJobStep(models.Model):
|
||||
if cr_action:
|
||||
return cr_action
|
||||
|
||||
# Racking step routing — same idea as Contract Review. If the
|
||||
# operator clicks Finish on a Racking step but the linked
|
||||
# racking inspection isn't done yet, route them straight to
|
||||
# the inspection form instead of throwing a "find the smart
|
||||
# button" error message. They complete the line check-off,
|
||||
# mark Done, and re-click Finish & Next to advance.
|
||||
ri_action = self._fp_racking_inspection_redirect()
|
||||
if ri_action:
|
||||
return ri_action
|
||||
|
||||
# Prompt-first behaviour: show the Record Inputs dialog when the
|
||||
# recipe step has authored prompts and nothing has been captured
|
||||
# in this run. Bypass when context flag is set (i.e. we're being
|
||||
@@ -386,6 +376,11 @@ class FpJobStep(models.Model):
|
||||
and self._fp_has_uncaptured_step_inputs()):
|
||||
return self._fp_open_input_wizard(advance_after=True)
|
||||
|
||||
# Auto-move shim: for qty_at_step==1 + downstream step,
|
||||
# silently record a move(qty=1) so the qty gate in
|
||||
# button_finish passes. Raises for qty>1 (operator must use
|
||||
# Complete 1 → Next or Move…). Last step is always allowed.
|
||||
self._fp_record_one_piece_auto_move()
|
||||
self.button_finish()
|
||||
next_step = self._fp_next_runnable_step()
|
||||
if next_step:
|
||||
@@ -641,34 +636,15 @@ class FpJobStep(models.Model):
|
||||
def _fp_open_contract_review(self):
|
||||
"""Auto-create the QA-005 form for this step's part if missing,
|
||||
return the act_window pointing at it. Called from button_start
|
||||
on Contract Review steps.
|
||||
|
||||
Returns None when the review is already satisfied (state
|
||||
'complete' or 'dismissed') — letting button_start fall through
|
||||
to the standard path so the step starts directly, without an
|
||||
unnecessary detour through an already-signed form. This mirrors
|
||||
the Finish & Next redirect behaviour: once contract review is
|
||||
cleared for a part, neither Start nor Finish stops to ask
|
||||
about it again.
|
||||
|
||||
Also short-circuits when the customer doesn't require contract
|
||||
review and via the manager-bypass context flag, to keep entry
|
||||
and finish gates in lockstep.
|
||||
"""
|
||||
on Contract Review steps."""
|
||||
self.ensure_one()
|
||||
if self.env.context.get('fp_skip_contract_review_gate'):
|
||||
return None
|
||||
part = self._fp_resolve_contract_review_part()
|
||||
if not part:
|
||||
return None
|
||||
if not part.partner_id.x_fc_contract_review_required:
|
||||
return None
|
||||
Review = self.env.get('fp.contract.review')
|
||||
if Review is None:
|
||||
return None # quality module not installed — skip
|
||||
review = part.x_fc_contract_review_id
|
||||
if review and review.state in ('complete', 'dismissed'):
|
||||
return None # already satisfied — fall through to normal start
|
||||
if not review:
|
||||
review = Review.sudo().create({
|
||||
'part_id': part.id,
|
||||
@@ -796,46 +772,6 @@ class FpJobStep(models.Model):
|
||||
'name': _('Racking Inspection — %s') % self.job_id.name,
|
||||
}
|
||||
|
||||
def _fp_racking_inspection_redirect(self):
|
||||
"""Return an act_window opening the linked racking inspection
|
||||
form, or False to indicate "no redirect needed".
|
||||
|
||||
Mirrors ``_fp_contract_review_redirect``. Triggers when:
|
||||
* this step is a Racking step (matched by ``_fp_is_racking_step``)
|
||||
* the linked ``fp.racking.inspection`` exists and is NOT yet in
|
||||
a terminal state (``done`` / ``discrepancy_flagged``)
|
||||
|
||||
When the inspection is already terminal — or doesn't exist at
|
||||
all — returns False so action_finish_and_advance falls through
|
||||
to the normal finish path. The hard gate
|
||||
(``_fp_check_racking_inspection_complete``) still fires from
|
||||
``button_finish`` for any caller that bypasses the redirect.
|
||||
|
||||
Manager bypass via ``fp_skip_racking_inspection_gate=True``.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.env.context.get('fp_skip_racking_inspection_gate'):
|
||||
return False
|
||||
if not self._fp_is_racking_step():
|
||||
return False
|
||||
if 'fp.racking.inspection' not in self.env:
|
||||
return False
|
||||
ri = self.job_id.racking_inspection_id
|
||||
if not ri:
|
||||
# No inspection record at all — let the soft gate handle
|
||||
# this with a chatter warning, don't redirect.
|
||||
return False
|
||||
if ri.state in ('done', 'discrepancy_flagged'):
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.racking.inspection',
|
||||
'res_id': ri.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'name': _('Racking Inspection — %s') % self.job_id.name,
|
||||
}
|
||||
|
||||
def _fp_check_racking_inspection_complete(self):
|
||||
"""Soft gate — block button_finish on a Racking step until the
|
||||
linked inspection is in a terminal state. discrepancy_flagged
|
||||
@@ -1008,51 +944,32 @@ class FpJobStep(models.Model):
|
||||
"""Return an ir.actions.act_window opening the part's QA-005
|
||||
Contract Review form, or False to indicate "no redirect needed".
|
||||
|
||||
Triggers when ALL of these are true:
|
||||
* the step is a Contract Review step (matched via
|
||||
``_fp_is_contract_review_step`` — name OR template kind OR
|
||||
node kind, same as the finish-time gate),
|
||||
* the customer requires contract review
|
||||
(``partner.x_fc_contract_review_required = True``), AND
|
||||
* the linked part either has no review yet OR the review is
|
||||
still in a non-terminal state (draft / assistant_review /
|
||||
manager_review).
|
||||
Triggers when:
|
||||
* the recipe node is flagged default_kind='contract_review', AND
|
||||
* the linked part has no review yet OR the review is still in
|
||||
a non-terminal state (draft / assistant_review / manager_review).
|
||||
|
||||
Once the review reaches state 'complete' or 'dismissed' the
|
||||
step is allowed to finish through the normal path. This is how
|
||||
Finish & Next moves on to the next step automatically once the
|
||||
contract review is already satisfied for that part — including
|
||||
when the review was completed on a previous order.
|
||||
Once the review reaches state 'complete' or 'dismissed' the step
|
||||
is allowed to finish through the normal path, which is how the
|
||||
operator clears the contract-review gate after signing QA-005.
|
||||
|
||||
Resolution mirrors ``_fp_check_contract_review_complete`` so a
|
||||
single source of truth governs both ENTRY (this redirect) and
|
||||
FINISH (the gate) — they always agree on whether a step is a
|
||||
contract review and which part it's bound to.
|
||||
|
||||
Soft-fail: if no part can be resolved we fall through to the
|
||||
standard wizard rather than blocking the operator.
|
||||
Soft-fail: if the job has no part_catalog_id we cannot route to
|
||||
a per-part review, so we fall through to the standard wizard
|
||||
rather than blocking the operator.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Manager bypass — same context flag the gate honours.
|
||||
if self.env.context.get('fp_skip_contract_review_gate'):
|
||||
node = self.recipe_node_id
|
||||
if not node or node.default_kind != 'contract_review':
|
||||
return False
|
||||
if not self._fp_is_contract_review_step():
|
||||
return False
|
||||
part = self._fp_resolve_contract_review_part() \
|
||||
or self.job_id.part_catalog_id
|
||||
part = self.job_id.part_catalog_id
|
||||
if not part:
|
||||
_logger.warning(
|
||||
"Contract-review step '%s' on job %s has no part — "
|
||||
"cannot redirect to QA-005 form, falling through to "
|
||||
"Contract-review step '%s' on job %s has no part_catalog_id "
|
||||
"— cannot redirect to QA-005 form, falling through to "
|
||||
"standard wizard.",
|
||||
self.name, self.job_id.name,
|
||||
)
|
||||
return False
|
||||
# Customer flag check — when the customer doesn't require
|
||||
# contract review, the redirect doesn't fire and the step
|
||||
# finishes through the normal path. Matches the gate's policy.
|
||||
if not part.partner_id.x_fc_contract_review_required:
|
||||
return False
|
||||
review = part.x_fc_contract_review_id
|
||||
if review and review.state in ('complete', 'dismissed'):
|
||||
return False
|
||||
@@ -1110,28 +1027,6 @@ class FpJobStep(models.Model):
|
||||
related='recipe_node_id.collect_measurements',
|
||||
readonly=True,
|
||||
)
|
||||
# Job context related fields — used by the quick-look modal so the
|
||||
# operator can see which job / customer / part / qty this step
|
||||
# belongs to without opening the parent job form. Related (not
|
||||
# stored) so they always reflect the live job record.
|
||||
quick_look_partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
related='job_id.partner_id', readonly=True,
|
||||
)
|
||||
quick_look_part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
related='job_id.part_catalog_id', readonly=True,
|
||||
)
|
||||
quick_look_qty = fields.Float(
|
||||
string='Order Qty',
|
||||
related='job_id.qty', readonly=True,
|
||||
)
|
||||
quick_look_instruction_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
string='Instruction Images',
|
||||
related='recipe_node_id.instruction_attachment_ids',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_prompt_ids = fields.Many2many(
|
||||
'fusion.plating.process.node.input',
|
||||
string='Prompts',
|
||||
@@ -1197,3 +1092,43 @@ class FpJobStep(models.Model):
|
||||
'target': 'new',
|
||||
'name': self.name,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
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))
|
||||
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
|
||||
|
||||
@@ -129,6 +129,18 @@ class FpJobWorkflowState(models.Model):
|
||||
'is in done/skipped state. Used for the "Done" milestone.',
|
||||
)
|
||||
|
||||
trigger_on_delivery_state = fields.Boolean(
|
||||
string='Trigger on Delivery Delivered',
|
||||
default=False,
|
||||
help='Special trigger — passes once the fusion.plating.delivery '
|
||||
'linked to the job (job.delivery_id) reaches state="delivered". '
|
||||
'Used for the Shipped milestone in lieu of recipe-side '
|
||||
'default_kind="ship" tagging. Shipping is logistics, not '
|
||||
'manufacturing — keeping the trigger off the recipe lets us '
|
||||
'route deliveries (split shipments, RMA reverse-flow, '
|
||||
'customer pickup) independently from plating steps.',
|
||||
)
|
||||
|
||||
block_when_quality_hold = fields.Boolean(
|
||||
string='Blocked by Quality Hold',
|
||||
default=False,
|
||||
@@ -180,6 +192,12 @@ class FpJobWorkflowState(models.Model):
|
||||
return False
|
||||
return all(s.state in ('done', 'skipped') for s in non_cancelled)
|
||||
|
||||
# Special trigger: linked delivery has been marked delivered
|
||||
if self.trigger_on_delivery_state:
|
||||
return bool(
|
||||
job.delivery_id and job.delivery_id.state == 'delivered'
|
||||
)
|
||||
|
||||
# Special trigger: first wet step started
|
||||
if self.trigger_first_step_started:
|
||||
wet_kinds = ('wet', 'bake', 'mask', 'rack')
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_fp_job_extensions
|
||||
from . import test_fp_job_milestone_cascade
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Milestone cascade Phase 1 tests.
|
||||
|
||||
Covers:
|
||||
- all_steps_terminal (Task 2)
|
||||
- _resolve_required_cert_types (Task 3)
|
||||
- _fp_create_certificates (Task 4)
|
||||
- next_milestone_action (Task 5)
|
||||
- action_advance_next_milestone dispatcher (Task 6)
|
||||
- action_mark_delivered cert gate (Task 8)
|
||||
|
||||
See docs/superpowers/plans/2026-05-12-job-milestone-cascade.md.
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestMilestoneCascade(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'CustA'})
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'Widget',
|
||||
})
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
def _make_step(self, job, name='Step', state='pending'):
|
||||
return self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': name,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
# ---------------- Task 2: all_steps_terminal ----------------------
|
||||
|
||||
def test_all_steps_terminal_false_when_no_steps(self):
|
||||
job = self._make_job()
|
||||
self.assertFalse(job.all_steps_terminal)
|
||||
|
||||
def test_all_steps_terminal_false_when_any_step_pending(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
self._make_step(job, state='pending')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertFalse(job.all_steps_terminal)
|
||||
|
||||
def test_all_steps_terminal_true_when_all_done(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
self._make_step(job, state='done')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertTrue(job.all_steps_terminal)
|
||||
|
||||
def test_all_steps_terminal_true_with_skipped_and_cancelled(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
self._make_step(job, state='skipped')
|
||||
self._make_step(job, state='cancelled')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertTrue(job.all_steps_terminal)
|
||||
|
||||
# ---------------- Task 3: _resolve_required_cert_types -----------
|
||||
|
||||
def _make_part(self, certificate_requirement='inherit'):
|
||||
return self.env['fp.part.catalog'].create({
|
||||
'name': 'PartA',
|
||||
'part_number': 'PN-001-%s' % certificate_requirement,
|
||||
'partner_id': self.partner.id,
|
||||
'certificate_requirement': certificate_requirement,
|
||||
})
|
||||
|
||||
def test_resolve_certs_none_returns_empty(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
def test_resolve_certs_coc_only(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), {'coc'})
|
||||
|
||||
def test_resolve_certs_coc_plus_thickness(self):
|
||||
part = self._make_part(certificate_requirement='coc_thickness')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(
|
||||
job._resolve_required_cert_types(),
|
||||
{'coc', 'thickness_report'},
|
||||
)
|
||||
|
||||
def test_resolve_certs_inherit_falls_back_to_partner(self):
|
||||
part = self._make_part(certificate_requirement='inherit')
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(
|
||||
job._resolve_required_cert_types(),
|
||||
{'coc', 'thickness_report'},
|
||||
)
|
||||
|
||||
def test_resolve_certs_inherit_partner_says_no(self):
|
||||
part = self._make_part(certificate_requirement='inherit')
|
||||
self.partner.x_fc_send_coc = False
|
||||
self.partner.x_fc_send_thickness_report = False
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
def test_resolve_certs_no_part_no_partner_flags(self):
|
||||
self.partner.x_fc_send_coc = False
|
||||
self.partner.x_fc_send_thickness_report = False
|
||||
job = self._make_job()
|
||||
self.assertEqual(job._resolve_required_cert_types(), set())
|
||||
|
||||
# ---------------- Task 4: _fp_create_certificates -----------------
|
||||
|
||||
def test_create_certs_skips_when_no_required(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertFalse(certs)
|
||||
|
||||
def test_create_certs_coc_only(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
self.assertEqual(certs.certificate_type, 'coc')
|
||||
self.assertEqual(certs.state, 'draft')
|
||||
|
||||
def test_create_certs_coc_plus_thickness(self):
|
||||
part = self._make_part(certificate_requirement='coc_thickness')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 2)
|
||||
self.assertEqual(
|
||||
set(certs.mapped('certificate_type')),
|
||||
{'coc', 'thickness_report'},
|
||||
)
|
||||
|
||||
def test_create_certs_idempotent(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job._fp_create_certificates()
|
||||
job._fp_create_certificates() # second call must be no-op
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
|
||||
# ---------------- Task 5: next_milestone_action -------------------
|
||||
|
||||
def test_next_milestone_false_while_steps_running(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='pending')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
self.assertFalse(job.next_milestone_action)
|
||||
|
||||
def test_next_milestone_mark_done_when_state_not_done(self):
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='done')
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
# default state is draft after create
|
||||
self.assertNotEqual(job.state, 'done')
|
||||
self.assertEqual(job.next_milestone_action, 'mark_done')
|
||||
self.assertEqual(job.next_milestone_label, 'Mark Job Done')
|
||||
|
||||
def test_next_milestone_issue_certs_when_draft_cert_exists(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates() # creates draft CoC
|
||||
job.invalidate_recordset([
|
||||
'all_steps_terminal', 'next_milestone_action',
|
||||
])
|
||||
self.assertEqual(job.next_milestone_action, 'issue_certs')
|
||||
|
||||
def test_next_milestone_schedule_delivery_when_no_certs(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
job.invalidate_recordset([
|
||||
'all_steps_terminal', 'next_milestone_action',
|
||||
])
|
||||
self.assertEqual(job.next_milestone_action, 'schedule_delivery')
|
||||
|
||||
def test_next_milestone_closed_when_delivered(self):
|
||||
part = self._make_part(certificate_requirement='none')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'delivered',
|
||||
})
|
||||
job.delivery_id = delivery.id
|
||||
job.invalidate_recordset([
|
||||
'all_steps_terminal', 'next_milestone_action',
|
||||
])
|
||||
self.assertEqual(job.next_milestone_action, 'closed')
|
||||
|
||||
# ---------------- Task 6: dispatcher ------------------------------
|
||||
|
||||
def test_dispatcher_raises_when_no_action(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job()
|
||||
self._make_step(job, state='pending') # not terminal
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
with self.assertRaises(UserError):
|
||||
job.action_advance_next_milestone()
|
||||
|
||||
def test_open_draft_certs_returns_filtered_action(self):
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
self._make_step(job, state='done')
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
action = job._action_open_draft_certs()
|
||||
self.assertEqual(action['res_model'], 'fp.certificate')
|
||||
self.assertIn(('state', '=', 'draft'), action['domain'])
|
||||
self.assertIn(('x_fc_job_id', '=', job.id), action['domain'])
|
||||
|
||||
def test_open_draft_delivery_returns_form_when_draft(self):
|
||||
job = self._make_job()
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'draft',
|
||||
})
|
||||
job.delivery_id = delivery.id
|
||||
action = job._action_open_draft_delivery()
|
||||
self.assertEqual(action['res_model'], 'fusion.plating.delivery')
|
||||
self.assertEqual(action.get('res_id'), delivery.id)
|
||||
self.assertEqual(action['view_mode'], 'form')
|
||||
|
||||
def test_open_draft_delivery_falls_back_to_list(self):
|
||||
# Delivery not draft → returns list view filtered to this job.
|
||||
job = self._make_job()
|
||||
self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'delivered',
|
||||
})
|
||||
action = job._action_open_draft_delivery()
|
||||
self.assertEqual(action['view_mode'], 'list,form')
|
||||
self.assertIn(('job_ref', '=', job.name), action['domain'])
|
||||
|
||||
def test_mark_active_raises_without_active_delivery(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job()
|
||||
with self.assertRaises(UserError):
|
||||
job._action_mark_active_delivery_delivered()
|
||||
|
||||
# ---------------- Task 8: cert gate on action_mark_delivered ------
|
||||
|
||||
def test_mark_delivered_blocks_on_draft_certs(self):
|
||||
from odoo.exceptions import UserError
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates() # creates one draft CoC
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'scheduled',
|
||||
})
|
||||
with self.assertRaises(UserError):
|
||||
delivery.action_mark_delivered()
|
||||
|
||||
def test_mark_delivered_bypass_skips_cert_gate(self):
|
||||
"""With fp_skip_cert_gate=True the gate doesn't raise. Downstream
|
||||
super() chain (notifications, invoicing) may still raise for
|
||||
their own reasons — out of scope for this test."""
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'scheduled',
|
||||
})
|
||||
try:
|
||||
delivery.with_context(
|
||||
fp_skip_cert_gate=True,
|
||||
).action_mark_delivered()
|
||||
except Exception as e:
|
||||
# Cert-gate message must NOT appear. Anything else is fine.
|
||||
self.assertNotIn('draft certificate', str(e))
|
||||
|
||||
def test_mark_delivered_passes_when_cert_issued(self):
|
||||
"""Issuing the cert clears the gate. Downstream chain errors
|
||||
are accepted (delivery PDF render etc. — see test above)."""
|
||||
part = self._make_part(certificate_requirement='coc')
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
cert.spec_reference = 'AMS 2404'
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'job_ref': job.name,
|
||||
'state': 'scheduled',
|
||||
})
|
||||
try:
|
||||
delivery.action_mark_delivered()
|
||||
except Exception as e:
|
||||
self.assertNotIn('draft certificate', str(e))
|
||||
|
||||
|
||||
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 5)
|
||||
"""
|
||||
|
||||
@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."""
|
||||
from odoo import fields
|
||||
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',
|
||||
)
|
||||
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)
|
||||
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).replace(
|
||||
'part(s) parked', 'parts parked'))
|
||||
|
||||
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."""
|
||||
from odoo import fields
|
||||
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)
|
||||
last.button_finish()
|
||||
self.assertEqual(last.state, 'done')
|
||||
|
||||
def test_button_finish_passes_when_qty_zero(self):
|
||||
"""qty_at_step==0 (already moved out) → no gate fires."""
|
||||
job, step1, step2 = self._make_two_step_chain(qty=2)
|
||||
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')
|
||||
|
||||
|
||||
# ---------------- 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()
|
||||
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.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()
|
||||
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)
|
||||
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
|
||||
from odoo import fields
|
||||
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'
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step1.action_complete_one_to_next()
|
||||
self.assertIn('must be in progress', str(exc.exception))
|
||||
|
||||
|
||||
# ---------------- 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()
|
||||
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))
|
||||
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)
|
||||
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)
|
||||
self.assertEqual(step1.state, 'done')
|
||||
|
||||
def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
|
||||
from odoo import fields
|
||||
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)
|
||||
self.assertEqual(last.state, 'done')
|
||||
|
||||
|
||||
# ---------------- display_name rename ----------------------------
|
||||
|
||||
def test_display_name_format(self):
|
||||
job = self._make_job(qty=1)
|
||||
self.assertTrue(job.name.startswith('WH/JOB/'))
|
||||
self.assertTrue(job.display_name.startswith('Work Order # '))
|
||||
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)
|
||||
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))
|
||||
@@ -35,11 +35,37 @@
|
||||
string="Finish & Next"
|
||||
class="btn-primary"
|
||||
icon="fa-arrow-right"
|
||||
invisible="state not in ('confirmed', 'in_progress')"/>
|
||||
<button name="action_print_traveller" type="object"
|
||||
string="Print Traveller"
|
||||
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
|
||||
|
||||
<!-- Milestone cascade (Phase 1). All four share the same
|
||||
dispatcher; visibility is gated on next_milestone_action
|
||||
so only one ever renders at a time. -->
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Job Done"
|
||||
class="btn-success"
|
||||
icon="fa-check-circle"
|
||||
invisible="next_milestone_action != 'mark_done'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Issue Certs"
|
||||
class="btn-primary"
|
||||
icon="fa-certificate"
|
||||
invisible="next_milestone_action != 'issue_certs'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Schedule Delivery"
|
||||
class="btn-primary"
|
||||
icon="fa-truck"
|
||||
invisible="next_milestone_action != 'schedule_delivery'"/>
|
||||
<button name="action_advance_next_milestone" type="object"
|
||||
string="Mark Shipped"
|
||||
class="btn-success"
|
||||
icon="fa-paper-plane"
|
||||
invisible="next_milestone_action != 'mark_shipped'"/>
|
||||
<field name="all_steps_terminal" invisible="1"/>
|
||||
<field name="next_milestone_action" invisible="1"/>
|
||||
<button name="action_print_sticker" type="object"
|
||||
string="Print Sticker"
|
||||
class="btn-secondary"
|
||||
icon="fa-print"
|
||||
icon="fa-tag"
|
||||
invisible="state == 'draft'"/>
|
||||
<button name="action_print_wo_detail" type="object"
|
||||
string="Print WO Detail"
|
||||
@@ -141,6 +167,14 @@
|
||||
string="Pause" icon="fa-pause"
|
||||
class="btn-link text-warning"
|
||||
invisible="state != 'in_progress'"/>
|
||||
<!-- Streaming flow: complete 1 part at a time,
|
||||
move to next step. Hidden when nothing is
|
||||
parked or the step isn't actively running.
|
||||
Auto-finishes 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"/>
|
||||
<button name="action_open_input_wizard" type="object"
|
||||
string="Record" icon="fa-pencil-square-o"
|
||||
class="btn-link"
|
||||
@@ -229,22 +263,6 @@
|
||||
<field name="quality_hold_count" widget="statinfo"
|
||||
string="Holds"/>
|
||||
</button>
|
||||
<button name="action_view_racking_inspection" type="object"
|
||||
class="oe_stat_button" icon="fa-clipboard-check">
|
||||
<div class="o_stat_info">
|
||||
<field name="racking_inspection_state"
|
||||
widget="badge"
|
||||
class="o_stat_value"
|
||||
decoration-success="racking_inspection_state == 'done'"
|
||||
decoration-info="racking_inspection_state == 'inspecting'"
|
||||
decoration-warning="racking_inspection_state == 'discrepancy_flagged'"
|
||||
decoration-muted="racking_inspection_state == 'draft'"
|
||||
invisible="not racking_inspection_state"/>
|
||||
<span class="o_stat_value"
|
||||
invisible="racking_inspection_state">—</span>
|
||||
<span class="o_stat_text">Racking Insp.</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_certificates" type="object"
|
||||
class="oe_stat_button" icon="fa-certificate"
|
||||
invisible="certificate_count == 0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Fusion Whitelabels",
|
||||
"version": "19.0.1.4.5",
|
||||
"version": "19.0.1.5.0",
|
||||
"category": "Website",
|
||||
"summary": "Replace Odoo frontend promotional branding with Nexa Systems whitelabeling.",
|
||||
"description": """
|
||||
@@ -12,10 +12,13 @@
|
||||
- Removes "Connect with your software" portal promotions.
|
||||
- Replaces global "Powered by Odoo" website/footer promotions with Nexa Systems credit.
|
||||
- Removes login-page "Powered by Odoo" footer link.
|
||||
- Replaces "Powered by Odoo" footer in transactional email notifications
|
||||
(mail.mail_notification_layout + mail.mail_notification_light) with
|
||||
Nexa Systems credit.
|
||||
""",
|
||||
"author": "Fusion",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["portal", "sale", "purchase", "website", "website_sale"],
|
||||
"depends": ["mail", "portal", "sale", "purchase", "website", "website_sale"],
|
||||
"data": [
|
||||
"views/fusion_whitelabels_templates.xml",
|
||||
],
|
||||
|
||||
@@ -36,4 +36,34 @@
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="fusion_whitelabels_mail_notification_layout"
|
||||
inherit_id="mail.mail_notification_layout" priority="999">
|
||||
<xpath expr="//div[@t-if='show_footer' and contains(@style, 'color: #555555')]" position="replace">
|
||||
<div t-if="show_footer" style="color: #555555; font-size:11px;">
|
||||
Designed by <a target="_blank" href="https://nexasystems.ca"
|
||||
rel="noopener noreferrer"
|
||||
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Nexa Systems</a>
|
||||
<span t-if="show_unfollow" id="mail_unfollow">
|
||||
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
|
||||
</span>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="fusion_whitelabels_mail_notification_light"
|
||||
inherit_id="mail.mail_notification_light" priority="999">
|
||||
<xpath expr="//tr[td/span[@id='mail_unfollow']]" position="replace">
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
Designed by <a target="_blank" href="https://nexasystems.ca"
|
||||
rel="noopener noreferrer"
|
||||
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Nexa Systems</a>
|
||||
<span t-if="show_unfollow" id="mail_unfollow">
|
||||
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user