diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py index 4350feed..ba877e1b 100644 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py @@ -88,6 +88,10 @@ class FpReceiving(models.Model): for vals in vals_list: if vals.get('name', 'New') == '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) # ------------------------------------------------------------------------- diff --git a/fusion_plating/scripts/fp_e2e_full.py b/fusion_plating/scripts/fp_e2e_full.py new file mode 100644 index 00000000..299685cd --- /dev/null +++ b/fusion_plating/scripts/fp_e2e_full.py @@ -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() diff --git a/fusion_plating/scripts/fp_e2e_human.py b/fusion_plating/scripts/fp_e2e_human.py new file mode 100644 index 00000000..505df683 --- /dev/null +++ b/fusion_plating/scripts/fp_e2e_human.py @@ -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': '
25 stainless brackets, AMS 2404, ~50µin ENP.
', + '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')