"""End-to-end numbering walkthrough for the 2026-05-12 parent-number hierarchy. Quote -> confirm -> 2 invoices (partial billing) -> CoC -> delivery -> receiving -> NCR (legacy fallback) -> Hold (parent-derived) -> immutability check -> unlink block check -> direct invoice block. Asserts every SO-linked doc shares the same parent number. Re-runnable; rolls back at the end so no DB state is left behind. Run via odoo-shell: exec(open('/path/to/numbering_e2e_walkthrough.py').read()) """ from odoo.exceptions import UserError SO = env['sale.order'] AM = env['account.move'] journal = env['account.journal'].search([('type', '=', 'sale')], limit=1) parts = env['fp.part.catalog'].search([('default_process_id', '!=', False)], limit=2) assert len(parts) >= 2, 'need at least 2 parts with default recipes' partner = env['res.partner'].search([], limit=1) facility = env['fusion.plating.facility'].search([], limit=1) product = env['product.product'].search([('type', '!=', 'service')], limit=1) or 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, 'x_fc_po_override': True, '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'A. Quote: {quote_name}') assert quote_name.startswith('Q'), f'expected Q-prefix, got {quote_name}' so.action_confirm() parent = so.x_fc_parent_number print(f'A. Confirmed: {so.name} (parent={parent}, quote_ref={so.x_fc_quote_ref})') assert so.name == f'SO-{parent}' assert so.x_fc_quote_ref == quote_name # === B: WOs (2 recipes split SO into -01, -02) === jobs = env['fp.job'].search([('sale_order_id', '=', so.id)], order='x_fc_doc_index') print(f'B. 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: Two invoices (partial billing) === inv1 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({ 'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id, 'invoice_origin': so.name, }) inv2 = AM.with_context(fp_from_so_invoice=True, fp_invoice_source_so_id=so.id).create({ 'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id, 'invoice_origin': so.name, }) print(f'C. 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'D. CoC: {coc.name}') assert coc.name == f'CoC-{parent}' # === E: Delivery (linked via job_ref) === dlv = env['fusion.plating.delivery'].create({'partner_id': partner.id, 'job_ref': jobs[0].name}) print(f'E. Delivery: {dlv.name}') assert dlv.name == f'DLV-{parent}' # === F: Receiving (already auto-created at confirm; manual is -02) === existing_rcv = env['fp.receiving'].search([('sale_order_id', '=', so.id)]) print(f'F. Receivings (incl. auto-created): {existing_rcv.mapped("name")}') assert any(r.name == f'RCV-{parent}' for r in existing_rcv) # === G: Hold (via job_id) === hold = env['fusion.plating.quality.hold'].create({ 'job_id': jobs[0].id, 'hold_reason': 'qc_failure', 'qty_on_hold': 1, 'description': 'E2E test hold', }) print(f'G. Hold: {hold.name}') assert hold.name == f'HOLD-{parent}' # === H: RMA (via sale_order_id directly) === rma = env['fusion.plating.rma'].create({'sale_order_id': so.id, 'partner_id': partner.id}) print(f'H. RMA: {rma.name}') assert rma.name == f'RMA-{parent}' # === I: NCR + CAPA (no SO link in core -> legacy seq) === ncr = env['fusion.plating.ncr'].create({ 'description': 'E2E test', 'customer_partner_id': partner.id, 'facility_id': facility.id, }) print(f'I. NCR (no SO link): {ncr.name}') assert not ncr.name.startswith('NCR-3'), f'expected legacy seq, got {ncr.name}' capa = env['fusion.plating.capa'].create({ 'description': 'E2E test capa', 'ncr_id': ncr.id, 'facility_id': facility.id, }) print(f'I. CAPA (no SO link): {capa.name}') assert not capa.name.startswith('CAPA-3'), f'expected legacy seq, got {capa.name}' # === J: Immutability === try: jobs[0].name = 'HACKED' print('FAIL J: name mutation succeeded') except UserError: print('J. OK: WO name immutable') # === K: Unlink block === try: coc.unlink() print('FAIL K: unlink succeeded') except UserError: print('K. OK: CoC unlink blocked') # === L: Direct invoice creation block === try: AM.create({ 'move_type': 'out_invoice', 'partner_id': partner.id, 'journal_id': journal.id, }) print('FAIL L: direct invoice succeeded') except UserError: print('L. OK: direct invoice blocked') print('=' * 60) print(f'PASS: every doc tied to parent {parent}') print('=' * 60) env.cr.rollback() print('(rolled back — DB unchanged)')