Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-12-parent-number-hierarchy-plan.md
gsinghpal 2de5491693 plan(numbering): step-by-step implementation plan
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>
2026-05-12 12:38:08 -04:00

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.