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>
44 KiB
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
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 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:
'data/fp_numbering_sequences.xml',
Bump the version.
- Step 3: Deploy + verify
Run the deploy commands, then this smoke test:
# /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
# 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:
from . import fp_parent_numbered_mixin
- Step 3: Deploy + verify
# /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:
# 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
# /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
@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
# /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):
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
# /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_sogrouping + naming) -
Modify: both
__manifest__.py(version bump) -
Step 1: fp.job inherits the mixin
Change fp.job class declaration:
class FpJob(models.Model):
_name = 'fp.job'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
Add hook methods:
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):
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:
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:
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
# /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; verifyaccountis in depends) -
Step 1: Create the override
# 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:
from . import account_move
- Step 3: Deploy + smoke test
# /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_invoicessets context
In sale_order.py:
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:
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
# /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
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
@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:
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
# /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:
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
# /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:
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
# /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
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
# /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
<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:
<t t-set="short_wo" t-value="(job.name or '').split('/')[-1]"/>
with:
<!-- 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
# 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-NNNNNshows 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 isIN-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(noWO-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.