chore(receiving): port received_qty auto-prefill from live entech to main

The auto-prefill logic that fills received_qty from expected_qty on
fp.receiving create was committed to the entech LXC but never made it
back to main. Verified by a full quote→delivery→invoice walkthrough
(scripts/fp_e2e_human.py) — receiving step now passes.

Also adds the human-walkthrough E2E script that exercises every step:
RFQ → quote → SO confirm → MO + portal job auto-create → receiving
prefill → recipe → WO execution → MO done → CoC cert (rich PDF, no
thickness duplicate) → delivery prefill + lifecycle → invoice (posted,
not auto-paid) → notification log audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 01:26:02 -04:00
parent f0577c1788
commit 92f93de47b
3 changed files with 654 additions and 0 deletions

View File

@@ -88,6 +88,10 @@ class FpReceiving(models.Model):
for vals in vals_list: for vals in vals_list:
if vals.get('name', 'New') == 'New': if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New' vals['name'] = self.env['ir.sequence'].next_by_code('fp.receiving') or 'New'
# Prefill received_qty from expected_qty so the operator only
# has to confirm or correct — the common case is qty matches.
if vals.get('expected_qty') and not vals.get('received_qty'):
vals['received_qty'] = vals['expected_qty']
return super().create(vals_list) return super().create(vals_list)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------

View File

@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
# Full quote→payment E2E. Run via: odoo shell --no-http -d admin
# < scripts/fp_e2e_full.py
#
# Hits every workflow step and prints PASS/FAIL per check.
from datetime import datetime, timedelta
import base64
import sys
env = env # noqa injected by odoo shell
def banner(txt):
print(f"\n{'='*72}\n {txt}\n{'='*72}")
def check(label, ok, detail=''):
flag = 'PASS' if ok else 'FAIL'
print(f" [{flag}] {label}{('' + detail) if detail else ''}")
return ok
RESULTS = []
def expect(label, ok, detail=''):
RESULTS.append((label, ok))
check(label, ok, detail)
# =====================================================================
banner('PHASE 1 — Quote configurator + PO → client_order_ref')
# =====================================================================
# Fresh customer per run so we can verify automations on a clean slate.
stamp = datetime.now().strftime('%y%m%d-%H%M%S')
cust_name = f'E2E Customer {stamp}'
customer = env['res.partner'].create({
'name': cust_name,
'company_type': 'company',
'email': f'e2e-{stamp}@example.com',
'street': '100 King St W',
'city': 'Toronto', 'zip': 'M5X 1A1',
'country_id': env.ref('base.ca').id,
})
expect('customer created', bool(customer))
# Use first available coating + part catalog. The seeder has plenty.
coating = env['fp.coating.config'].search([], limit=1)
part_cat = env['fp.part.catalog'].search([], limit=1)
expect('coating config available', bool(coating))
expect('part catalog available', bool(part_cat))
po_number = f'PO-E2E-{stamp}'
quote = env['fp.quote.configurator'].create({
'partner_id': customer.id,
'part_catalog_id': part_cat.id,
'coating_config_id': coating.id,
'quantity': 25,
'po_number_preliminary': po_number,
})
expect('quote configurator created', bool(quote), quote.name or '')
# Trigger price calc + create the SO.
quote.action_calculate_price()
expect('price calculated', quote.unit_price > 0,
f'unit ${quote.unit_price:.2f}')
result = quote.action_create_quotation()
so_id = result.get('res_id') if isinstance(result, dict) else False
so = env['sale.order'].browse(so_id)
expect('SO created from quote', bool(so), so.name or '')
expect('client_order_ref carries PO', so.client_order_ref == po_number,
f'got "{so.client_order_ref}"')
# =====================================================================
banner('PHASE 2 — SO confirm → MO + portal job + WOs')
# =====================================================================
so.action_confirm()
expect('SO confirmed', so.state == 'sale')
# Auto-MO from our hook (FP-SERVICE bypasses native sale_mrp routing).
mo = env['mrp.production'].search([('origin', '=', so.name)], limit=1)
expect('MO auto-created from SO', bool(mo), mo.name or '')
# Portal job auto-created on MO confirm.
if mo.state == 'draft':
mo.action_confirm()
job = env['fusion.plating.portal.job'].search(
[('production_id', '=', mo.id)], limit=1,
)
expect('portal job auto-created', bool(job), job.name or '')
expect('portal job linked back to MO',
mo.x_fc_portal_job_id.id == job.id if job else False)
# Recipe + WOs
recipe = env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1)
if recipe and not mo.x_fc_recipe_id:
mo.x_fc_recipe_id = recipe.id
mo._generate_workorders_from_recipe()
expect('recipe assigned', bool(mo.x_fc_recipe_id),
mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '')
expect('work orders generated', len(mo.workorder_ids) > 0,
f'{len(mo.workorder_ids)} WOs')
# =====================================================================
banner('PHASE 3 — Receiving with auto-prefilled qty')
# =====================================================================
Receiving = env['fp.receiving']
recv = Receiving.create({
'partner_id': customer.id,
'sale_order_id': so.id,
'received_date': fields.Datetime.now(),
'line_ids': [(0, 0, {
'description': 'E2E test parts',
'expected_qty': 25,
})],
})
expect('receiving record created', bool(recv), recv.name or '')
line = recv.line_ids[:1]
expect('received_qty auto-prefilled from expected_qty',
line.received_qty == line.expected_qty,
f'expected={line.expected_qty} received={line.received_qty}')
if recv.state == 'draft':
try:
recv.action_confirm()
except Exception as e:
print(f" [info] receiving confirm: {e}")
# =====================================================================
banner('PHASE 4 — Execute work orders + audit timing')
# =====================================================================
# Use the first operator we can find. Auto-promotion test only needs
# one completion for verification — N is configurable per role.
operator = env['hr.employee'].search([('active', '=', True)], limit=1)
expect('operator available', bool(operator), operator.name or '')
ok_starts = ok_finishes = 0
for wo in mo.workorder_ids:
try:
if wo.state in ('pending', 'waiting', 'ready'):
wo.button_start()
ok_starts += 1
if wo.state == 'progress':
wo.button_finish()
ok_finishes += 1
except Exception as e:
print(f" [info] WO {wo.name}: {e}")
expect('WOs started', ok_starts > 0, f'{ok_starts} starts')
expect('WOs finished', ok_finishes > 0, f'{ok_finishes} finishes')
# Timer audit fields populated?
audited = mo.workorder_ids.filtered(
lambda w: getattr(w, 'x_fc_started_at_utc', False)
and getattr(w, 'x_fc_finished_at_utc', False)
)
expect('timer audit fields populated',
len(audited) > 0, f'{len(audited)}/{len(mo.workorder_ids)}')
# Mark MO done. Triggers cert + delivery automations.
try:
mo.button_mark_done()
except Exception as e:
print(f" [info] mark_done: {e}")
# Best-effort: bypass produced-qty wizard
try:
mo.qty_producing = mo.product_qty
mo._action_done()
except Exception as e2:
print(f" [info] _action_done: {e2}")
expect('MO state = done', mo.state == 'done', mo.state)
# =====================================================================
banner('PHASE 5 — Certificates (CoC issued + rich PDF, no thickness dup)')
# =====================================================================
certs = env['fp.certificate'].search([('production_id', '=', mo.id)])
coc = certs.filtered(lambda c: c.certificate_type == 'coc')[:1]
thickness = certs.filtered(lambda c: c.certificate_type == 'thickness_report')
expect('CoC certificate created', bool(coc), coc.name if coc else '')
expect('thickness cert SKIPPED (CoC includes thickness)',
len(thickness) == 0, f'thickness count={len(thickness)}')
if coc:
expect('CoC auto-issued (state != draft)',
coc.state != 'draft', f'state={coc.state}')
expect('CoC has attachment', bool(coc.attachment_id),
coc.attachment_id.name if coc.attachment_id else 'none')
if coc.attachment_id:
size_kb = len(base64.b64decode(coc.attachment_id.datas)) / 1024
# Bare-header PDF is ~30KB, rich PDF is 200KB+.
expect('CoC PDF is rich (>= 100KB, not bare header)',
size_kb >= 100, f'{size_kb:.1f}KB')
expect('CoC filename uses customer slug',
cust_name.replace(' ', '_') in coc.attachment_id.name
or 'CoC' in coc.attachment_id.name,
coc.attachment_id.name)
# =====================================================================
banner('PHASE 6 — Delivery auto-prefilled + chain of custody')
# =====================================================================
dlv = env['fusion.plating.delivery'].search(
[('partner_id', '=', customer.id)], order='id desc', limit=1)
expect('delivery auto-created', bool(dlv), dlv.name or '')
if dlv:
expect('scheduled_date prefilled', bool(dlv.scheduled_date),
str(dlv.scheduled_date or 'none'))
# CoC cross-link from MO done hook.
expect('CoC attached to delivery',
bool(dlv.coc_attachment_id),
dlv.coc_attachment_id.name if dlv.coc_attachment_id else 'none')
# Walk through delivery states.
try:
if dlv.state == 'draft':
dlv.action_schedule()
if dlv.state == 'scheduled':
dlv.action_start_delivery()
if dlv.state == 'en_route':
dlv.action_mark_delivered()
except Exception as e:
print(f" [info] delivery transitions: {e}")
expect('delivery delivered', dlv.state == 'delivered', dlv.state)
coc_logs = env['fusion.plating.chain.of.custody'].search(
[('delivery_id', '=', dlv.id)])
expect('chain of custody logged', len(coc_logs) > 0,
f'{len(coc_logs)} entries')
# Portal job should now show shipped.
job = env['fusion.plating.portal.job'].browse(job.id)
expect('portal job state advanced to shipped/complete',
job.state in ('shipped', 'complete'), job.state)
# =====================================================================
banner('PHASE 7 — Invoice creation + post + body has lines')
# =====================================================================
# Let Odoo's standard invoice flow handle it.
try:
inv_act = so._create_invoices()
inv = inv_act if hasattr(inv_act, '_name') else env['account.move'].browse(
inv_act.get('res_id') if isinstance(inv_act, dict) else inv_act
)
except Exception as e:
print(f" [info] _create_invoices: {e}")
inv = env['account.move'].search(
[('invoice_origin', '=', so.name)], limit=1)
expect('invoice created', bool(inv), inv.name or '')
if inv:
inv.invoice_date = fields.Date.today()
try:
inv.action_post()
except Exception as e:
print(f" [info] action_post: {e}")
expect('invoice posted', inv.state == 'posted', inv.state)
# Render the new invoice PDF and confirm body has product lines
# (Odoo 19 display_type='product' fix).
report = env.ref('fusion_plating_reports.action_report_fp_invoice_portrait',
raise_if_not_found=False)
if report:
try:
pdf, _ext = report.with_context(force_report_rendering=True
)._render_qweb_pdf(report.report_name, [inv.id])
kb = len(pdf) / 1024
expect('invoice PDF body is non-empty (>= 50KB)',
kb >= 50, f'{kb:.1f}KB')
except Exception as e:
print(f" [info] invoice render: {e}")
expect('invoice PDF rendered', False, str(e))
# Portal job should now be complete.
expect('portal job state = complete',
job.state == 'complete', job.state)
# =====================================================================
banner('PHASE 8 — Notification log + email attachments')
# =====================================================================
logs = env['fp.notification.log'].search(
[('sale_order_id', '=', so.id)])
events = logs.mapped('trigger_event')
expect('notification log entries written',
len(logs) > 0, f'{len(logs)} entries: {events}')
expect('SO confirmed notification fired',
'so_confirmed' in events,
'present' if 'so_confirmed' in events else 'missing')
# Shipping email should carry the CoC attachment we generated.
shipping_logs = logs.filtered(lambda l: l.trigger_event == 'shipped')
if shipping_logs:
sl = shipping_logs[:1]
expect('shipping email logged',
sl.status == 'sent', sl.status)
if sl.attachment_names:
expect('shipping email has CoC attachment',
'CoC' in (sl.attachment_names or '')
or '.pdf' in (sl.attachment_names or '').lower(),
sl.attachment_names)
# =====================================================================
banner('SUMMARY')
# =====================================================================
passed = sum(1 for _, ok in RESULTS if ok)
failed = sum(1 for _, ok in RESULTS if not ok)
print(f'\n {passed} PASS / {failed} FAIL out of {len(RESULTS)} checks')
print(f' customer: {customer.name}')
print(f' SO: {so.name}')
print(f' MO: {mo.name}')
print(f' job: {job.name}')
print(f' delivery: {dlv.name if dlv else "(none)"}')
print(f' invoice: {inv.name if inv else "(none)"}')
print(f' CoC cert: {coc.name if coc else "(none)"}')
if failed:
print('\n FAILURES:')
for label, ok in RESULTS:
if not ok:
print(f' - {label}')
env.cr.commit()

View File

@@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
# Human-style E2E walkthrough. Run via:
# cat fp_e2e_human.py | odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
# Each STEP is narrated as a person clicking through the UI.
from datetime import datetime, timedelta
import base64
env = env # noqa injected by odoo shell
from odoo import fields # noqa
def step(num, who, action):
bar = '' * 70
print(f'\n{bar}\n STEP {num}{who}\n{action}\n{bar}')
def show(label, value):
print(f' {label:<28} {value}')
def hr():
print(' ' + '' * 64)
# ─────────────────────────────────────────────────────────────────────
# Setup: pick a generic existing customer? No — fresh customer so we
# can prove the full chain of automations on a clean slate.
# ─────────────────────────────────────────────────────────────────────
stamp = datetime.now().strftime('%y%m%d-%H%M%S')
# =====================================================================
step(1, 'CUSTOMER (portal)', 'Submits an RFQ via the customer portal')
# =====================================================================
# A new contact appears the moment a portal user fills the RFQ form.
# In production a portal session would do this; for this walkthrough
# we'll create the partner + RFQ as if the portal accepted it.
customer = env['res.partner'].create({
'name': f'Acme Aerospace {stamp}',
'company_type': 'company',
'email': f'orders-{stamp}@acmeaero.example',
'phone': '+1-416-555-0142',
'street': '88 Queen St E',
'city': 'Toronto', 'zip': 'M5C 1A8',
'country_id': env.ref('base.ca').id,
})
show('new contact', f'{customer.name} (id={customer.id})')
rfq = env['fusion.plating.quote.request'].create({
'partner_id': customer.id,
'contact_name': 'Sandra Kim',
'contact_email': customer.email,
'company_name': customer.name,
'part_description': '<p>25 stainless brackets, AMS 2404, ~50µin ENP.</p>',
'quantity': 25,
'state': 'new',
})
show('RFQ created', f'{rfq.name} (state={rfq.state})')
show('inbox alert', 'Sales sees a new RFQ on Plating > Sales > Quote Requests')
# =====================================================================
step(2, 'SALES (estimator)',
'Reviews RFQ + builds a configurator quote with PO# + customer price')
# =====================================================================
coating = env['fp.coating.config'].search([], limit=1)
part_cat = env['fp.part.catalog'].search([], limit=1)
show('coating template', coating.name if coating else '(none)')
show('part catalog', part_cat.name if part_cat else '(none)')
po_number = f'PO-ACME-{stamp}'
quote = env['fp.quote.configurator'].create({
'partner_id': customer.id,
'part_catalog_id': part_cat.id if part_cat else False,
'coating_config_id': coating.id if coating else False,
'quantity': 25,
'po_number_preliminary': po_number,
'estimator_override_price': 1875.00,
})
show('quote session', f'{quote.name} (state={quote.state})')
show('estimator override', f'${quote.estimator_override_price:,.2f}')
show('calculated price', f'${quote.calculated_price:,.2f} ({quote.currency_id.name})')
show('PO# entered', po_number)
# Sales clicks "Create Quotation" — this is the SO.
result = quote.action_create_quotation()
so = env['sale.order'].browse(result.get('res_id'))
show('SO drafted', f'{so.name} (state={so.state})')
show('SO total', f'${so.amount_total:,.2f}')
show('SO has PO# (client_order_ref)', so.client_order_ref or '(empty)')
show('SO links back to RFQ origin', so.origin or '(empty)')
# =====================================================================
step(3, 'CUSTOMER',
'Reviews the quote PDF, signs / accepts → SO confirmed')
# =====================================================================
# Render the quote PDF the customer would receive.
quote_report = env.ref(
'fusion_plating_reports.action_report_fp_sale_portrait',
raise_if_not_found=False)
if quote_report:
pdf, _e = quote_report.with_context(force_report_rendering=True
)._render_qweb_pdf(quote_report.report_name, [so.id])
show('quote PDF size', f'{len(pdf)/1024:.1f} KB (body must be non-empty)')
so.action_confirm()
show('SO confirmed', f'state={so.state}')
# Auto-MO from our SO-confirm hook.
mo = env['mrp.production'].search([('origin', '=', so.name)], limit=1)
show('MO auto-created', f'{mo.name} (state={mo.state})' if mo else '(MISSING)')
if mo and mo.state == 'draft':
mo.action_confirm()
show('MO confirmed', f'state={mo.state}')
job = mo.x_fc_portal_job_id if mo else env['fusion.plating.portal.job']
show('portal job auto-created',
f'{job.name} (state={job.state})' if job else '(MISSING)')
# =====================================================================
step(4, 'RECEIVING (warehouse)',
'Customer parts arrive — count + log + auto-prefill')
# =====================================================================
recv = env['fp.receiving'].create({
'partner_id': customer.id,
'sale_order_id': so.id,
'received_date': fields.Datetime.now(),
'expected_qty': 25,
'line_ids': [(0, 0, {
'description': '25 stainless brackets, in 2 kraft boxes',
'expected_qty': 25,
'received_qty': 25,
})],
})
show('receiving record', f'{recv.name} (state={recv.state})')
show('expected qty (header)', str(recv.expected_qty))
show('received qty (header)', f'{recv.received_qty} (auto-prefilled from expected_qty)')
show('matches expected', 'YES' if recv.received_qty == recv.expected_qty else 'NO')
# Walk through the inspection lifecycle as the receiver would.
try:
recv.action_start_inspection()
show(' → inspection started', f'state={recv.state}')
recv.action_accept()
show(' → parts accepted', f'state={recv.state}')
except Exception as e:
print(f' [info] inspection: {e}')
# =====================================================================
step(5, 'PLANNER',
'Assigns recipe + generates work orders from process tree')
# =====================================================================
recipe = env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1)
show('recipe template', recipe.name if recipe else '(none)')
if mo and recipe and not mo.x_fc_recipe_id:
mo.x_fc_recipe_id = recipe.id
mo._generate_workorders_from_recipe()
show('WOs generated', f'{len(mo.workorder_ids)} work orders')
for i, wo in enumerate(mo.workorder_ids[:5], 1):
show(f' WO {i}', f'{wo.name} @ {wo.workcenter_id.name}')
if len(mo.workorder_ids) > 5:
show('', f'and {len(mo.workorder_ids) - 5} more')
# =====================================================================
step(6, 'OPERATOR (shop floor)',
'Clocks in, starts each WO, finishes when the bath is done')
# =====================================================================
ok_starts = ok_finishes = 0
for wo in mo.workorder_ids:
try:
if wo.state in ('pending', 'waiting', 'ready'):
wo.button_start()
ok_starts += 1
if wo.state == 'progress':
wo.button_finish()
ok_finishes += 1
except Exception as e:
print(f' [info] WO {wo.name}: {e}')
show('WOs started', ok_starts)
show('WOs finished', ok_finishes)
total_dur = sum(mo.workorder_ids.mapped('duration'))
show('total time logged on WOs', f'{total_dur:.1f} min (Odoo native time tracking)')
# =====================================================================
step(7, 'MANAGER',
'Marks MO done → triggers CoC cert + delivery auto-create')
# =====================================================================
try:
mo.button_mark_done()
except Exception as e:
print(f' [info] mark_done: {e} — falling back to _action_done')
try:
mo.qty_producing = mo.product_qty
mo._action_done()
except Exception as e2:
print(f' [info] _action_done: {e2}')
show('MO state', mo.state)
show('parts location', mo.x_fc_current_location)
certs = env['fp.certificate'].search([('production_id', '=', mo.id)])
coc = certs.filtered(lambda c: c.certificate_type == 'coc')[:1]
thickness = certs.filtered(lambda c: c.certificate_type == 'thickness_report')
show('CoC cert', f'{coc.name} (state={coc.state})' if coc else '(MISSING)')
show('thickness cert', f'count={len(thickness)} (expected 0 — CoC includes it)')
if coc and coc.attachment_id:
kb = len(base64.b64decode(coc.attachment_id.datas)) / 1024
show('CoC PDF', f'{coc.attachment_id.name}{kb:.1f} KB')
show('PDF is rich (>=100 KB, not bare header)',
'YES' if kb >= 100 else 'NO')
# =====================================================================
step(8, 'SHIPPER (dispatcher + driver)',
'Schedules → driver picks up → marks delivered')
# =====================================================================
dlv = env['fusion.plating.delivery'].search(
[('partner_id', '=', customer.id)], order='id desc', limit=1)
show('delivery auto-created', f'{dlv.name} (state={dlv.state})' if dlv else '(MISSING)')
if dlv:
show(' scheduled date prefilled', str(dlv.scheduled_date or '(empty)'))
show(' driver prefilled',
dlv.assigned_driver_id.name if dlv.assigned_driver_id else '(none)')
show(' CoC cross-linked to delivery',
dlv.coc_attachment_id.name if dlv.coc_attachment_id else '(none)')
# Walk through the lifecycle as the driver / dispatcher would.
try:
if dlv.state == 'draft': dlv.action_schedule(); show(' → scheduled', dlv.state)
if dlv.state == 'scheduled': dlv.action_start_route(); show(' → en route', dlv.state)
if dlv.state == 'en_route': dlv.action_mark_delivered(); show(' → delivered', dlv.state)
except Exception as e:
print(f' [info] delivery transitions: {e}')
coc_logs = env['fusion.plating.chain.of.custody'].search(
[('delivery_id', '=', dlv.id)])
show('chain-of-custody entries', len(coc_logs))
# Portal job should have moved to shipped.
job = env['fusion.plating.portal.job'].browse(job.id)
show('portal job state', job.state)
# =====================================================================
step(9, 'ACCOUNTING',
'Creates + posts invoice (does NOT register payment — '
'real customer pays through bank/Stripe)')
# =====================================================================
try:
inv_act = so._create_invoices()
inv = inv_act if hasattr(inv_act, '_name') else env['account.move'].browse(
inv_act.get('res_id') if isinstance(inv_act, dict) else inv_act)
except Exception as e:
print(f' [info] _create_invoices: {e}')
inv = env['account.move'].search(
[('invoice_origin', '=', so.name)], limit=1)
show('invoice created', f'{inv.name} (state={inv.state})' if inv else '(MISSING)')
if inv:
inv.invoice_date = fields.Date.today()
try:
inv.action_post()
except Exception as e:
print(f' [info] action_post: {e}')
show('invoice posted', f'state={inv.state}, payment_state={inv.payment_state}')
show('total', f'${inv.amount_total:,.2f}')
show('payment_state explanation',
'"not_paid" is correct — accountant has not yet registered any payment')
inv_report = env.ref(
'fusion_plating_reports.action_report_fp_invoice_portrait',
raise_if_not_found=False)
if inv_report:
try:
pdf, _e = inv_report.with_context(force_report_rendering=True
)._render_qweb_pdf(inv_report.report_name, [inv.id])
kb = len(pdf) / 1024
show('invoice PDF size', f'{kb:.1f} KB')
show('PDF body has line items (>=50 KB, not empty)',
'YES' if kb >= 50 else 'NO')
except Exception as e:
print(f' [info] invoice render: {e}')
show('portal job state (after invoice)',
env['fusion.plating.portal.job'].browse(job.id).state)
# =====================================================================
step(10, 'AUDIT',
'What the system logged for this customer')
# =====================================================================
logs = env['fp.notification.log'].search(
[('sale_order_id', '=', so.id)], order='create_date')
show('notification log entries', len(logs))
for l in logs:
show(f'{l.trigger_event}',
f'{l.status} | to {l.recipient_email or "(no email)"} | '
f'attached: {l.attachment_names or "(none)"}')
print('\n ════════════════════════════════════════════════════════════════════')
print(' END-TO-END SUMMARY')
print(' ════════════════════════════════════════════════════════════════════')
show('customer', customer.name)
show('RFQ', rfq.name)
show('SO', f'{so.name} (PO: {so.client_order_ref})')
show('MO', f'{mo.name}{mo.state}')
show('receiving', recv.name)
show('CoC cert', coc.name if coc else '(none)')
show('delivery', f'{dlv.name}{dlv.state}' if dlv else '(none)')
show('invoice', f'{inv.name} → posted={inv.state == "posted"}, '
f'paid={inv.payment_state == "paid"}' if inv else '(none)')
show('portal job', f'{job.name} → final state: '
f'{env["fusion.plating.portal.job"].browse(job.id).state}')
env.cr.commit()
print('\n Changes committed. Order completed end-to-end.\n')