400 lines
17 KiB
Python
400 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
# End-to-end order walkthrough — simulates each role on the shop floor.
|
|
#
|
|
# Run via odoo-shell:
|
|
# echo 'exec(open("/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_e2e_walkthrough.py").read())' \
|
|
# | /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
|
|
#
|
|
# Each step prints what the employee would see / type. Failures and
|
|
# missing affordances are printed with [GAP] tags.
|
|
|
|
import logging
|
|
|
|
_log = logging.getLogger(__name__)
|
|
GAPS = []
|
|
|
|
|
|
def gap(role, where, msg):
|
|
GAPS.append((role, where, msg))
|
|
print(f' [GAP] {role} @ {where}: {msg}')
|
|
|
|
|
|
def walk():
|
|
e = env # noqa -- env injected by odoo-shell
|
|
print('====================== E2E ORDER WALKTHROUGH ======================')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Sales / Estimator — open Plating > Sales > Quotations
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Estimator] Plating > Sales > Quotations > New Quote')
|
|
|
|
# 1. Pick or create a customer
|
|
Partner = e['res.partner']
|
|
customer = Partner.search([('customer_rank', '>', 0)], limit=1)
|
|
if not customer:
|
|
gap('Estimator', 'res.partner', 'No customers in DB at all')
|
|
return
|
|
print(f' Customer chosen: {customer.display_name} (id={customer.id})')
|
|
|
|
# 2. Pick a part from the catalog (or create on the fly)
|
|
Part = e.get('fp.part.catalog') or (
|
|
e['fp.part.catalog'] if 'fp.part.catalog' in e else None
|
|
)
|
|
if Part is None or 'fp.part.catalog' not in e:
|
|
gap('Estimator', 'fp.part.catalog', 'Part catalog model missing')
|
|
return
|
|
Part = e['fp.part.catalog']
|
|
part = Part.search([], limit=1)
|
|
if not part:
|
|
gap('Estimator', 'fp.part.catalog',
|
|
'No parts in catalog — estimator has nothing to quote against')
|
|
return
|
|
print(f' Part chosen: {part.display_name} '
|
|
f'(part#={getattr(part, "part_number", "?")} '
|
|
f'rev={getattr(part, "revision", "?")})')
|
|
|
|
# 2a. Required-field walk (Sub 2 made part_number + revision required)
|
|
for f in ('part_number', 'revision', 'name'):
|
|
if f not in part._fields:
|
|
gap('Estimator', f'fp.part.catalog.{f}', 'field missing')
|
|
elif not part[f]:
|
|
gap('Estimator', f'fp.part.catalog.{f}',
|
|
f'value blank on existing record')
|
|
|
|
# 3. Pick a coating config
|
|
if 'fp.coating.config' not in e:
|
|
gap('Estimator', 'fp.coating.config', 'coating config model missing')
|
|
return
|
|
coating = e['fp.coating.config'].search([], limit=1)
|
|
if not coating:
|
|
gap('Estimator', 'fp.coating.config',
|
|
'No coating configs defined — estimator cannot configure quote')
|
|
else:
|
|
print(f' Coating chosen: {coating.display_name}')
|
|
|
|
# 4. Try to create a quote configurator session (the "New Quote" wizard)
|
|
if 'fp.quote.configurator' not in e:
|
|
gap('Estimator', 'fp.quote.configurator', 'configurator model missing')
|
|
return
|
|
Configurator = e['fp.quote.configurator']
|
|
cfg_vals = {
|
|
'partner_id': customer.id,
|
|
}
|
|
if 'part_catalog_id' in Configurator._fields and part:
|
|
cfg_vals['part_catalog_id'] = part.id
|
|
if 'coating_config_id' in Configurator._fields and coating:
|
|
cfg_vals['coating_config_id'] = coating.id
|
|
try:
|
|
cfg = Configurator.create(cfg_vals)
|
|
print(f' ✓ Configurator session created: {cfg.display_name}')
|
|
except Exception as ex:
|
|
gap('Estimator', 'fp.quote.configurator.create', str(ex))
|
|
return
|
|
|
|
# 4a. Try the "Create Quotation" path — what action confirms the SO?
|
|
so = False
|
|
for meth in ('action_create_quotation', 'action_promote_to_direct_order',
|
|
'action_create_sale_order', 'action_generate_quote'):
|
|
if hasattr(cfg, meth):
|
|
try:
|
|
result = getattr(cfg, meth)()
|
|
so = (
|
|
e['sale.order'].browse(result.get('res_id'))
|
|
if isinstance(result, dict) and result.get('res_id')
|
|
else (cfg.x_fc_sale_order_id if 'x_fc_sale_order_id' in cfg._fields else False)
|
|
)
|
|
print(f' ✓ Quote created via {meth}: '
|
|
f'{so.name if so else "(no SO returned)"}')
|
|
break
|
|
except Exception as ex:
|
|
gap('Estimator', f'configurator.{meth}', str(ex))
|
|
if not so:
|
|
# Fall back: create SO directly and see if the configurator workflow is wired.
|
|
gap('Estimator', 'configurator',
|
|
'No working "create quote" action found on the configurator '
|
|
'— estimator has no button to make a quote')
|
|
# Manual SO creation for the rest of the walkthrough
|
|
SO = e['sale.order']
|
|
try:
|
|
so = SO.create({
|
|
'partner_id': customer.id,
|
|
'order_line': [(0, 0, {
|
|
'product_id': (
|
|
e['product.product'].search(
|
|
[('sale_ok', '=', True)], limit=1).id
|
|
),
|
|
'product_uom_qty': 10,
|
|
})],
|
|
})
|
|
print(f' Fallback: hand-created SO {so.name}')
|
|
except Exception as ex:
|
|
gap('Estimator', 'sale.order.create (fallback)', str(ex))
|
|
return
|
|
|
|
# 5. Customer-facing fields on the SO line
|
|
if so.order_line:
|
|
line = so.order_line[0]
|
|
for f in ('x_fc_internal_description', 'x_fc_part_catalog_id',
|
|
'x_fc_coating_config_id', 'x_fc_thickness_id',
|
|
'x_fc_serial_id', 'x_fc_job_number'):
|
|
if f not in line._fields:
|
|
gap('Estimator', f'sale.order.line.{f}',
|
|
'expected field missing')
|
|
print(f' SO header fields: po={so.x_fc_po_number or "(blank)"}, '
|
|
f'invoice_strategy={so.x_fc_invoice_strategy}, '
|
|
f'rush={so.x_fc_rush_order}')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Estimator confirms the quote → SO
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Estimator] Click Confirm on the quote')
|
|
# Estimator types in the customer PO# (real flow: paste from email)
|
|
if 'x_fc_po_number' in so._fields and not so.x_fc_po_number:
|
|
so.x_fc_po_number = 'TEST-PO-E2E-001'
|
|
print(f' Set x_fc_po_number=TEST-PO-E2E-001 on {so.name}')
|
|
if so.state == 'draft':
|
|
try:
|
|
so.action_confirm()
|
|
print(f' ✓ SO confirmed — state={so.state}')
|
|
except Exception as ex:
|
|
gap('Estimator', 'sale.order.action_confirm', str(ex))
|
|
return
|
|
else:
|
|
print(f' SO already in state {so.state}')
|
|
|
|
# 5a. Confirm side-effects fired
|
|
Job = e['fp.job']
|
|
jobs = Job.search([('sale_order_id', '=', so.id)])
|
|
if not jobs:
|
|
gap('Planner', 'fp.job auto-create',
|
|
'No fp.job auto-created on SO confirm — planner has nothing '
|
|
'to plan against')
|
|
else:
|
|
print(f' ✓ {len(jobs)} fp.job(s) created: '
|
|
f'{", ".join(jobs.mapped("name"))}')
|
|
|
|
# 5b. Receiving record auto-created?
|
|
Recv = e['fp.receiving']
|
|
receivings = Recv.search([('sale_order_id', '=', so.id)])
|
|
if not receivings:
|
|
gap('Receiver', 'fp.receiving auto-create',
|
|
'No fp.receiving auto-created on SO confirm — receiver has '
|
|
'nothing to count against')
|
|
else:
|
|
print(f' ✓ Receiving record(s): {", ".join(receivings.mapped("name"))}')
|
|
|
|
# 5c. Racking inspection auto-created on job confirm?
|
|
Insp = e['fp.racking.inspection']
|
|
insps = Insp.search([('sale_order_id', '=', so.id)])
|
|
if not insps and jobs:
|
|
gap('Racker', 'fp.racking.inspection auto-create',
|
|
'jobs exist but no racking inspection — racker walks empty')
|
|
elif insps:
|
|
print(f' ✓ Racking inspection(s): '
|
|
f'{", ".join(insps.mapped("name"))}')
|
|
|
|
# 5d. Portal job mirror auto-created?
|
|
PJ = e['fusion.plating.portal.job']
|
|
pjs = PJ.search([('partner_id', '=', customer.id)],
|
|
order='id desc', limit=2)
|
|
if pjs:
|
|
print(f' ✓ Portal job(s) for customer: '
|
|
f'{", ".join(pjs.mapped("name"))}')
|
|
else:
|
|
gap('Portal', 'portal job auto-create',
|
|
'No portal.job mirror — customer sees nothing on portal')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Receiver — Plating > Receiving > All Receiving
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Receiver] Open the receiving record, count boxes')
|
|
if receivings:
|
|
r = receivings[0]
|
|
if 'box_count_in' not in r._fields:
|
|
gap('Receiver', 'fp.receiving.box_count_in', 'field missing')
|
|
else:
|
|
r.box_count_in = 3
|
|
print(f' Set box_count_in=3 on {r.name}')
|
|
if hasattr(r, 'action_mark_counted'):
|
|
try:
|
|
r.action_mark_counted()
|
|
print(f' ✓ Marked counted — state={r.state}')
|
|
except Exception as ex:
|
|
gap('Receiver', 'action_mark_counted', str(ex))
|
|
else:
|
|
gap('Receiver', 'fp.receiving',
|
|
'no action_mark_counted button')
|
|
if hasattr(r, 'action_mark_staged'):
|
|
try:
|
|
r.action_mark_staged()
|
|
print(f' ✓ Marked staged — state={r.state}')
|
|
except Exception as ex:
|
|
gap('Receiver', 'action_mark_staged', str(ex))
|
|
# Smart button to racking inspection?
|
|
if 'racking_inspection_count' in r._fields:
|
|
print(f' ✓ Receiving form shows '
|
|
f'{r.racking_inspection_count} racking inspection(s)')
|
|
else:
|
|
gap('Receiver', 'fp.receiving.racking_inspection_count',
|
|
'no smart button; receiver navigates manually')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Racking Crew — open the linked racking inspection
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Racker] Open the racking inspection from receiving smart button')
|
|
if insps:
|
|
insp = insps[0]
|
|
# Real fields are line_count / ok_count / flagged_count (not "parts_*")
|
|
for f in ('line_count', 'ok_count', 'flagged_count', 'has_variance'):
|
|
if f not in insp._fields:
|
|
gap('Racker', f'fp.racking.inspection.{f}',
|
|
f'expected field missing')
|
|
# Real workflow: draft → inspecting (action_start) → done (action_complete)
|
|
if hasattr(insp, 'action_start'):
|
|
try:
|
|
insp.action_start()
|
|
print(f' ✓ Inspection started — state={insp.state}')
|
|
except Exception as ex:
|
|
gap('Racker', 'racking_inspection.action_start', str(ex))
|
|
if hasattr(insp, 'action_complete'):
|
|
try:
|
|
insp.action_complete()
|
|
print(f' ✓ Inspection completed — state={insp.state}')
|
|
except Exception as ex:
|
|
gap('Racker', 'racking_inspection.action_complete', str(ex))
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Operator — runs the plating job step-by-step
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Operator] Open the job, run each step')
|
|
if jobs:
|
|
job = jobs[0]
|
|
steps = job.step_ids.sorted('sequence')
|
|
if not steps:
|
|
gap('Operator', 'fp.job.step_ids',
|
|
'job has no steps — recipe not generated')
|
|
else:
|
|
print(f' Job {job.name} has {len(steps)} steps')
|
|
ran = 0
|
|
for step in steps[:3]: # walk the first 3
|
|
if step.state in ('ready', 'paused') and hasattr(step, 'button_start'):
|
|
try:
|
|
step.button_start()
|
|
step.button_finish()
|
|
ran += 1
|
|
except Exception as ex:
|
|
gap('Operator', f'step.{step.name}', str(ex))
|
|
else:
|
|
gap('Operator', f'step.{step.name}',
|
|
f"state={step.state} — operator can't start it")
|
|
print(f' ✓ Ran {ran} of 3 first steps')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Inspector — walk the QC checklist if customer requires QC
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Inspector] Look for an open QC check on the job')
|
|
QC = e['fusion.plating.quality.check']
|
|
if jobs:
|
|
job = jobs[0]
|
|
# Customer might not be flagged x_fc_requires_qc — flip it for the test.
|
|
wants = ('x_fc_requires_qc' in customer._fields
|
|
and customer.x_fc_requires_qc)
|
|
print(f' Customer requires QC: {wants}')
|
|
if wants:
|
|
check = QC.search([('job_id', '=', job.id)], limit=1)
|
|
if not check:
|
|
gap('Inspector', 'QC.create_for_job',
|
|
'customer wants QC but no check was auto-spawned on confirm')
|
|
else:
|
|
print(f' ✓ QC check found: {check.name}')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Operator — try to mark job done (will hit QC gate if applicable)
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Operator] Click Mark Done on the job')
|
|
if jobs:
|
|
job = jobs[0]
|
|
# Move all steps to done first so the job CAN be done
|
|
for step in job.step_ids:
|
|
if step.state in ('pending', 'in_progress'):
|
|
if step.state == 'pending' and hasattr(step, 'button_start'):
|
|
try:
|
|
step.button_start()
|
|
except Exception:
|
|
pass
|
|
if hasattr(step, 'button_finish'):
|
|
try:
|
|
step.button_finish()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
job.with_context(fp_skip_qc_gate=True).button_mark_done()
|
|
print(f' ✓ Job marked done (with QC bypass) — state={job.state}')
|
|
except Exception as ex:
|
|
gap('Operator', 'fp.job.button_mark_done', str(ex))
|
|
|
|
# 5e. Delivery auto-created on done?
|
|
Del = e.get('fusion.plating.delivery') or (
|
|
e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
|
|
)
|
|
Del = e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
|
|
if Del is not None and jobs:
|
|
deliveries = Del.search([], order='id desc', limit=3)
|
|
print(f' Latest deliveries on system: '
|
|
f'{", ".join(deliveries.mapped("name") or ["(none)"])}')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Driver — picks up the delivery
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Driver] Find the linked fusion.plating.delivery')
|
|
if Del is not None and jobs:
|
|
d = Del.search([('job_id', '=', jobs[0].id) if 'job_id' in Del._fields
|
|
else ('id', '=', 0)], limit=1)
|
|
if d:
|
|
print(f' ✓ Delivery {d.name} state={d.state}')
|
|
if hasattr(d, 'action_mark_delivered'):
|
|
try:
|
|
d.action_mark_delivered()
|
|
print(f' ✓ Marked delivered — state={d.state}')
|
|
except Exception as ex:
|
|
gap('Driver', 'delivery.action_mark_delivered', str(ex))
|
|
else:
|
|
print(' No delivery linked to job — checking by SO')
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROLE: Accountant — invoice the SO
|
|
# ------------------------------------------------------------------
|
|
print('\n[ROLE: Accountant] Generate invoice')
|
|
print(f' invoice_status={so.invoice_status}')
|
|
if so and so.invoice_status == 'to invoice':
|
|
try:
|
|
so._create_invoices()
|
|
invs = e['account.move'].search(
|
|
[('invoice_origin', '=', so.name)])
|
|
print(f' ✓ Invoice(s) created: '
|
|
f'{", ".join(invs.mapped("name") or ["(none yet)"])}')
|
|
except Exception as ex:
|
|
gap('Accountant', 'sale.order._create_invoices', str(ex))
|
|
elif so.invoice_status == 'no':
|
|
# qty_delivered is 0 — service products invoice on ordered qty by
|
|
# default. If "no" persists, the SO has no invoiceable lines yet
|
|
# (e.g. delivered_qty=0 + invoice_policy='delivery').
|
|
print(f' Note: SO not yet invoiceable (qty_delivered=0). '
|
|
f'Set invoice_policy=order on plating service products to '
|
|
f'invoice immediately on confirm.')
|
|
|
|
# ------------------------------------------------------------------
|
|
# SUMMARY
|
|
# ------------------------------------------------------------------
|
|
print('\n=========================== SUMMARY ===========================')
|
|
if not GAPS:
|
|
print('NO GAPS FOUND — workflow walked end-to-end clean')
|
|
else:
|
|
print(f'{len(GAPS)} GAP(S) FOUND:')
|
|
for role, where, msg in GAPS:
|
|
print(f' - [{role}] {where} :: {msg}')
|
|
e.cr.commit()
|
|
|
|
|
|
walk()
|