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

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_so grouping + 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; verify account is 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_invoices sets 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"

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"

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-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.