feat(plating): Sub 5 — order-line fields (serial, job#, thickness, revision)

Four new fields on every sale.order.line, propagated through to MO,
Delivery, and Invoice for end-to-end traceability:

- fp.serial registry (new model in configurator) with smart-button
  traceability to Sale Order, MO, Delivery, Invoice, Part. M2O on SO
  line; optional; user types a customer serial or clicks Generate
  Serial for a sequence-backed one. Reverse O2M links split across
  configurator (invoice) / bridge_mrp (MO) / logistics (delivery) so
  module load order is respected.
- x_fc_job_number on SO line, auto-sequenced FP-JOB-NNNNN on SO
  confirm. Editable — shops can override for customer/legacy schemes.
- fp.coating.thickness (new child of fp.coating.config) with per-
  config discrete thickness options; x_fc_thickness_id on SO line
  domain-filtered to the line's coating. Auto-clears when coating
  changes.
- x_fc_revision_snapshot Char on SO line, frozen from
  x_fc_part_catalog_id.revision at save. Protects historical SOs from
  later catalog edits. Secondary "Revision" picker on the tree view
  lets users switch between prior revisions of the same part number;
  the Part M2O still surfaces only is_latest_revision rows.

Reports (CoC, packing slip, invoice, BoL) pick up all four via the
Sub 2 customer_line_header macro — one macro edit, four reports.

Smoke on entech: 11 assertions pass including revision snapshot,
generate-serial button, typed-serial create-on-fly, coating→thickness
domain reset, SO confirm auto job#, and MO traceability carry.

Module version bumps:
  fusion_plating_configurator  → 19.0.12.0.0
  fusion_plating_bridge_mrp    → 19.0.11.0.0
  fusion_plating_logistics     → 19.0.2.0.0 (+depends configurator)
  fusion_plating_reports       → 19.0.5.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-22 23:04:44 -04:00
parent bb9bcf45f8
commit 25c3f6f8d1
29 changed files with 934 additions and 21 deletions

View File

@@ -0,0 +1,149 @@
"""Sub 5 smoke test — runs inside odoo-shell on entech."""
env = env
Partner = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Thickness = env['fp.coating.thickness']
Serial = env['fp.serial']
SO = env['sale.order']
admin = env.ref('base.user_admin')
# ---- Seed customer + part + coating + thickness options ---------------
cust = Partner.create({
'name': 'Sub5 Smoke Customer',
'is_company': True,
'customer_rank': 1,
})
part_a = Part.create({
'partner_id': cust.id,
'part_number': 'SUB5-001',
'revision': 'A',
})
# Bump to a newer revision so we have A + B in the chain
part_a.action_create_revision()
part_b = Part.search([
('parent_part_id', '=', (part_a.parent_part_id or part_a).id),
('is_latest_revision', '=', True),
], limit=1, order='revision_number desc')
assert part_b.revision != part_a.revision, 'revision chain failed'
print(f'[OK] Part revisions: A={part_a.revision} / latest={part_b.revision}')
coating = Coating.search([], limit=1) or Coating.create({
'name': 'Sub5 Coating Test',
'process_type_id': env['fusion.plating.process.type'].search([], limit=1).id,
})
t1 = Thickness.create({
'coating_config_id': coating.id,
'value': 0.001, 'uom': 'inches', 'sequence': 10,
})
t2 = Thickness.create({
'coating_config_id': coating.id,
'value': 0.0015, 'uom': 'inches', 'sequence': 20,
})
print(f'[OK] Thickness options: {t1.display_name} / {t2.display_name}')
# ---- Create SO with latest revision, assign serial via create-on-fly --
product = env['product.product'].search([('sale_ok', '=', True)], limit=1)
so = SO.create({
'partner_id': cust.id,
'x_fc_po_number': 'PO-SUB5-SMOKE',
'x_fc_po_received': True,
'order_line': [(0, 0, {
'product_id': product.id,
'product_uom_qty': 10,
'name': 'Sub5 smoke line',
'x_fc_part_catalog_id': part_b.id,
'x_fc_coating_config_id': coating.id,
'x_fc_thickness_id': t1.id,
'x_fc_internal_description': 'smoke',
})],
})
line = so.order_line[0]
line.invalidate_recordset()
assert line.x_fc_revision_snapshot == part_b.revision, (
f'snapshot mismatch: {line.x_fc_revision_snapshot} vs {part_b.revision}'
)
print(f'[OK] Revision snapshot captured on create: {line.x_fc_revision_snapshot}')
# ---- Generate serial button -----------------------------------------
line.action_generate_serial()
line.invalidate_recordset()
assert line.x_fc_serial_id, 'serial should be assigned'
assert line.x_fc_serial_id.name.startswith('FP-SN-'), (
f'unexpected serial name: {line.x_fc_serial_id.name}'
)
print(f'[OK] Generate serial: {line.x_fc_serial_id.name}')
# ---- Customer-typed serial --------------------------------------------
so2 = SO.create({
'partner_id': cust.id,
'x_fc_po_number': 'PO-SUB5-SMOKE-2',
'x_fc_po_received': True,
'order_line': [(0, 0, {
'product_id': product.id,
'product_uom_qty': 5,
'name': 'Sub5 smoke line 2',
'x_fc_part_catalog_id': part_b.id,
'x_fc_coating_config_id': coating.id,
'x_fc_internal_description': 'smoke2',
})],
})
line2 = so2.order_line[0]
typed_serial = Serial.create({'name': 'CUST-999'})
line2.x_fc_serial_id = typed_serial.id
line2.invalidate_recordset()
assert line2.x_fc_serial_id.name == 'CUST-999'
print('[OK] Customer-typed serial attached')
# ---- Revision picker: switch to Rev A --------------------------------
line.x_fc_revision_pick_id = part_a.id
line.invalidate_recordset()
assert line.x_fc_part_catalog_id == part_a, (
f'picker did not re-point part: {line.x_fc_part_catalog_id.revision}'
)
assert line.x_fc_revision_snapshot == part_a.revision
print(f'[OK] Revision picker: switched to {part_a.revision}')
# ---- Confirm SO → job number auto-assigned ---------------------------
so.action_confirm()
line.invalidate_recordset()
assert line.x_fc_job_number, 'job number should be auto-assigned on confirm'
assert line.x_fc_job_number.startswith('FP-JOB-'), (
f'unexpected job number: {line.x_fc_job_number}'
)
print(f'[OK] SO confirmed → job number: {line.x_fc_job_number}')
# ---- MO carry-over (if bridge_mrp auto-created an MO) -----------------
MO = env['mrp.production']
mos = MO.search([('origin', '=', so.name)])
if mos:
mo = mos[0]
print(f'[OK] MO created ({mo.name}) → serial={mo.x_fc_serial_id.name if mo.x_fc_serial_id else ""} / job={mo.x_fc_job_number or ""} / thickness={mo.x_fc_thickness_id.display_name if mo.x_fc_thickness_id else ""} / rev={mo.x_fc_revision_snapshot or ""}')
if mo.x_fc_serial_id:
assert mo.x_fc_serial_id == line.x_fc_serial_id, 'MO serial mismatch'
print('[OK] Serial carried MO ← SO line')
else:
print('[SKIP] No MO auto-created for this SO')
# ---- fp.serial smart-button counts reflect reality -------------------
serial = line.x_fc_serial_id
serial.invalidate_recordset()
prod_cnt = serial.production_count
del_cnt = serial.delivery_count
print(f'[OK] Serial {serial.name}: MOs={prod_cnt}, Deliveries={del_cnt}, Invoices={serial.invoice_count}')
# ---- Thickness dropdown domain ----------------------------------------
another_coating = Coating.create({
'name': 'Sub5 Other Coating',
'process_type_id': coating.process_type_id.id,
})
line.x_fc_coating_config_id = another_coating.id
line._onchange_coating_clears_thickness()
line.invalidate_recordset()
assert not line.x_fc_thickness_id, 'thickness should clear when coating changes'
print('[OK] Thickness clears when coating switches')
# ---- Cleanup ---------------------------------------------------------
env.cr.rollback()
print('\n=== SUB 5 SMOKE PASS — all assertions held ===')