fix(configurator): programmatic SO-line create fallback for x_fc_internal_description
When Sub 2 Task 26 flipped x_fc_internal_description to required=True, any programmatic sale.order.line creation that doesn't set the field fails at the Postgres NOT NULL constraint. Callers include: - sale_mrp stock-move line creation (doesn't set name either) - demo seeders - external integrations - test scripts The UI-side onchange populates the field when the user picks a description template; this hook mirrors that for programmatic callers. Fallback chain: explicit vals['x_fc_internal_description'] → vals['name'] → product_id.display_name → '—'. Matches the migration's backfill rule. Also adds Sub 2 end-to-end smoke test (6 cases, all green): 1. Required-field rejection on part creation 2. Required-field rejection on template creation 3. Template picker populates both SO-line descriptions 4. Cert resolver: part-level override wins over partner 5. display_name renders part_number + revision + name 6. certificate_requirement defaults to 'inherit' QC Phase 1-3 regression suite remains green after the fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
144
docs/superpowers/tests/2026-04-21-sub2-smoke.py
Normal file
144
docs/superpowers/tests/2026-04-21-sub2-smoke.py
Normal file
@@ -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")
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user