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.',
|
'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')
|
@api.onchange('x_fc_description_template_id')
|
||||||
def _onchange_description_template(self):
|
def _onchange_description_template(self):
|
||||||
"""When estimator picks a template, auto-fill both descriptions.
|
"""When estimator picks a template, auto-fill both descriptions.
|
||||||
|
|||||||
Reference in New Issue
Block a user