# 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 | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/'" ``` 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 --stop-after-init\" && systemctl start odoo'" ``` **Smoke-test command:** ``` cat | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /tmp/.py'" ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"exec(open(\\\"/tmp/.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 Fusion Plating: Parent Number fp.parent.number 0 30000 Fusion Plating: Quote Number fp.quote.number Q%(year)s%(month)s- 0 200 ``` - [ ] **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 %s 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-.""" 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 %s as %s.' ) % (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- 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 '' def _fp_parent_counter_field(self): return '' ``` - [ ] **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 = '' 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
Originally quoted as
``` - [ ] **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 ``` with: ```xml ``` - [ ] **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 `. - [ ] **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.