diff --git a/docs/superpowers/tests/2026-04-21-sub2-smoke.py b/docs/superpowers/tests/2026-04-21-sub2-smoke.py new file mode 100644 index 00000000..6e8c0255 --- /dev/null +++ b/docs/superpowers/tests/2026-04-21-sub2-smoke.py @@ -0,0 +1,144 @@ +"""Sub 2 — end-to-end smoke. Full lifecycle with Sub 2 features. + +Confirms: + - new parts reject without part_number / revision + - description template row requires both fields + - SO line requires both descriptions + - cert cascade via resolver, part-level wins over partner + - display_name renders part_number + revision + name +""" +import sys +env = self.env + +def ok(msg): print(f" [OK] {msg}") +def fail(msg): print(f" [FAIL] {msg}"); sys.exit(1) +def hdr(t): print(f"\n=== {t} ===") + +# Helper — savepoint wrapper so a required-field rejection doesn't +# leave the transaction in an aborted state for later tests. +def expect_raise(label, callable_, keyword): + env.cr.execute("SAVEPOINT smoke_test") + try: + callable_() + env.cr.execute("RELEASE SAVEPOINT smoke_test") + fail(f"{label} — no exception raised") + except Exception as e: + env.cr.execute("ROLLBACK TO SAVEPOINT smoke_test") + msg = str(e)[:120] + if keyword.lower() in msg.lower(): + ok(f"{label} — rejected: {msg[:80]}") + else: + fail(f"{label} — wrong error: {msg}") + +# ---- 1. Required fields on fp.part.catalog ---- +hdr("1. Required fields on fp.part.catalog") +expect_raise( + "part without part_number", + lambda: env['fp.part.catalog'].create({ + 'partner_id': env['res.partner'].search([('customer_rank', '>', 0)], limit=1).id, + }), + keyword='part_number', +) + +# ---- 2. Description template two-field requirement ---- +hdr("2. Description template required fields") +part = env['fp.part.catalog'].create({ + 'name': 'Sub2 Smoke Part', + 'part_number': 'SUB2-SMOKE-001', + 'revision': 'A', + 'partner_id': env['res.partner'].search([('customer_rank', '>', 0)], limit=1).id, +}) +expect_raise( + "template without customer_facing_description", + lambda: env['fp.sale.description.template'].create({ + 'name': 'Broken template', + 'part_catalog_id': part.id, + 'internal_description': 'ops only', + # customer_facing_description missing + }), + keyword='customer_facing', +) + +# ---- 3. SO line dual descriptions ---- +hdr("3. SO line template picker populates both descriptions") +tpl = env['fp.sale.description.template'].create({ + 'name': 'Standard', + 'part_catalog_id': part.id, + 'internal_description': 'Racking pattern A; mask threaded holes', + 'customer_facing_description': 'Electroless nickel plate per customer spec', +}) +product = env['product.product'].search([('active', '=', True)], limit=1) +# In the UI the onchange populates x_fc_internal_description + name from +# the template; for a programmatic create both must be set explicitly. +# The onchange is unit-tested separately (simulate it here). +so_vals = { + 'partner_id': part.partner_id.id, + 'order_line': [(0, 0, { + 'product_id': product.id, + 'product_uom_qty': 1, + 'x_fc_part_catalog_id': part.id, + 'x_fc_description_template_id': tpl.id, + 'name': tpl.customer_facing_description, + 'x_fc_internal_description': tpl.internal_description, + })], +} +if 'x_fc_po_number' in env['sale.order']._fields: + so_vals['x_fc_po_number'] = 'SUB2-SMOKE-PO' +so = env['sale.order'].create(so_vals) +line = so.order_line[0] +if line.x_fc_internal_description != tpl.internal_description: + fail(f"line.x_fc_internal_description mismatch: got {line.x_fc_internal_description!r}") +if line.name != tpl.customer_facing_description: + fail(f"line.name mismatch: got {line.name!r}") +ok(f"SO line has both descriptions set from template") + +# Verify onchange method works when invoked directly (simulating UI) +line2 = env['sale.order.line'].new({ + 'x_fc_description_template_id': tpl.id, + 'product_id': product.id, + 'order_id': so.id, +}) +line2._onchange_description_template() +if line2.name != tpl.customer_facing_description: + fail(f"onchange didn't set name: {line2.name!r}") +if line2.x_fc_internal_description != tpl.internal_description: + fail(f"onchange didn't set internal: {line2.x_fc_internal_description!r}") +ok("onchange method populates both fields correctly") + +# ---- 4. Cert resolver end-to-end ---- +hdr("4. Cert resolver with part override wins over partner") +part.certificate_requirement = 'coc_thickness' +part.partner_id.x_fc_send_coc = False +part.partner_id.x_fc_send_thickness_report = False +so.action_confirm() +mo = env['mrp.production'].create({ + 'product_id': product.id, 'product_qty': 1, 'origin': so.name, +}) +mo.action_confirm() +want_coc, want_thickness = mo._fp_resolve_cert_requirement() +if (want_coc, want_thickness) != (True, True): + fail(f"resolver returned ({want_coc}, {want_thickness}); expected (True, True)") +ok("part-level coc_thickness override wins over partner=off") + +# ---- 5. display_name compute ---- +hdr("5. display_name includes part_number + revision") +part.invalidate_recordset() +if 'SUB2-SMOKE-001' not in part.display_name: + fail(f"display_name missing part_number: {part.display_name!r}") +if 'Rev A' not in part.display_name: + fail(f"display_name missing revision: {part.display_name!r}") +ok(f"display_name: {part.display_name}") + +# ---- 6. certificate_requirement defaults to 'inherit' ---- +hdr("6. New parts default certificate_requirement to 'inherit'") +p2 = env['fp.part.catalog'].create({ + 'name': 'Default Inherit Test', + 'part_number': 'DEF-TEST-001', + 'revision': 'A', + 'partner_id': env['res.partner'].search([('customer_rank', '>', 0)], limit=1).id, +}) +if p2.certificate_requirement != 'inherit': + fail(f"expected 'inherit', got {p2.certificate_requirement!r}") +ok(f"certificate_requirement default = {p2.certificate_requirement}") + +hdr("SUB 2 SMOKE COMPLETE") diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py index 98b6616a..121d8b40 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py @@ -67,6 +67,36 @@ class SaleOrderLine(models.Model): 'preserved for audit. Useful when a part is cancelled mid-order.', ) + @api.model_create_multi + def create(self, vals_list): + """Default `x_fc_internal_description` from `name` when a caller + creates a line programmatically without supplying the internal + description. + + Sub 2 made `x_fc_internal_description` required. The UI-side + onchange fills it when the user picks a description template, + but programmatic creators (sale_mrp bridge, migration scripts, + external integrations, demo seeders) may not know about this + field. Instead of forcing every call site to update, fall back + to `name` — same rule the upgrade migration used when it + back-filled historical lines. + """ + Product = self.env['product.product'] + for vals in vals_list: + if vals.get('x_fc_internal_description'): + continue + # Try the explicit `name` first. If the caller didn't pass + # one (sale_mrp + some Odoo internals don't — they let the + # name compute from product_id later), fall back to the + # product's display_name so we have SOMETHING non-empty. + fallback = vals.get('name') + if not fallback and vals.get('product_id'): + prod = Product.browse(vals['product_id']).exists() + if prod: + fallback = prod.display_name or prod.name + vals['x_fc_internal_description'] = fallback or '—' + return super().create(vals_list) + @api.onchange('x_fc_description_template_id') def _onchange_description_template(self): """When estimator picks a template, auto-fill both descriptions.