15 tasks across 8 phases — foundation (sequences + mixin + SO fields), quote/SO rename, WO grouping rewrite, invoice block + naming, child model wiring (CoC/RCV/DLV/PU/NCR/CAPA/Hold/RMA), immutability + unlink block, view + report fixes, end-to-end walkthrough. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1276 lines
44 KiB
Markdown
1276 lines
44 KiB
Markdown
# Parent Number Hierarchy Implementation 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:** Replace divergent per-model sequences with a single shared parent-number scheme where every document linked to a Sale Order derives its name from the SO. Numbers are compliance-immutable; direct invoice creation is blocked.
|
|
|
|
**Architecture:** New abstract mixin `fp.parent.numbered.mixin` lives in `fusion_plating` core. It exposes `_fp_assign_parent_name()` which atomically locks the parent SO, increments a per-model counter, and composes the child's name (`PREFIX-PARENT` bare for the first, `PREFIX-PARENT-NN` zero-padded for the 2nd+). Subclasses (`fp.job`, `account.move`, `fp.certificate`, `fp.receiving`, `fusion.plating.delivery`, `fusion.plating.pickup.request`, NCR/CAPA/Hold/RMA) implement three hook methods and delegate. Quote-to-SO rename happens in `sale.order` overrides. Direct customer-invoice creation outside the SO workflow raises `UserError` for all users including admins.
|
|
|
|
**Tech Stack:** Odoo 19, PostgreSQL row-locks (SELECT FOR UPDATE), `ir.sequence`, Python 3, deployed via SSH+pct to entech LXC 111.
|
|
|
|
**Spec:** [docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md](../specs/2026-05-12-parent-number-hierarchy-design.md)
|
|
|
|
---
|
|
|
|
## Conventions
|
|
|
|
**Deploy command (every task ends with this after a successful smoke test):**
|
|
|
|
For each modified file:
|
|
```
|
|
cat <LOCAL_FILE> | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/<REMOTE_PATH>'"
|
|
```
|
|
|
|
Then upgrade the module:
|
|
```
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u <MODULE> --stop-after-init\" && systemctl start odoo'"
|
|
```
|
|
|
|
**Smoke-test command:**
|
|
```
|
|
cat <LOCAL_SCRIPT> | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /tmp/<NAME>.py'"
|
|
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"exec(open(\\\"/tmp/<NAME>.py\\\").read())\" | su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" 2>&1 | tail -20'"
|
|
```
|
|
|
|
**Smoke-test scripts go in:** `fusion_plating_jobs/scripts/numbering_*.py` (mirrors the existing battle-test pattern at `fusion_plating_quality/scripts/bt_s*.py`).
|
|
|
|
**Manifest version bump:** Every change to a module bumps the third-or-fourth segment of its `__manifest__.py` version. Without a bump Odoo's upgrade hooks won't fire.
|
|
|
|
---
|
|
|
|
## PHASE 1 — Foundation: sequences + mixin + SO fields
|
|
|
|
### Task 1: Add the two new sequences
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/data/fp_numbering_sequences.xml`
|
|
- Modify: `fusion_plating/__manifest__.py` (data list + version bump)
|
|
|
|
- [ ] **Step 1: Create the sequence data file**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo noupdate="1">
|
|
<!-- Parent-number sequence: drives the integer at the heart of every linked
|
|
document's name (SO-30000, WO-30000, IN-30000, ...). Starts at 30000.
|
|
noupdate=1 so module upgrade never resets the counter. -->
|
|
<record id="seq_fp_parent_number" model="ir.sequence">
|
|
<field name="name">Fusion Plating: Parent Number</field>
|
|
<field name="code">fp.parent.number</field>
|
|
<field name="prefix"/>
|
|
<field name="padding">0</field>
|
|
<field name="number_next_actual">30000</field>
|
|
<field name="company_id" eval="False"/>
|
|
</record>
|
|
|
|
<!-- Quote sequence: Q + YYYY + MM + '-' + non-resetting counter. -->
|
|
<record id="seq_fp_quote_number" model="ir.sequence">
|
|
<field name="name">Fusion Plating: Quote Number</field>
|
|
<field name="code">fp.quote.number</field>
|
|
<field name="prefix">Q%(year)s%(month)s-</field>
|
|
<field name="padding">0</field>
|
|
<field name="number_next_actual">200</field>
|
|
<field name="company_id" eval="False"/>
|
|
</record>
|
|
</odoo>
|
|
```
|
|
|
|
- [ ] **Step 2: Register the data file**
|
|
|
|
In `fusion_plating/__manifest__.py`, add to the `'data'` list:
|
|
```python
|
|
'data/fp_numbering_sequences.xml',
|
|
```
|
|
Bump the version.
|
|
|
|
- [ ] **Step 3: Deploy + verify**
|
|
|
|
Run the deploy commands, then this smoke test:
|
|
```python
|
|
# /tmp/numbering_task1_verify.py
|
|
seq_parent = env['ir.sequence'].search([('code', '=', 'fp.parent.number')])
|
|
seq_quote = env['ir.sequence'].search([('code', '=', 'fp.quote.number')])
|
|
assert seq_parent and seq_parent.number_next_actual == 30000
|
|
assert seq_quote and seq_quote.number_next_actual == 200
|
|
preview_parent = env['ir.sequence'].next_by_code('fp.parent.number')
|
|
preview_quote = env['ir.sequence'].next_by_code('fp.quote.number')
|
|
print(f"OK: parent draws {preview_parent}, quote draws {preview_quote}")
|
|
env.cr.rollback()
|
|
```
|
|
Expected: `OK: parent draws 30000, quote draws Q202605-200`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```
|
|
git add fusion_plating/data/fp_numbering_sequences.xml fusion_plating/__manifest__.py
|
|
git commit -m "feat(numbering): add fp.parent.number + fp.quote.number sequences"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create the abstract mixin model
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating/models/fp_parent_numbered_mixin.py`
|
|
- Modify: `fusion_plating/models/__init__.py`
|
|
- Modify: `fusion_plating/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Create the mixin**
|
|
|
|
```python
|
|
# fusion_plating/models/fp_parent_numbered_mixin.py
|
|
from odoo import fields, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools.translate import _
|
|
|
|
|
|
class FpParentNumberedMixin(models.AbstractModel):
|
|
_name = 'fp.parent.numbered.mixin'
|
|
_description = 'Fusion Plating — Parent-Number-Derived Naming'
|
|
|
|
x_fc_doc_index = fields.Integer(
|
|
string='Parent Doc Index',
|
|
readonly=True, copy=False, index=True,
|
|
help='1-based position within this parent SO. 1 = first child of this '
|
|
'type; subsequent siblings get 2, 3, … First sibling renders its '
|
|
'name bare; later siblings get a zero-padded "-NN" suffix.',
|
|
)
|
|
|
|
# ----- Hooks subclasses must override -----
|
|
def _fp_parent_sale_order(self):
|
|
return self.env['sale.order']
|
|
|
|
def _fp_name_prefix(self):
|
|
raise NotImplementedError('Subclass must define _fp_name_prefix()')
|
|
|
|
def _fp_parent_counter_field(self):
|
|
raise NotImplementedError('Subclass must define _fp_parent_counter_field()')
|
|
|
|
# ----- Core -----
|
|
def _fp_compose_name(self, parent_number, index):
|
|
prefix = self._fp_name_prefix()
|
|
if index <= 1:
|
|
return f'{prefix}-{parent_number}'
|
|
if index <= 99:
|
|
return f'{prefix}-{parent_number}-{index:02d}'
|
|
return f'{prefix}-{parent_number}-{index}'
|
|
|
|
def _fp_assign_parent_name(self):
|
|
"""Lock parent SO, bump counter, set name + doc index.
|
|
Returns True on success; False if no parent SO is linked
|
|
(caller falls back to the model's legacy sequence)."""
|
|
self.ensure_one()
|
|
so = self._fp_parent_sale_order()
|
|
if not so or not so.x_fc_parent_number:
|
|
return False
|
|
counter_field = self._fp_parent_counter_field()
|
|
# Row-lock the SO; concurrent creates block here.
|
|
self.env.cr.execute(
|
|
f'SELECT {counter_field} FROM sale_order WHERE id = %s FOR UPDATE',
|
|
(so.id,),
|
|
)
|
|
row = self.env.cr.fetchone()
|
|
current = (row and row[0]) or 0
|
|
new_index = current + 1
|
|
self.env.cr.execute(
|
|
f'UPDATE sale_order SET {counter_field} = %s WHERE id = %s',
|
|
(new_index, so.id),
|
|
)
|
|
so.invalidate_recordset([counter_field])
|
|
new_name = self._fp_compose_name(so.x_fc_parent_number, new_index)
|
|
# Use raw SQL to set name + doc index so the immutability write()
|
|
# guard (added in Task 11) doesn't reject our own update.
|
|
self.env.cr.execute(
|
|
f'UPDATE {self._table} SET name = %s, x_fc_doc_index = %s WHERE id = %s',
|
|
(new_name, new_index, self.id),
|
|
)
|
|
self.invalidate_recordset(['name', 'x_fc_doc_index'])
|
|
so.message_post(body=_(
|
|
'Issued <strong>%s</strong> to %s #%s.'
|
|
) % (new_name, self._name, self.id))
|
|
return True
|
|
```
|
|
|
|
- [ ] **Step 2: Register**
|
|
|
|
Add to `fusion_plating/models/__init__.py`:
|
|
```python
|
|
from . import fp_parent_numbered_mixin
|
|
```
|
|
|
|
- [ ] **Step 3: Deploy + verify**
|
|
|
|
```python
|
|
# /tmp/numbering_task2_verify.py
|
|
mixin = env['fp.parent.numbered.mixin']
|
|
assert mixin._name == 'fp.parent.numbered.mixin'
|
|
print('OK: mixin abstract model registered')
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```
|
|
git add fusion_plating/models/fp_parent_numbered_mixin.py fusion_plating/models/__init__.py fusion_plating/__manifest__.py
|
|
git commit -m "feat(numbering): add fp.parent.numbered.mixin abstract model"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add SO fields (parent_number, quote_ref, all counters)
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_jobs/models/sale_order.py` (add fields)
|
|
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Add fields to the SO inherit class**
|
|
|
|
Insert after existing field declarations:
|
|
```python
|
|
# Parent-number hierarchy (2026-05-12 design)
|
|
x_fc_parent_number = fields.Integer(
|
|
string='Parent Number', readonly=True, copy=False, index=True,
|
|
help='Set on confirm. Drives child-document naming. Immutable post-assignment.',
|
|
)
|
|
x_fc_quote_ref = fields.Char(
|
|
string='Originally Quoted As', readonly=True, copy=False,
|
|
help='The quote-stage name (e.g. Q202605-200). Preserved when the SO is renamed on confirm.',
|
|
)
|
|
# Per-model counters — monotonic, never decrement.
|
|
x_fc_wo_count = fields.Integer(string='WO Count', readonly=True, copy=False, default=0)
|
|
x_fc_invoice_count = fields.Integer(string='Invoice Count', readonly=True, copy=False, default=0)
|
|
x_fc_cn_count = fields.Integer(string='Credit Note Count', readonly=True, copy=False, default=0)
|
|
x_fc_cert_count = fields.Integer(string='Certificate Count', readonly=True, copy=False, default=0)
|
|
x_fc_delivery_count = fields.Integer(string='Delivery Count', readonly=True, copy=False, default=0)
|
|
x_fc_receiving_count = fields.Integer(string='Receiving Count', readonly=True, copy=False, default=0)
|
|
x_fc_pickup_count = fields.Integer(string='Pickup Count', readonly=True, copy=False, default=0)
|
|
x_fc_ncr_count = fields.Integer(string='NCR Count', readonly=True, copy=False, default=0)
|
|
x_fc_capa_count = fields.Integer(string='CAPA Count', readonly=True, copy=False, default=0)
|
|
x_fc_hold_count = fields.Integer(string='Hold Count', readonly=True, copy=False, default=0)
|
|
x_fc_rma_count = fields.Integer(string='RMA Count', readonly=True, copy=False, default=0)
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy + verify**
|
|
|
|
```python
|
|
# /tmp/numbering_task3_verify.py
|
|
SO = env['sale.order']
|
|
expected = ['x_fc_parent_number','x_fc_quote_ref','x_fc_wo_count','x_fc_invoice_count',
|
|
'x_fc_cn_count','x_fc_cert_count','x_fc_delivery_count','x_fc_receiving_count',
|
|
'x_fc_pickup_count','x_fc_ncr_count','x_fc_capa_count','x_fc_hold_count','x_fc_rma_count']
|
|
missing = [f for f in expected if f not in SO._fields]
|
|
assert not missing, f"MISSING: {missing}"
|
|
print(f"OK: all {len(expected)} new fields on sale.order")
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/models/sale_order.py fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "feat(numbering): add parent_number + counters to sale.order"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 2 — Quote-to-SO rename
|
|
|
|
### Task 4: Quote naming on sale.order.create
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_jobs/models/sale_order.py` (add create override)
|
|
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Add create override**
|
|
|
|
```python
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
"""Draw Q-YYYYMM-N from fp.quote.number when caller didn't pass a name."""
|
|
Seq = self.env['ir.sequence']
|
|
for vals in vals_list:
|
|
existing = vals.get('name')
|
|
if not existing or existing == _('New'):
|
|
quote_name = Seq.next_by_code('fp.quote.number')
|
|
if quote_name:
|
|
vals['name'] = quote_name
|
|
vals['x_fc_quote_ref'] = quote_name
|
|
elif not vals.get('x_fc_quote_ref'):
|
|
vals['x_fc_quote_ref'] = existing
|
|
return super().create(vals_list)
|
|
```
|
|
|
|
Ensure `_` is imported: `from odoo.tools.translate import _`
|
|
|
|
- [ ] **Step 2: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task4_verify.py
|
|
SO = env['sale.order']
|
|
partner = env['res.partner'].search([], limit=1)
|
|
so = SO.create({'partner_id': partner.id})
|
|
print(f"Name: {so.name!r}, quote_ref: {so.x_fc_quote_ref!r}")
|
|
assert so.name.startswith('Q')
|
|
assert so.x_fc_quote_ref == so.name
|
|
print("OK: quote naming")
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/models/sale_order.py fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "feat(numbering): draw quote name from fp.quote.number on SO create"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Parent number + SO rename on action_confirm
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_jobs/models/sale_order.py` (action_confirm override)
|
|
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Override action_confirm**
|
|
|
|
Insert at the START of the existing `action_confirm` (or add it if none exists):
|
|
```python
|
|
def action_confirm(self):
|
|
"""On confirm, draw parent number and rename Q-…-N to SO-<parent>."""
|
|
Seq = self.env['ir.sequence']
|
|
for so in self:
|
|
if so.x_fc_parent_number:
|
|
continue
|
|
parent = Seq.next_by_code('fp.parent.number')
|
|
if not parent:
|
|
raise UserError(_(
|
|
'Sequence fp.parent.number is missing. Reinstall fusion_plating.'
|
|
))
|
|
parent_int = int(parent)
|
|
old_name = so.name
|
|
new_name = f'SO-{parent_int}'
|
|
so.with_context(fp_allow_name_rename=True).write({
|
|
'name': new_name,
|
|
'x_fc_parent_number': parent_int,
|
|
})
|
|
so.message_post(body=_(
|
|
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
|
|
) % (old_name, new_name))
|
|
return super().action_confirm()
|
|
```
|
|
|
|
Ensure `UserError` is imported: `from odoo.exceptions import UserError`
|
|
|
|
- [ ] **Step 2: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task5_verify.py
|
|
SO = env['sale.order']
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
so = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
|
|
})
|
|
quote_name = so.name
|
|
so.action_confirm()
|
|
print(f"Quote: {quote_name} -> Confirmed: {so.name}")
|
|
print(f"Parent: {so.x_fc_parent_number}, quote_ref: {so.x_fc_quote_ref}")
|
|
assert so.name.startswith('SO-')
|
|
assert so.x_fc_parent_number >= 30000
|
|
assert so.x_fc_quote_ref == quote_name
|
|
print("OK: confirm rename")
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/models/sale_order.py fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "feat(numbering): assign parent_number + rename to SO-<n> on confirm"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 3 — WO grouping rewrite + fp.job mixin wiring
|
|
|
|
### Task 6: Recipe-based WO grouping + parent-derived naming
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/models/fp_job.py` (inherit mixin + 3 hooks)
|
|
- Modify: `fusion_plating_jobs/models/sale_order.py` (rewrite `_fp_native_jobs_for_so` grouping + naming)
|
|
- Modify: both `__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: fp.job inherits the mixin**
|
|
|
|
Change `fp.job` class declaration:
|
|
```python
|
|
class FpJob(models.Model):
|
|
_name = 'fp.job'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
|
```
|
|
|
|
Add hook methods:
|
|
```python
|
|
def _fp_parent_sale_order(self):
|
|
return self.sale_order_id
|
|
|
|
def _fp_name_prefix(self):
|
|
return 'WO'
|
|
|
|
def _fp_parent_counter_field(self):
|
|
return 'x_fc_wo_count'
|
|
```
|
|
|
|
- [ ] **Step 2: Extract recipe resolution to a helper**
|
|
|
|
In `fusion_plating_jobs/models/sale_order.py`, add (used by Task 6 grouping AND by existing job-vals construction):
|
|
```python
|
|
def _fp_resolve_recipe_for_line(self, line):
|
|
"""4-tier recipe resolution. See spec §3.4."""
|
|
Node = self.env['fusion.plating.process.node']
|
|
part = ('x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id) or False
|
|
if not part and 'x_fc_part_catalog_id' in self._fields:
|
|
part = self.x_fc_part_catalog_id or False
|
|
coating = ('x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id) or False
|
|
if not coating and 'x_fc_coating_config_id' in self._fields:
|
|
coating = self.x_fc_coating_config_id or False
|
|
picked = ('x_fc_process_variant_id' in line._fields and line.x_fc_process_variant_id) or False
|
|
if picked:
|
|
return picked
|
|
if part and 'default_process_id' in part._fields and part.default_process_id:
|
|
return part.default_process_id
|
|
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
|
|
return coating.recipe_id
|
|
if part and 'recipe_id' in part._fields and part.recipe_id:
|
|
return part.recipe_id
|
|
return Node
|
|
```
|
|
|
|
- [ ] **Step 3: Rewrite grouping in _fp_native_jobs_for_so**
|
|
|
|
Replace the existing `groups = {}` ... `for line in plating_lines:` block (which keyed by `x_fc_wo_group_tag`) with:
|
|
```python
|
|
groups = {}
|
|
unrecipe_idx = 0
|
|
for line in plating_lines:
|
|
recipe = self._fp_resolve_recipe_for_line(line)
|
|
if recipe:
|
|
key = recipe.id
|
|
else:
|
|
unrecipe_idx += 1
|
|
key = ('no_recipe', unrecipe_idx)
|
|
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
|
```
|
|
|
|
Replace the job-creation loop:
|
|
```python
|
|
ordered_keys = sorted(
|
|
groups.keys(),
|
|
key=lambda k: min(groups[k].mapped('sequence')) if groups[k] else 0,
|
|
)
|
|
n_groups = len(ordered_keys)
|
|
for idx, key in enumerate(ordered_keys, start=1):
|
|
lines = groups[key]
|
|
first_line = lines[0]
|
|
# ... existing vals dict construction unchanged ...
|
|
recipe = self._fp_resolve_recipe_for_line(first_line)
|
|
# ... rest of vals construction unchanged ...
|
|
|
|
# Bulk-confirm naming
|
|
if n_groups == 1:
|
|
vals['name'] = f'WO-{self.x_fc_parent_number}'
|
|
vals['x_fc_doc_index'] = 1
|
|
else:
|
|
vals['name'] = self.env['fp.job']._fp_compose_name(
|
|
self.x_fc_parent_number, idx
|
|
)
|
|
vals['x_fc_doc_index'] = idx
|
|
|
|
job = self.env['fp.job'].with_context(
|
|
fp_allow_name_rename=True
|
|
).create(vals)
|
|
self.x_fc_wo_count = n_groups
|
|
```
|
|
|
|
- [ ] **Step 4: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task6_verify.py
|
|
SO = env['sale.order']
|
|
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=2)
|
|
assert len(parts) >= 2
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
|
|
# Single-recipe SO -> bare WO
|
|
so1 = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[0].id})],
|
|
})
|
|
so1.action_confirm()
|
|
jobs1 = env['fp.job'].search([('sale_order_id', '=', so1.id)])
|
|
assert len(jobs1) == 1
|
|
assert jobs1[0].name == f'WO-{so1.x_fc_parent_number}'
|
|
|
|
# Two-recipe SO -> -01, -02
|
|
so2 = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [
|
|
(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[0].id, 'sequence': 10}),
|
|
(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[1].id, 'sequence': 20}),
|
|
],
|
|
})
|
|
so2.action_confirm()
|
|
jobs2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], order='x_fc_doc_index')
|
|
assert len(jobs2) == 2
|
|
assert jobs2[0].name == f'WO-{so2.x_fc_parent_number}-01'
|
|
assert jobs2[1].name == f'WO-{so2.x_fc_parent_number}-02'
|
|
print(f"OK: single -> {jobs1[0].name}, double -> {jobs2.mapped('name')}")
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating/models/fp_job.py fusion_plating/fusion_plating_jobs/models/sale_order.py fusion_plating/fusion_plating/__manifest__.py fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "feat(numbering): WO grouping by recipe + parent-derived bulk naming"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 4 — Invoice block + naming
|
|
|
|
### Task 7: Block direct customer-invoice creation
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating_jobs/models/account_move.py`
|
|
- Modify: `fusion_plating_jobs/models/__init__.py`
|
|
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump; verify `account` is in depends)
|
|
|
|
- [ ] **Step 1: Create the override**
|
|
|
|
```python
|
|
# fusion_plating_jobs/models/account_move.py
|
|
from odoo import api, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools.translate import _
|
|
|
|
CUSTOMER_TYPES = ('out_invoice', 'out_refund')
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = 'account.move'
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
self._fp_validate_customer_invoice(vals)
|
|
return super().create(vals_list)
|
|
|
|
@api.model
|
|
def _fp_validate_customer_invoice(self, vals):
|
|
mtype = vals.get('move_type', 'entry')
|
|
if mtype not in CUSTOMER_TYPES:
|
|
return
|
|
if self.env.context.get('fp_from_so_invoice'):
|
|
return
|
|
origin = (vals.get('invoice_origin') or '').strip()
|
|
if origin and self.env['sale.order'].sudo().search_count(
|
|
[('name', '=', origin)]
|
|
):
|
|
return
|
|
raise UserError(_(
|
|
'Customer invoices and credit notes must be created from a '
|
|
'Sale Order. Open the originating SO and use Create Invoice. '
|
|
'This rule applies to all users including administrators '
|
|
'(parent-number audit trail).'
|
|
))
|
|
```
|
|
|
|
- [ ] **Step 2: Register**
|
|
|
|
Add to `fusion_plating_jobs/models/__init__.py`:
|
|
```python
|
|
from . import account_move
|
|
```
|
|
|
|
- [ ] **Step 3: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task7_verify.py
|
|
AM = env['account.move']
|
|
journal = env['account.journal'].search([('type', '=', 'sale')], limit=1)
|
|
partner = env['res.partner'].search([], limit=1)
|
|
|
|
# Direct create should raise
|
|
try:
|
|
AM.create({'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id})
|
|
print("FAIL: not blocked")
|
|
except Exception as e:
|
|
print(f"OK: blocked - {str(e)[:80]}")
|
|
|
|
# With context flag should pass
|
|
try:
|
|
mv = AM.with_context(fp_from_so_invoice=True).create({
|
|
'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id,
|
|
})
|
|
print(f"OK: context flag allows {mv.id}")
|
|
env.cr.rollback()
|
|
except Exception as e:
|
|
print(f"FAIL: {e}")
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/models/account_move.py fusion_plating/fusion_plating_jobs/models/__init__.py fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "feat(numbering): block direct customer-invoice creation outside SO flow"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Wire account.move into the mixin
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_jobs/models/account_move.py`
|
|
- Modify: `fusion_plating_jobs/models/sale_order.py` (set context on _create_invoices)
|
|
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: SO `_create_invoices` sets context**
|
|
|
|
In `sale_order.py`:
|
|
```python
|
|
def _create_invoices(self, grouped=False, final=False, date=None):
|
|
return super(SaleOrder, self.with_context(
|
|
fp_from_so_invoice=True,
|
|
fp_invoice_source_so_id=self.id if len(self) == 1 else None,
|
|
))._create_invoices(grouped=grouped, final=final, date=date)
|
|
```
|
|
|
|
- [ ] **Step 2: account.move inherits mixin + assigns name post-create**
|
|
|
|
Rewrite `account_move.py`:
|
|
```python
|
|
from odoo import api, models
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools.translate import _
|
|
|
|
CUSTOMER_TYPES = ('out_invoice', 'out_refund')
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = ['account.move', 'fp.parent.numbered.mixin']
|
|
|
|
def _fp_parent_sale_order(self):
|
|
so_id = self.env.context.get('fp_invoice_source_so_id')
|
|
if so_id:
|
|
return self.env['sale.order'].browse(so_id)
|
|
if self.invoice_origin:
|
|
return self.env['sale.order'].search(
|
|
[('name', '=', self.invoice_origin)], limit=1,
|
|
)
|
|
return self.env['sale.order']
|
|
|
|
def _fp_name_prefix(self):
|
|
return 'CN' if self.move_type == 'out_refund' else 'IN'
|
|
|
|
def _fp_parent_counter_field(self):
|
|
return 'x_fc_cn_count' if self.move_type == 'out_refund' else 'x_fc_invoice_count'
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
self._fp_validate_customer_invoice(vals)
|
|
moves = super().create(vals_list)
|
|
for mv in moves:
|
|
if mv.move_type in CUSTOMER_TYPES:
|
|
mv._fp_assign_parent_name()
|
|
return moves
|
|
|
|
@api.model
|
|
def _fp_validate_customer_invoice(self, vals):
|
|
mtype = vals.get('move_type', 'entry')
|
|
if mtype not in CUSTOMER_TYPES:
|
|
return
|
|
if self.env.context.get('fp_from_so_invoice'):
|
|
return
|
|
origin = (vals.get('invoice_origin') or '').strip()
|
|
if origin and self.env['sale.order'].sudo().search_count(
|
|
[('name', '=', origin)]
|
|
):
|
|
return
|
|
raise UserError(_(
|
|
'Customer invoices and credit notes must be created from a '
|
|
'Sale Order. Open the originating SO and use Create Invoice. '
|
|
'This rule applies to all users including administrators.'
|
|
))
|
|
```
|
|
|
|
- [ ] **Step 3: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task8_verify.py
|
|
SO = env['sale.order']
|
|
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=1)
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
|
|
so = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[0].id})],
|
|
})
|
|
so.action_confirm()
|
|
|
|
inv1 = so._create_invoices()
|
|
print(f"Invoice 1: {inv1.name}")
|
|
assert inv1.name == f'IN-{so.x_fc_parent_number}'
|
|
|
|
inv2 = so._create_invoices()
|
|
print(f"Invoice 2: {inv2.name}")
|
|
assert inv2.name == f'IN-{so.x_fc_parent_number}-02'
|
|
|
|
assert so.x_fc_invoice_count == 2
|
|
print("OK: invoice naming")
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/models/account_move.py fusion_plating/fusion_plating_jobs/models/sale_order.py fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "feat(numbering): wire account.move into parent-numbered mixin"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 5 — Other child models
|
|
|
|
### Task 9: Wire CoC, Receiving, Delivery, Pickup
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_certificates/models/fp_certificate.py`
|
|
- Modify: `fusion_plating_receiving/models/fp_receiving.py`
|
|
- Modify: `fusion_plating_logistics/models/fp_delivery.py`
|
|
- Modify: `fusion_plating_logistics/models/fp_pickup_request.py`
|
|
- Modify each module's `__manifest__.py` (version bump)
|
|
|
|
**Per-model wiring template** (apply to each):
|
|
|
|
- [ ] **Step 1: Inherit the mixin**
|
|
|
|
Change `_inherit` line to include `'fp.parent.numbered.mixin'`.
|
|
|
|
- [ ] **Step 2: Add hook methods**
|
|
|
|
```python
|
|
def _fp_parent_sale_order(self):
|
|
return self.sale_order_id
|
|
|
|
def _fp_name_prefix(self):
|
|
return '<PREFIX>'
|
|
|
|
def _fp_parent_counter_field(self):
|
|
return '<COUNTER_FIELD>'
|
|
```
|
|
|
|
- [ ] **Step 3: Rewrite create() to call the mixin**
|
|
|
|
```python
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
records = super().create(vals_list)
|
|
for rec in records:
|
|
if rec.name and rec.name != 'New':
|
|
continue
|
|
if not rec._fp_assign_parent_name():
|
|
# Fall back to the legacy per-model sequence
|
|
seq_code = '<EXISTING_SEQ_CODE>'
|
|
rec.with_context(fp_allow_name_rename=True).name = (
|
|
self.env['ir.sequence'].next_by_code(seq_code) or 'New'
|
|
)
|
|
return records
|
|
```
|
|
|
|
**Per-model substitutions:**
|
|
|
|
| Model | Prefix | Counter | Sequence code |
|
|
|---|---|---|---|
|
|
| `fp.certificate` | `'CoC'` | `'x_fc_cert_count'` | `'fp.certificate'` |
|
|
| `fp.receiving` | `'RCV'` | `'x_fc_receiving_count'` | `'fp.receiving'` |
|
|
| `fusion.plating.delivery` | `'DLV'` | `'x_fc_delivery_count'` | `'fusion.plating.delivery'` |
|
|
| `fusion.plating.pickup.request` | `'PU'` | `'x_fc_pickup_count'` | `'fusion.plating.pickup.request'` |
|
|
|
|
If `fusion.plating.pickup.request` lacks `sale_order_id`, add it:
|
|
```python
|
|
sale_order_id = fields.Many2one(
|
|
'sale.order', string='Sale Order', ondelete='set null', index=True,
|
|
help='Sale order this pickup is associated with. Pickup may be created '
|
|
'before the SO exists; in that case the parent-number naming falls '
|
|
'back to the standalone PU/YYYY/NNNN sequence.',
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task9_verify.py
|
|
SO = env['sale.order']
|
|
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=1)
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
so = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[0].id})],
|
|
})
|
|
so.action_confirm()
|
|
|
|
# CoC
|
|
cert = env['fp.certificate'].create({'sale_order_id': so.id, 'partner_id': so.partner_id.id})
|
|
assert cert.name == f'CoC-{so.x_fc_parent_number}', cert.name
|
|
print(f"OK: CoC = {cert.name}")
|
|
|
|
# Receiving
|
|
rcv = env['fp.receiving'].create({'sale_order_id': so.id, 'partner_id': so.partner_id.id})
|
|
assert rcv.name == f'RCV-{so.x_fc_parent_number}', rcv.name
|
|
print(f"OK: RCV = {rcv.name}")
|
|
|
|
# Delivery
|
|
dlv = env['fusion.plating.delivery'].create({'sale_order_id': so.id, 'partner_id': so.partner_id.id})
|
|
assert dlv.name == f'DLV-{so.x_fc_parent_number}', dlv.name
|
|
print(f"OK: DLV = {dlv.name}")
|
|
|
|
# Pickup
|
|
pu = env['fusion.plating.pickup.request'].create({'sale_order_id': so.id, 'partner_id': so.partner_id.id})
|
|
assert pu.name == f'PU-{so.x_fc_parent_number}', pu.name
|
|
print(f"OK: PU = {pu.name}")
|
|
|
|
# Standalone PU (no SO) -> legacy sequence
|
|
pu_std = env['fusion.plating.pickup.request'].create({'partner_id': partner.id})
|
|
assert not pu_std.name.startswith('PU-3'), pu_std.name
|
|
print(f"OK: standalone PU -> {pu_std.name} (legacy)")
|
|
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_certificates fusion_plating/fusion_plating_receiving fusion_plating/fusion_plating_logistics
|
|
git commit -m "feat(numbering): wire CoC, Receiving, Delivery, Pickup into parent-numbered mixin"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Wire NCR, CAPA, Hold, RMA
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_quality/models/fp_ncr.py`, `fp_capa.py`, `fp_quality_hold.py`, `fp_rma.py`
|
|
- Modify: `fusion_plating_quality/__manifest__.py` (version bump)
|
|
|
|
Same per-model template as Task 9. Substitutions:
|
|
|
|
| Model | Prefix | Counter | Sequence code |
|
|
|---|---|---|---|
|
|
| `fusion.plating.ncr` | `'NCR'` | `'x_fc_ncr_count'` | `'fp.ncr'` |
|
|
| `fusion.plating.capa` | `'CAPA'` | `'x_fc_capa_count'` | `'fp.capa'` |
|
|
| `fusion.plating.quality.hold` | `'HOLD'` | `'x_fc_hold_count'` | `'fp.quality.hold'` |
|
|
| `fusion.plating.rma` | `'RMA'` | `'x_fc_rma_count'` | `'fp.rma'` |
|
|
|
|
NCR/CAPA/Hold reach the SO via `job_id`:
|
|
```python
|
|
def _fp_parent_sale_order(self):
|
|
return self.job_id.sale_order_id if self.job_id else self.env['sale.order']
|
|
```
|
|
|
|
RMA reaches via `sale_order_id` directly (the model has it).
|
|
|
|
- [ ] **Step 1: Apply the pattern to all 4 quality models**
|
|
|
|
- [ ] **Step 2: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task10_verify.py
|
|
SO = env['sale.order']
|
|
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=1)
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
so = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[0].id})],
|
|
})
|
|
so.action_confirm()
|
|
job = env['fp.job'].search([('sale_order_id', '=', so.id)])[0]
|
|
|
|
ncr = env['fusion.plating.ncr'].create({'job_id': job.id, 'partner_id': so.partner_id.id, 'description': 'test'})
|
|
assert ncr.name == f'NCR-{so.x_fc_parent_number}'
|
|
print(f"OK: NCR = {ncr.name}")
|
|
|
|
hold = env['fusion.plating.quality.hold'].create({'job_id': job.id, 'partner_id': so.partner_id.id, 'hold_reason': 'qc_failure'})
|
|
assert hold.name == f'HOLD-{so.x_fc_parent_number}'
|
|
print(f"OK: HOLD = {hold.name}")
|
|
|
|
rma = env['fusion.plating.rma'].create({'sale_order_id': so.id, 'partner_id': so.partner_id.id})
|
|
assert rma.name == f'RMA-{so.x_fc_parent_number}'
|
|
print(f"OK: RMA = {rma.name}")
|
|
|
|
# Standalone NCR (no job_id) -> legacy
|
|
ncr_std = env['fusion.plating.ncr'].create({'partner_id': partner.id, 'description': 'standalone'})
|
|
assert not ncr_std.name.startswith('NCR-3'), ncr_std.name
|
|
print(f"OK: standalone NCR -> {ncr_std.name} (legacy)")
|
|
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_quality
|
|
git commit -m "feat(numbering): wire NCR, CAPA, Hold, RMA into parent-numbered mixin"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 6 — Immutability + unlink block
|
|
|
|
### Task 11: Make name + doc_index immutable via write override
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/models/fp_parent_numbered_mixin.py`
|
|
- Modify: `fusion_plating/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Add write override**
|
|
|
|
Append to `FpParentNumberedMixin`:
|
|
```python
|
|
IMMUTABLE_FIELDS = ('name', 'x_fc_doc_index')
|
|
|
|
def write(self, vals):
|
|
"""Block post-creation changes to name / x_fc_doc_index.
|
|
|
|
Bypass: context flag fp_allow_name_rename=True. Used only by:
|
|
1. SO action_confirm rename (the one-time Q -> SO).
|
|
2. Bulk WO creation mid-create.
|
|
3. Legacy-sequence fallback path in child models.
|
|
Nothing else should ever bypass."""
|
|
if not self.env.context.get('fp_allow_name_rename'):
|
|
for f in self.IMMUTABLE_FIELDS:
|
|
if f in vals:
|
|
for rec in self:
|
|
current = rec[f]
|
|
if current and current != vals[f]:
|
|
raise UserError(_(
|
|
'Field "%s" on %s "%s" is immutable. '
|
|
'Once issued, it cannot be changed — this '
|
|
'preserves the compliance audit trail. '
|
|
'(Attempted: %r -> %r)'
|
|
) % (f, self._description, rec.display_name, current, vals[f]))
|
|
return super().write(vals)
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task11_verify.py
|
|
SO = env['sale.order']
|
|
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=1)
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
so = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[0].id})],
|
|
})
|
|
so.action_confirm()
|
|
job = env['fp.job'].search([('sale_order_id', '=', so.id)])[0]
|
|
original = job.name
|
|
|
|
# Block plain rename
|
|
try:
|
|
job.name = 'HACKED-12345'
|
|
print("FAIL: rename succeeded")
|
|
except Exception as e:
|
|
print(f"OK: blocked - {str(e)[:60]}")
|
|
|
|
# Bypass works
|
|
job.with_context(fp_allow_name_rename=True).name = 'WO-LEGIT'
|
|
print(f"OK: bypass works -> {job.name}")
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py fusion_plating/fusion_plating/__manifest__.py
|
|
git commit -m "feat(numbering): make name + doc_index immutable post-issuance"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Block unlink on issued documents
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating/models/fp_parent_numbered_mixin.py`
|
|
- Modify: `fusion_plating/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Add unlink override**
|
|
|
|
```python
|
|
def unlink(self):
|
|
"""Block hard-delete on any record that has been issued a name.
|
|
|
|
Cancellation must go through the state machine so the audit trail
|
|
keeps the issued number tied to its cancellation reason. Hard-delete
|
|
would leave a phantom gap in the counter."""
|
|
for rec in self:
|
|
if rec.name and rec.x_fc_doc_index:
|
|
raise UserError(_(
|
|
'Document "%s" cannot be deleted — it is part of the '
|
|
'compliance audit trail. Cancel it instead (use the '
|
|
'state machine\'s Cancel action). This rule applies to '
|
|
'all users including administrators.'
|
|
) % rec.display_name)
|
|
return super().unlink()
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy + smoke test**
|
|
|
|
```python
|
|
# /tmp/numbering_task12_verify.py
|
|
SO = env['sale.order']
|
|
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=1)
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
so = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 5, 'x_fc_part_catalog_id': parts[0].id})],
|
|
})
|
|
so.action_confirm()
|
|
job = env['fp.job'].search([('sale_order_id', '=', so.id)])[0]
|
|
|
|
try:
|
|
job.unlink()
|
|
print("FAIL: unlink succeeded")
|
|
except Exception as e:
|
|
print(f"OK: blocked - {str(e)[:60]}")
|
|
|
|
try:
|
|
so.unlink()
|
|
print("FAIL: SO unlink succeeded")
|
|
except Exception as e:
|
|
print(f"OK: SO unlink blocked - {str(e)[:60]}")
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating/models/fp_parent_numbered_mixin.py fusion_plating/fusion_plating/__manifest__.py
|
|
git commit -m "feat(numbering): block unlink on issued compliance documents"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 7 — Views + reports
|
|
|
|
### Task 13: Surface quote ref alongside SO name on form
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_jobs/views/sale_order_views.xml`
|
|
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Add the quote-ref line to the SO form**
|
|
|
|
```xml
|
|
<xpath expr="//div[hasclass('oe_title')]" position="inside">
|
|
<div t-if="x_fc_quote_ref" class="text-muted" style="font-size: 0.9em;">
|
|
Originally quoted as <field name="x_fc_quote_ref" readonly="1" nolabel="1" class="d-inline"/>
|
|
</div>
|
|
</xpath>
|
|
<xpath expr="//search" position="inside">
|
|
<field name="x_fc_parent_number" string="Parent Number"/>
|
|
<field name="x_fc_quote_ref" string="Quote Ref"/>
|
|
</xpath>
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy + visually verify**
|
|
|
|
Open any confirmed SO; verify the heading shows `SO-NNNNN` and a grey "Originally quoted as Q...-..." line appears below.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/views/sale_order_views.xml fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "feat(numbering): surface quote ref alongside SO name on form"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Fix WO Detail report's short_wo logic
|
|
|
|
**Files:**
|
|
- Modify: `fusion_plating_jobs/report/report_fp_job_wo_detail.xml`
|
|
- Modify: `fusion_plating_jobs/__manifest__.py` (version bump)
|
|
|
|
- [ ] **Step 1: Update the t-set**
|
|
|
|
Replace:
|
|
```xml
|
|
<t t-set="short_wo" t-value="(job.name or '').split('/')[-1]"/>
|
|
```
|
|
with:
|
|
```xml
|
|
<!-- Strip the WO- prefix for new-format names; fall back to last
|
|
slash-segment for legacy WH/JOB/NNNNN names. -->
|
|
<t t-set="short_wo" t-value="(
|
|
job.name and job.name.startswith('WO-') and job.name[3:]
|
|
or (job.name or '').split('/')[-1]
|
|
)"/>
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy + visual verify**
|
|
|
|
Print the WO Detail PDF for a new-format job; confirm the Work Order column shows `30000` or `30000-02` (no `WO-` prefix).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/report/report_fp_job_wo_detail.xml fusion_plating/fusion_plating_jobs/__manifest__.py
|
|
git commit -m "fix(numbering): WO Detail report strips WO- prefix for compact display"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 8 — End-to-end verification
|
|
|
|
### Task 15: Full quote→cash audit walkthrough
|
|
|
|
**Files:**
|
|
- Create: `fusion_plating_jobs/scripts/numbering_e2e_walkthrough.py`
|
|
|
|
- [ ] **Step 1: Write the walkthrough**
|
|
|
|
```python
|
|
# fusion_plating_jobs/scripts/numbering_e2e_walkthrough.py
|
|
"""E2E numbering walkthrough.
|
|
|
|
Quote -> confirm -> 2 invoices -> CoC -> delivery -> receiving ->
|
|
NCR -> immutability check -> unlink block check -> direct invoice
|
|
block check. Asserts every doc shares the same parent number.
|
|
Re-runnable; rolls back at the end."""
|
|
SO = env['sale.order']
|
|
parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=2)
|
|
assert len(parts) >= 2
|
|
partner = env['res.partner'].search([], limit=1)
|
|
product = env['product.product'].search([], limit=1)
|
|
|
|
print('=' * 60)
|
|
print('Numbering hierarchy — E2E walkthrough')
|
|
print('=' * 60)
|
|
|
|
# A: Quote -> confirm
|
|
so = SO.create({
|
|
'partner_id': partner.id,
|
|
'order_line': [
|
|
(0, 0, {'product_id': product.id, 'product_uom_qty': 5,
|
|
'x_fc_part_catalog_id': parts[0].id, 'sequence': 10}),
|
|
(0, 0, {'product_id': product.id, 'product_uom_qty': 3,
|
|
'x_fc_part_catalog_id': parts[1].id, 'sequence': 20}),
|
|
],
|
|
})
|
|
quote_name = so.name
|
|
print(f'Quote: {quote_name}')
|
|
assert quote_name.startswith('Q')
|
|
so.action_confirm()
|
|
parent = so.x_fc_parent_number
|
|
print(f'Confirmed: {so.name} (parent={parent}, quote_ref={so.x_fc_quote_ref})')
|
|
assert so.name == f'SO-{parent}'
|
|
|
|
# B: WOs
|
|
jobs = env['fp.job'].search([('sale_order_id', '=', so.id)], order='x_fc_doc_index')
|
|
print(f'WOs: {jobs.mapped("name")}')
|
|
assert len(jobs) == 2
|
|
assert jobs[0].name == f'WO-{parent}-01'
|
|
assert jobs[1].name == f'WO-{parent}-02'
|
|
|
|
# C: Invoices
|
|
inv1 = so._create_invoices()
|
|
inv2 = so._create_invoices()
|
|
print(f'Invoices: {inv1.name}, {inv2.name}')
|
|
assert inv1.name == f'IN-{parent}'
|
|
assert inv2.name == f'IN-{parent}-02'
|
|
|
|
# D: CoC
|
|
coc = env['fp.certificate'].create({'sale_order_id': so.id, 'partner_id': partner.id})
|
|
print(f'CoC: {coc.name}')
|
|
assert coc.name == f'CoC-{parent}'
|
|
|
|
# E: Delivery
|
|
dlv = env['fusion.plating.delivery'].create({'sale_order_id': so.id, 'partner_id': partner.id})
|
|
print(f'Delivery: {dlv.name}')
|
|
assert dlv.name == f'DLV-{parent}'
|
|
|
|
# F: Receiving
|
|
rcv = env['fp.receiving'].create({'sale_order_id': so.id, 'partner_id': partner.id})
|
|
print(f'Receiving: {rcv.name}')
|
|
assert rcv.name == f'RCV-{parent}'
|
|
|
|
# G: NCR
|
|
ncr = env['fusion.plating.ncr'].create({
|
|
'job_id': jobs[0].id, 'partner_id': partner.id, 'description': 'E2E test',
|
|
})
|
|
print(f'NCR: {ncr.name}')
|
|
assert ncr.name == f'NCR-{parent}'
|
|
|
|
# H: Immutability + unlink block
|
|
from odoo.exceptions import UserError
|
|
try:
|
|
jobs[0].name = 'HACKED'
|
|
print('FAIL: name mutation')
|
|
except UserError:
|
|
print('OK: WO name immutable')
|
|
try:
|
|
coc.unlink()
|
|
print('FAIL: unlink')
|
|
except UserError:
|
|
print('OK: CoC unlink blocked')
|
|
|
|
# I: Direct invoice block
|
|
try:
|
|
env['account.move'].create({
|
|
'move_type': 'out_invoice',
|
|
'partner_id': partner.id,
|
|
'journal_id': env['account.journal'].search([('type', '=', 'sale')], limit=1).id,
|
|
})
|
|
print('FAIL: direct invoice')
|
|
except UserError:
|
|
print('OK: direct invoice blocked')
|
|
|
|
print('=' * 60)
|
|
print(f'PASS: every doc tied to parent {parent}')
|
|
env.cr.rollback()
|
|
```
|
|
|
|
- [ ] **Step 2: Run the walkthrough**
|
|
|
|
Copy to entech, exec via odoo-shell. Expected output ends with `PASS: every doc tied to parent <N>`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```
|
|
git add fusion_plating/fusion_plating_jobs/scripts/numbering_e2e_walkthrough.py
|
|
git commit -m "test(numbering): E2E walkthrough script"
|
|
```
|
|
|
|
---
|
|
|
|
## Post-implementation manual checklist
|
|
|
|
After Task 15 passes, perform these on entech (the live system):
|
|
|
|
- [ ] Create a new quotation in the UI. Confirm it. Verify `SO-NNNNN` shows on the form and the grey "Originally quoted as" line below.
|
|
- [ ] Open the resulting WO. Verify it's named `WO-NNNNN` (bare, if 1 recipe).
|
|
- [ ] Invoice the SO. Verify the invoice is named `IN-NNNNN`. Invoice it again partially. Verify second is `IN-NNNNN-02`.
|
|
- [ ] Try to create an invoice directly from the Accounting app. Verify the UserError blocks you (admin too).
|
|
- [ ] Print the WO Detail PDF. Verify the Work Order column shows `NNNNN` (no `WO-` prefix in the table).
|
|
- [ ] Print the CoC. Verify the reference is `CoC-NNNNN`.
|
|
|
|
If any fail, capture the failing form/PDF screenshot and debug the relevant task.
|