396 lines
17 KiB
Python
396 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
# E2E persona walk — order entry from start to finish.
|
||
#
|
||
# Personas:
|
||
# Sarah — Customer Service Rep
|
||
# Mike — Receiver
|
||
# Carlos — Plating Operator
|
||
# Lisa — QC Inspector
|
||
# Tom — Shipper
|
||
# Jane — Accounting
|
||
#
|
||
# This script fills every visible-to-operator field per step, walks the
|
||
# workflow with no shortcuts, asserts the data is sane after each phase,
|
||
# and prints what's actually visible in each form view.
|
||
|
||
import logging
|
||
import traceback
|
||
from datetime import date, timedelta
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
|
||
def section(title):
|
||
print(f'\n{"="*72}\n{title}\n{"="*72}')
|
||
|
||
|
||
def step(persona, msg):
|
||
print(f' [{persona:>7}] {msg}')
|
||
|
||
|
||
def fail(persona, msg):
|
||
print(f' [{persona:>7}] ❌ {msg}')
|
||
|
||
|
||
def find(persona, msg):
|
||
print(f' [{persona:>7}] 🔍 GAP: {msg}')
|
||
|
||
|
||
def e2e(env):
|
||
findings = []
|
||
|
||
# ----- pick a real partner with a recipe-able product -----
|
||
section('SETUP — pick a customer + a part already in the catalog')
|
||
Partner = env['res.partner']
|
||
Part = env['fp.part.catalog']
|
||
Coating = env['fp.coating.config']
|
||
partner = Partner.search([
|
||
('customer_rank', '>', 0),
|
||
('x_fc_account_hold', '=', False),
|
||
], limit=1)
|
||
if not partner:
|
||
partner = Partner.search([('customer_rank', '>', 0)], limit=1)
|
||
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1) \
|
||
or Part.search([], limit=1)
|
||
coating = part.x_fc_default_coating_config_id \
|
||
if part.x_fc_default_coating_config_id \
|
||
else Coating.search([], limit=1)
|
||
step('Sarah', f'Customer: {partner.display_name} (id={partner.id})')
|
||
step('Sarah', f'Part: {part.part_number or part.name} rev {part.revision or "?"} (id={part.id})')
|
||
step('Sarah', f'Coating: {coating.display_name if coating else "NONE"} (id={coating.id if coating else 0})')
|
||
if not coating:
|
||
findings.append('No fp.coating.config found in DB — cannot create realistic SO')
|
||
return findings
|
||
|
||
# ----- Sarah builds a sale order -----
|
||
section('PHASE 1 — Sarah (CSR) creates the sale order')
|
||
SO = env['sale.order']
|
||
SOL = env['sale.order.line']
|
||
so_vals = {
|
||
'partner_id': partner.id,
|
||
'x_fc_po_number': f'PO-E2E-{date.today():%y%m%d}',
|
||
'x_fc_customer_job_number': 'CUSTJOB-001',
|
||
'x_fc_contact_phone': '+1-555-0100',
|
||
'x_fc_ship_via': 'Customer pickup',
|
||
'x_fc_planned_start_date': date.today() + timedelta(days=2),
|
||
'x_fc_internal_deadline': date.today() + timedelta(days=10),
|
||
'commitment_date': date.today() + timedelta(days=14),
|
||
'x_fc_invoice_strategy': 'net_terms',
|
||
'x_fc_delivery_method': 'shipping_partner',
|
||
'x_fc_rush_order': False,
|
||
'x_fc_is_blanket_order': False,
|
||
'x_fc_internal_note': 'E2E test SO — full persona walk.',
|
||
'x_fc_external_note': 'Standard plating per spec.',
|
||
}
|
||
so = SO.create(so_vals)
|
||
step('Sarah', f'Created SO {so.name} (id={so.id})')
|
||
|
||
# add a line — fill the part / coating / treatment fields
|
||
product = env['product.product'].search([('sale_ok', '=', True)], limit=1)
|
||
if not product:
|
||
findings.append('No saleable product available for SO line')
|
||
return findings
|
||
line_vals = {
|
||
'order_id': so.id,
|
||
'product_id': product.id,
|
||
'product_uom_qty': 25,
|
||
'name': f'{part.part_number or part.name} — Plating per coating spec',
|
||
'x_fc_part_catalog_id': part.id,
|
||
'x_fc_coating_config_id': coating.id,
|
||
'x_fc_internal_description': 'Process via standard recipe; bake ASAP.',
|
||
'x_fc_job_number': 'INTJOB-001',
|
||
}
|
||
line = SOL.create(line_vals)
|
||
step('Sarah', f'Added line: {line.product_uom_qty} × {line.name[:40]}')
|
||
|
||
# confirm — does account hold block?
|
||
if partner.x_fc_account_hold:
|
||
find('Sarah', 'Customer is on account hold; SO confirm should block (or warn)')
|
||
try:
|
||
so.action_confirm()
|
||
step('Sarah', f'SO confirmed → state={so.state}')
|
||
except Exception as e:
|
||
fail('Sarah', f'SO confirm raised: {e}')
|
||
findings.append(f'SO confirm failure: {e}')
|
||
return findings
|
||
|
||
# ----- side effects: fp.job created? receiving created? -----
|
||
Job = env['fp.job']
|
||
Receiving = env['fp.receiving']
|
||
PortalJob = env['fusion.plating.portal.job']
|
||
jobs = Job.search([('sale_order_id', '=', so.id)])
|
||
receivings = Receiving.search([('sale_order_id', '=', so.id)])
|
||
portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
|
||
step('Sarah', f'After confirm: {len(jobs)} fp.job, {len(receivings)} fp.receiving, {len(portal_jobs)} portal.job')
|
||
if not jobs:
|
||
find('Sarah', 'NO fp.job auto-created on SO confirm! Operator has nothing to work.')
|
||
findings.append('SO confirm did not auto-spawn fp.job')
|
||
if not receivings:
|
||
find('Sarah', 'NO fp.receiving auto-created on SO confirm! Receiver has nothing to track.')
|
||
findings.append('SO confirm did not auto-spawn fp.receiving')
|
||
if jobs and not portal_jobs:
|
||
find('Sarah', 'fp.job exists but no portal.job mirror — customer can\'t track on portal.')
|
||
findings.append('Portal job mirror missing post-confirm')
|
||
|
||
# smart-button visibility check
|
||
so._compute_smart_button_visibility()
|
||
so._compute_fp_qc_counts()
|
||
step('Sarah', f'SO smart buttons: BOM Items visible? {so.x_fc_distinct_part_count >= 2} (count={so.x_fc_distinct_part_count}); '
|
||
f'By Job Group visible? {so.x_fc_has_wo_group_tag}; '
|
||
f'NCRs visible? {so.fp_qc_ncr_count_so > 0} (count={so.fp_qc_ncr_count_so})')
|
||
|
||
# ----- Mike receives parts -----
|
||
section('PHASE 2 — Mike (Receiver) processes inbound parts')
|
||
receiving = receivings[:1]
|
||
if not receiving:
|
||
receiving = Receiving.create({
|
||
'sale_order_id': so.id,
|
||
'expected_qty': 25,
|
||
})
|
||
step('Mike', f'Manually created receiving {receiving.name} (auto-create did not fire)')
|
||
find('Mike', 'Had to manually create receiving — auto-create from SO confirm is missing')
|
||
findings.append('Auto-receiving on SO confirm not wired')
|
||
else:
|
||
step('Mike', f'Found auto-created receiving {receiving.name} (state={receiving.state})')
|
||
|
||
# operator fills carrier + box count
|
||
receiving.write({
|
||
'carrier_name': 'Purolator Ground',
|
||
'carrier_tracking': 'PUR-1Z9999E2E',
|
||
'box_count_in': 3,
|
||
'received_qty': 25,
|
||
})
|
||
step('Mike', f'Set box_count_in={receiving.box_count_in}, carrier={receiving.carrier_name}')
|
||
|
||
# walk the state machine: draft → counted → staged → closed
|
||
try:
|
||
receiving.action_mark_counted()
|
||
step('Mike', f'Marked Counted → state={receiving.state}, SO status={so.x_fc_receiving_status}')
|
||
assert receiving.state == 'counted'
|
||
assert so.x_fc_receiving_status == 'partial', f'Expected partial after Counted, got {so.x_fc_receiving_status}'
|
||
except AssertionError as e:
|
||
fail('Mike', str(e))
|
||
findings.append(f'Receiving status mismatch after Counted: {e}')
|
||
except Exception as e:
|
||
fail('Mike', f'action_mark_counted failed: {e}')
|
||
findings.append(f'action_mark_counted: {e}')
|
||
|
||
try:
|
||
receiving.action_mark_staged()
|
||
step('Mike', f'Marked Staged → state={receiving.state}, SO status={so.x_fc_receiving_status}')
|
||
assert receiving.state == 'staged'
|
||
assert so.x_fc_receiving_status == 'partial'
|
||
except Exception as e:
|
||
fail('Mike', f'action_mark_staged failed: {e}')
|
||
findings.append(f'action_mark_staged: {e}')
|
||
|
||
try:
|
||
receiving.action_close()
|
||
step('Mike', f'Closed receiving → state={receiving.state}, SO status={so.x_fc_receiving_status}')
|
||
assert receiving.state == 'closed'
|
||
assert so.x_fc_receiving_status == 'received'
|
||
except Exception as e:
|
||
fail('Mike', f'action_close failed: {e}')
|
||
findings.append(f'receiving action_close: {e}')
|
||
|
||
# racking inspection should exist
|
||
if 'fp.racking.inspection' in env:
|
||
Inspection = env['fp.racking.inspection']
|
||
racks = Inspection.search([('sale_order_id', '=', so.id)])
|
||
step('Mike', f'Racking inspections for this SO: {len(racks)}')
|
||
if not racks:
|
||
find('Mike', 'Racking inspection NOT auto-created — racking crew has nothing to walk.')
|
||
findings.append('No racking inspection auto-created post-confirm')
|
||
|
||
# ----- Carlos works the plating job -----
|
||
section('PHASE 3 — Carlos (Operator) walks the plating job')
|
||
if not jobs:
|
||
fail('Carlos', 'No job to work — SO confirm did not spawn one. Skipping phase.')
|
||
else:
|
||
job = jobs[0]
|
||
step('Carlos', f'Job {job.name}: state={job.state}, qty={job.qty}, deadline={job.date_deadline}')
|
||
step('Carlos', f'Steps: {len(job.step_ids)} — recipe={job.recipe_id.name or "(none)"}')
|
||
if not job.step_ids:
|
||
find('Carlos', f'Job has zero steps! Recipe not assigned or not generated. Recipe field: {job.recipe_id}')
|
||
findings.append('Job confirmed with zero steps')
|
||
|
||
if job.step_ids:
|
||
first_step = job.step_ids.sorted('sequence')[0]
|
||
step('Carlos', f'Starting step {first_step.sequence}: {first_step.name}')
|
||
try:
|
||
first_step.button_start()
|
||
step('Carlos', f'After start: state={first_step.state}, started_by={first_step.started_by_user_id.name if first_step.started_by_user_id else "(none)"}')
|
||
except Exception as e:
|
||
fail('Carlos', f'button_start failed: {e}')
|
||
findings.append(f'step button_start: {e}')
|
||
|
||
try:
|
||
first_step.button_finish()
|
||
step('Carlos', f'After finish: state={first_step.state}, duration_actual={first_step.duration_actual}')
|
||
except Exception as e:
|
||
fail('Carlos', f'button_finish failed: {e}')
|
||
findings.append(f'step button_finish: {e}')
|
||
|
||
# walk the rest at warp speed
|
||
for s in job.step_ids.sorted('sequence')[1:]:
|
||
try:
|
||
if s.state == 'pending':
|
||
s.button_start()
|
||
if s.state == 'in_progress':
|
||
s.button_finish()
|
||
except Exception as e:
|
||
fail('Carlos', f'step {s.name} walk: {e}')
|
||
findings.append(f'step walk {s.name}: {e}')
|
||
done_count = len(job.step_ids.filtered(lambda st: st.state == 'done'))
|
||
step('Carlos', f'Walked {done_count}/{len(job.step_ids)} steps to done')
|
||
|
||
# try to mark job done — should hit QC gate if customer requires QC
|
||
wants_qc = 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc
|
||
step('Carlos', f'Customer requires QC? {wants_qc}')
|
||
try:
|
||
job.button_mark_done()
|
||
step('Carlos', f'Job done → state={job.state}, finished={job.date_finished}')
|
||
except Exception as e:
|
||
if wants_qc:
|
||
step('Carlos', f'(Expected) QC gate fired: {str(e)[:120]}')
|
||
else:
|
||
fail('Carlos', f'button_mark_done unexpectedly failed: {e}')
|
||
findings.append(f'button_mark_done: {e}')
|
||
|
||
# ----- Lisa runs QC -----
|
||
section('PHASE 4 — Lisa (QC) walks the checklist (if any)')
|
||
QC = env['fusion.plating.quality.check']
|
||
qcs = QC.search([('job_id', 'in', jobs.ids)]) if jobs else QC.browse()
|
||
step('Lisa', f'QC checks for this job: {len(qcs)}')
|
||
if jobs and 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc and not qcs:
|
||
find('Lisa', 'Customer requires QC but no QC check auto-spawned!')
|
||
findings.append('QC gate fired but no check spawned')
|
||
for qc in qcs:
|
||
step('Lisa', f'QC {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
|
||
# try to pass it
|
||
for ln in qc.line_ids:
|
||
try:
|
||
ln.write({'result': 'pass'})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
qc.action_pass()
|
||
step('Lisa', f'After action_pass: state={qc.state}')
|
||
except Exception as e:
|
||
fail('Lisa', f'action_pass failed: {e}')
|
||
findings.append(f'qc action_pass: {e}')
|
||
|
||
# retry job done if blocked
|
||
if jobs:
|
||
job = jobs[0]
|
||
if job.state != 'done':
|
||
try:
|
||
job.button_mark_done()
|
||
step('Lisa', f'Job marked done after QC pass → state={job.state}')
|
||
except Exception as e:
|
||
fail('Lisa', f'Job still blocked: {e}')
|
||
findings.append(f'Job blocked post-QC: {e}')
|
||
|
||
# ----- Tom ships -----
|
||
section('PHASE 5 — Tom (Shipper) prepares the delivery')
|
||
Delivery = env['fusion.plating.delivery']
|
||
deliveries = Delivery.search([
|
||
'|', ('job_ref', 'in', jobs.mapped('name') if jobs else []),
|
||
('x_fc_job_id', 'in', jobs.ids) if jobs else (False, False, False),
|
||
]) if jobs else Delivery.browse()
|
||
step('Tom', f'Deliveries linked to this job: {len(deliveries)}')
|
||
if jobs and jobs[0].state == 'done' and not deliveries:
|
||
find('Tom', 'Job is done but NO delivery auto-created!')
|
||
findings.append('Delivery auto-create on job done missing')
|
||
for delivery in deliveries:
|
||
method = (
|
||
getattr(delivery, 'x_fc_delivery_method', None)
|
||
or getattr(delivery, 'delivery_method', None)
|
||
or '(no method field)'
|
||
)
|
||
step('Tom', f'Delivery {delivery.name}: state={delivery.state}, method={method}')
|
||
try:
|
||
if hasattr(delivery, 'action_schedule') and delivery.state == 'draft':
|
||
delivery.action_schedule()
|
||
step('Tom', f'Scheduled → state={delivery.state}')
|
||
except Exception as e:
|
||
fail('Tom', f'schedule: {e}')
|
||
|
||
# certificates
|
||
Cert = env['fp.certificate']
|
||
certs = Cert.search([('x_fc_job_id', 'in', jobs.ids)]) if jobs else Cert.browse()
|
||
step('Tom', f'Certificates for this job: {len(certs)}')
|
||
if jobs and jobs[0].state == 'done' and not certs:
|
||
find('Tom', 'Job done but NO certificate auto-generated.')
|
||
findings.append('Certificate auto-create missing')
|
||
|
||
# ----- Jane invoices -----
|
||
section('PHASE 6 — Jane (Accounting) creates and posts invoice')
|
||
invoices_before = env['account.move'].search_count([
|
||
('invoice_origin', '=', so.name),
|
||
])
|
||
try:
|
||
if so.invoice_status == 'to invoice':
|
||
inv_action = so._create_invoices()
|
||
step('Jane', f'Invoiced — {invoices_before} → {env["account.move"].search_count([("invoice_origin","=",so.name)])} moves')
|
||
else:
|
||
step('Jane', f'invoice_status={so.invoice_status} (nothing to invoice)')
|
||
except Exception as e:
|
||
fail('Jane', f'_create_invoices failed: {e}')
|
||
findings.append(f'invoice creation: {e}')
|
||
|
||
# ----- common-sense edge case sweeps -----
|
||
section('PHASE 7 — common-sense edge case sweeps')
|
||
|
||
# smart-button results: do they actually return non-empty data?
|
||
section_name = ' smart-button result probes'
|
||
print(section_name)
|
||
if jobs:
|
||
job = jobs[0]
|
||
for action in ('action_view_fp_holds', 'action_view_fp_checks',
|
||
'action_view_fp_ncrs', 'action_view_fp_capas',
|
||
'action_view_fp_rmas'):
|
||
try:
|
||
act = getattr(job, action)()
|
||
domain = act.get('domain') or []
|
||
model = act.get('res_model')
|
||
count = env[model].search_count(domain) if model else 0
|
||
step('audit', f'{action}: model={model}, domain count={count}')
|
||
except Exception as e:
|
||
fail('audit', f'{action}: {e}')
|
||
findings.append(f'{action}: {e}')
|
||
|
||
# SO smart-buttons
|
||
for action in ('action_view_fp_holds', 'action_view_fp_checks',
|
||
'action_view_fp_ncrs_so', 'action_view_fp_capas',
|
||
'action_view_fp_rmas'):
|
||
try:
|
||
act = getattr(so, action)()
|
||
domain = act.get('domain') or []
|
||
model = act.get('res_model')
|
||
count = env[model].search_count(domain) if model else 0
|
||
step('audit', f'SO {action}: model={model}, domain count={count}')
|
||
except Exception as e:
|
||
fail('audit', f'SO {action}: {e}')
|
||
findings.append(f'SO {action}: {e}')
|
||
|
||
# final summary
|
||
section('SUMMARY')
|
||
if findings:
|
||
print(f' ❌ {len(findings)} finding(s):')
|
||
for i, f in enumerate(findings, 1):
|
||
print(f' {i}. {f}')
|
||
else:
|
||
print(' ✅ No findings — workflow is clean end-to-end.')
|
||
|
||
env.cr.commit()
|
||
return findings
|
||
|
||
|
||
# entry-point: env injected by odoo-shell
|
||
try:
|
||
findings = e2e(env) # noqa
|
||
except Exception as e:
|
||
print('FATAL:', e)
|
||
traceback.print_exc()
|