diff --git a/fusion_plating/docs/superpowers/plans/2026-05-12-parent-number-hierarchy-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-12-parent-number-hierarchy-plan.md new file mode 100644 index 00000000..b2a1e710 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-12-parent-number-hierarchy-plan.md @@ -0,0 +1,1275 @@ +# 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.