This commit is contained in:
gsinghpal
2026-04-27 00:11:18 -04:00
parent d9f58b9851
commit f08f328688
116 changed files with 9891 additions and 359 deletions

View File

@@ -0,0 +1,210 @@
# Battle test — real shop failure modes.
#
# This is the "what if my operator is sloppy / forgetful / lazy" suite.
# We document what the system does TODAY, then identify what's missing.
#
# Persona shorthand:
# Carlos = operator
# Mike = second operator
# Bob = supervisor / manager (admin in this DB)
import time
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
def make_job(po_suffix):
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': f'PO-BT-{po_suffix}',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# ====================================================================== 1
print('='*72)
print('SCENARIO 1 — Carlos forgot to click Start. Realizes 2 hours later.')
print('='*72)
job = make_job('S1-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
print(f' Setup: {job.name}, step "{step.name}" state={step.state}')
print(f' Reality: Carlos started masking 2h ago but forgot to click.')
print(f' Now he clicks Start, then immediately Finish.')
step.button_start()
step.button_finish()
print(f' Result: state={step.state}, duration_actual={step.duration_actual:.4f} min')
print(f' → Lost 2h of clock time. NO way to back-date date_started without admin SQL.')
print(f' → date_started field is readonly=True on the form.')
print(f' GAP: No "Adjust Time" affordance for forgetful operators.')
# ====================================================================== 2
print()
print('='*72)
print('SCENARIO 2 — Carlos finished step physically. Forgot Finish. Went home.')
print('='*72)
job = make_job('S2-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos starts {step.name} at {step.date_started}')
print(f' ... 12 hours later Mike notices the step is still in_progress ...')
# Simulate the time gap by setting started 12h ago
step.write({'date_started': fields.Datetime.now() - timedelta(hours=12)})
# Mike taps Finish now
step.button_finish()
print(f' Mike clicks Finish: duration_actual = {step.duration_actual:.1f} min')
print(f' Reality was probably 30 min. System recorded {step.duration_actual:.0f} min.')
print(f' Cost rollup is wildly wrong: cost_total = ${step.cost_total or 0:.2f}')
print(f' GAP: No way to retroactively correct the timelog interval.')
# ====================================================================== 3
print()
print('='*72)
print('SCENARIO 3 — Two operators tap Start on the same step.')
print('='*72)
job = make_job('S3-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos clicks Start → state={step.state}, '
f'open logs={len(step.time_log_ids.filtered(lambda l: not l.date_finished))}')
try:
# Mike "logs in as himself" then taps Start on the same step
step.button_start()
open_logs = step.time_log_ids.filtered(lambda l: not l.date_finished)
print(f' Mike clicks Start → state={step.state}, open logs={len(open_logs)}')
if len(open_logs) >= 2:
print(f' ❌ TWO open timelogs created. duration_actual will double-count.')
except Exception as e:
print(f' ✓ Blocked: {e}')
# ====================================================================== 4
print()
print('='*72)
print('SCENARIO 4 — Operator finishes step #6 before #5 is started.')
print('='*72)
job = make_job('S4-' + fields.Datetime.now().strftime('%H%M%S'))
steps = job.step_ids.sorted('sequence')
step5 = steps[4]
step6 = steps[5]
print(f' Step #5: {step5.name} state={step5.state}')
print(f' Step #6: {step6.name} state={step6.state}')
try:
step6.button_start()
print(f' ❌ Allowed start of step #6 while step #5 still ready')
step6.button_finish()
print(f' Step #6 done. Step #5 still: {step5.state}')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
print(f' GAP: No predecessor enforcement. Steps are independent.')
print(f' REALITY: This may be intentional (parallel work in different tanks).')
print(f' But there\'s no "force serial" flag for steps that MUST be in order.')
# ====================================================================== 5
print()
print('='*72)
print('SCENARIO 5 — Job stuck mid-process. Manager wants to take over.')
print('='*72)
job = make_job('S5-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.write({'assigned_user_id': env.user.id})
step.button_start()
print(f' Step assigned to Carlos, in progress.')
print(f' Carlos is on vacation. Bob needs to reassign + finish.')
print(f' Bob views step → assigned_user_id={step.assigned_user_id.name}')
# Can Bob reassign?
try:
step.write({'assigned_user_id': env.user.id})
print(f' ✓ Bob reassigned step (write to assigned_user_id allowed)')
except Exception as e:
print(f' ❌ Reassign blocked: {e}')
# Bob finishes
step.button_finish()
print(f' Bob finishes: state={step.state}, finished_by={step.finished_by_user_id.name}')
# ====================================================================== 6
print()
print('='*72)
print('SCENARIO 6 — Bake window expired (operator at lunch). Override?')
print('='*72)
BW = env['fusion.plating.bake.window']
Bath = env['fusion.plating.bath']
bath = Bath.search([], limit=1)
expired = BW.create({
'bath_id': bath.id,
'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
'window_hours': 4.0,
'part_ref': 'BT-EXPIRED',
'quantity': 5,
})
# Cron updates state if past required_by
BW._cron_update_states()
expired.invalidate_recordset()
print(f' Bake window {expired.name}: state={expired.state}, '
f'required_by={expired.bake_required_by} (10h ago)')
# Try to start_bake on a missed_window
try:
expired.action_start_bake()
print(f' ⚠️ action_start_bake worked even on missed_window: state={expired.state}')
print(f' GAP: No guard against starting bake after missing window. Should require manager override.')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
# ====================================================================== 7
print()
print('='*72)
print('SCENARIO 7 — Operator clocks 6 hours on a step expected to take 30 min.')
print('='*72)
job = make_job('S7-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.duration_expected = 30 # 30 min
step.button_start()
# Simulate 6h elapsed
step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
step.button_finish()
ratio = (step.duration_actual / step.duration_expected) if step.duration_expected else 0
print(f' duration_expected={step.duration_expected} min, duration_actual={step.duration_actual:.0f} min')
print(f' Ratio: {ratio:.1f}x expected')
print(f' GAP: System silently accepted 12x overrun. No alert, no chatter post.')
# ====================================================================== 8
print()
print('='*72)
print('SCENARIO 8 — Operator did 4 of 5 parts. 1 contaminated. Qty drift.')
print('='*72)
job = make_job('S8-' + fields.Datetime.now().strftime('%H%M%S'))
print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
# Operator finishes all steps
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
# Try to mark done — qty_done is still 0
try:
job.button_mark_done()
print(f' Job done: qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
print(f' ⚠️ System lets job close with qty_done=0 even though qty=5')
print(f' GAP: No reconciliation between qty + qty_done + qty_scrapped at close.')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
env.cr.commit()
print()
print('== Battle test complete ==')

View File

@@ -0,0 +1,150 @@
# Battle test v2 — re-verify after fixes for: bake-window override,
# duration overrun chatter, qty reconciliation, recompute-duration.
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
def make_job(po_suffix):
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': f'PO-BT2-{po_suffix}',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# ====================================================================== Fix 1
print('='*72)
print('FIX 1 — Bake-window: missed_window blocks, manager override allowed + audited')
print('='*72)
BW = env['fusion.plating.bake.window']
Bath = env['fusion.plating.bath']
expired = BW.create({
'bath_id': Bath.search([], limit=1).id,
'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
'window_hours': 4.0,
'part_ref': 'BT2-EXPIRED',
'quantity': 5,
})
BW._cron_update_states()
expired.invalidate_recordset()
print(f' Window {expired.name} state: {expired.state}')
# Naive operator (no override) — should fail
try:
expired.action_start_bake()
print(f' ❌ start_bake worked without override')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:120]}')
# Manager override
try:
expired.action_force_start_missed()
print(f' ✓ Manager override succeeded: state={expired.state}')
# Check chatter
msgs = expired.message_ids.filtered(lambda m: 'OVERRIDE' in (m.body or ''))
print(f' ✓ Chatter audit: {len(msgs)} OVERRIDE message logged')
except Exception as e:
print(f' ❌ Override failed: {e}')
# ====================================================================== Fix 2
print()
print('='*72)
print('FIX 2 — Duration overrun: > 1.5x expected posts chatter warning')
print('='*72)
job = make_job('F2-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.duration_expected = 30 # 30 min expected
step.button_start()
# Force a 6h elapsed via timelog backdate
step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
# Update the open timelog to start 6h ago too
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
step.button_finish()
print(f' duration_expected={step.duration_expected:.0f} min, '
f'duration_actual={step.duration_actual:.0f} min, '
f'ratio={step.duration_actual/step.duration_expected:.1f}x')
overrun_msgs = job.message_ids.filtered(lambda m: 'expected' in (m.body or ''))
print(f' Chatter overrun warnings on job: {len(overrun_msgs)}')
if overrun_msgs:
print(f' ✓ Posted: {overrun_msgs[0].body[:100]}...')
# ====================================================================== Fix 3
print()
print('='*72)
print('FIX 3 — Qty reconciliation: job mark-done blocks if qty mismatch')
print('='*72)
job = make_job('F3-' + fields.Datetime.now().strftime('%H%M%S'))
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
# Try mark done with qty_done = 0
try:
job.button_mark_done()
print(f' ❌ Job closed with qty_done=0!')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:160]}')
# Set qty_done = 4, qty_scrapped = 1, retry
job.qty_done = 4
job.qty_scrapped = 1
print(f' Update: qty_done=4, qty_scrapped=1 (sums to qty=5)')
try:
job.button_mark_done()
print(f' ✓ Closed with reconciled qty: state={job.state}')
except Exception as e:
print(f' ❌ Still blocked: {e}')
# ====================================================================== Fix 4
print()
print('='*72)
print('FIX 4 — Supervisor edits timelog → Recompute Duration action picks it up')
print('='*72)
job = make_job('F4-' + fields.Datetime.now().strftime('%H%M%S'))
step = job.step_ids.sorted('sequence')[0]
step.button_start()
import time as _t
_t.sleep(1)
step.button_finish()
print(f' Initial: duration_actual={step.duration_actual:.4f} min, '
f'logs={len(step.time_log_ids)}')
# Bob backdates the timelog (operator forgot to start; was actually 30 min)
log = step.time_log_ids[0]
real_start = log.date_finished - timedelta(minutes=30)
log.write({'date_started': real_start})
print(f' Bob backdates log: started 30 min before finish')
print(f' log.duration_minutes (auto): {log.duration_minutes:.2f} min')
print(f' step.duration_actual STILL stale: {step.duration_actual:.2f} min')
# Apply recompute
step.action_recompute_duration_from_timelogs()
print(f' After Recompute: duration_actual={step.duration_actual:.2f} min')
recompute_msgs = job.message_ids.filtered(lambda m: 'recomputed' in (m.body or '').lower())
print(f' Chatter audit: {len(recompute_msgs)} recompute entry logged')
env.cr.commit()
print()
print('== Battle test v2 complete ==')

View File

@@ -0,0 +1,82 @@
# Scenario 10 — Carlos paused for lunch. Got pulled to another job. Step
# is now sitting in 'paused' state for 3 days. No alert. Costing is wrong
# (the open timelog row was already closed at pause, but the step shows
# zero progress).
#
# Real shop pattern: this happens daily — interruptions, shift change,
# operator pulled to rush job.
#
# What we want:
# 1. A way to find ALL steps stuck in 'paused' beyond a threshold
# 2. An automatic activity / chatter nudge to the supervisor
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S10-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[0]
# Carlos starts → pauses → walks away
step.button_start()
step.button_pause()
print(f'[Carlos] Started + paused step "{step.name}" (state={step.state})')
# Simulate 3 days passing — backdate the pause by setting date_started
step.date_started = fields.Datetime.now() - timedelta(days=3)
print(f' Pretending it has been paused 3 days')
# Today: how would a manager find this?
print()
print('=== Manager finds stale paused steps ===')
Step = env['fp.job.step']
all_paused = Step.search([('state', '=', 'paused')])
print(f' Total paused steps in DB: {len(all_paused)}')
print(f' Stale-paused (date_started > 1 day ago, state=paused):')
cutoff = fields.Datetime.now() - timedelta(days=1)
stale = Step.search([
('state', '=', 'paused'),
('date_started', '<', cutoff),
])
print(f' found {len(stale)} via search_count')
for s in stale[:5]:
age = (fields.Datetime.now() - s.date_started).days
print(f' - {s.job_id.name} step "{s.name}": paused {age}d, '
f'assigned={s.assigned_user_id.name or "(no one)"}')
# Is there a cron / activity nudge?
crons = env['ir.cron'].search([('name', 'ilike', 'pause')])
print()
print(f' Crons matching "pause": {len(crons)}')
activities = env['mail.activity'].search([
('res_model', '=', 'fp.job.step'),
('summary', 'ilike', 'paused'),
])
print(f' Activities about paused steps: {len(activities)}')
print()
if not activities and not crons:
print(' ❌ GAP: stale-paused steps live forever silently. No nudge.')
env.cr.commit()

View File

@@ -0,0 +1,110 @@
# Scenario 11 — Carlos plating step #4 in tank 3. 8 minutes in, the
# rectifier dies. Parts come out half-plated. Carlos needs to:
# 1. Abort the current step (parts not finished — but partial work
# already happened)
# 2. Switch to backup tank 5
# 3. Restart the step there
#
# What does the system support today?
#
# Fields on fp.job.step that exist:
# - state machine: pending/ready/in_progress/paused/done/skipped/cancelled
# - bath_id, tank_id (the tank picked at start)
# - time_log_ids
#
# Operator's options today:
# A) button_cancel → state=cancelled, but then step shows as cancelled
# and won't be replayed. Not what we want — we WANT a retry.
# B) button_finish + open NCR manually + create a new step manually?
# Way too much paperwork.
# C) button_pause + change tank_id + button_start → preserves history
# but doesn't capture WHY (equipment failure)
#
# Real shop need:
# - "Abort + restart" action that:
# 1. Closes the current timelog (capturing the partial time)
# 2. Resets state to ready
# 3. Lets operator pick a new tank/bath
# 4. Posts chatter on the JOB explaining (equipment failure → tank)
# 5. Optionally fires an NCR / Maintenance request
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S11-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Pick the plating step
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not plating:
plating = job.step_ids.sorted('sequence')[3:4]
# Walk earlier steps to done
for s in job.step_ids.sorted('sequence'):
if s == plating:
break
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' [Carlos] About to start: {plating.name}')
Tank = env['fusion.plating.tank']
Bath = env['fusion.plating.bath']
tanks = Tank.search([], limit=2)
if len(tanks) < 2:
print(f' ⚠️ Need 2+ tanks for the test, only have {len(tanks)}')
else:
tank3, tank5 = tanks[0], tanks[1]
plating.write({'tank_id': tank3.id})
print(f' Initial tank: {plating.tank_id.name}')
plating.button_start()
print(f' Started → state={plating.state}, started_by={plating.started_by_user_id.name}')
print(f' Open timelog rows: {len(plating.time_log_ids)}')
print()
print(f' ⚡ 8 MINUTES LATER: Rectifier dies on tank {plating.tank_id.name}')
print(f' Carlos needs to abort and restart on backup tank.')
print()
# Today's options:
print(f' Today\'s options the operator has:')
print(f' A) button_cancel → step becomes cancelled (job stuck — no replay)')
print(f' B) button_pause + write tank_id + button_start (no failure record)')
print(f' C) ???')
print()
# Try option B (the workaround)
print(f' Trying option B (pause → change tank → resume):')
plating.button_pause()
print(f' Paused: state={plating.state}, logs={len(plating.time_log_ids)}')
if len(tanks) >= 2:
plating.write({'tank_id': tanks[1].id})
print(f' Changed tank to: {plating.tank_id.name}')
plating.button_start()
print(f' Resumed: state={plating.state}, logs={len(plating.time_log_ids)}')
print()
print(f' ❌ GAP: NO RECORD of WHY the tank change happened.')
print(f' ❌ GAP: Workaround works but loses the equipment-failure event.')
print(f' ❌ GAP: No automatic Maintenance Request / NCR creation for the failed equipment.')
env.cr.commit()

View File

@@ -0,0 +1,82 @@
# Verify action_abort_for_retry on a fresh job.
import time
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
Tank = env['fusion.plating.tank']
tanks = Tank.search([], limit=2)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S11V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[3] # plating
step.tank_id = tanks[0].id
step.button_start()
print(f' [Carlos] Started {step.name} on tank {step.tank_id.name}')
time.sleep(2)
# Equipment fails
print(f' ⚡ Rectifier dies on tank {step.tank_id.name}')
print()
before_msgs = len(job.message_ids)
step.action_abort_for_retry(
reason='Rectifier #3 tripped breaker; sparking on bus bar',
new_tank_id=tanks[1].id if len(tanks) > 1 else False,
)
print(f' After abort:')
print(f' state={step.state}')
print(f' tank_id={step.tank_id.name}')
print(f' duration_actual (partial work)={step.duration_actual:.4f} min')
print(f' timelogs={len(step.time_log_ids)}, all closed: '
f'{all(l.date_finished for l in step.time_log_ids)}')
print()
after_msgs = len(job.message_ids)
print(f' Job chatter: {before_msgs}{after_msgs} (delta {after_msgs - before_msgs})')
abort_msg = job.message_ids[0]
print(f' Latest message:')
print(f' {abort_msg.body[:300]}...')
# Operator restarts on the new tank
print()
print(f' [Carlos] Restarts the step on the new tank')
step.button_start()
time.sleep(2)
step.button_finish()
print(f' Final state={step.state}, total duration_actual={step.duration_actual:.4f} min')
print(f' Total timelogs={len(step.time_log_ids)} (1 from abort + 1 from retry)')
# Failure case: try to abort a step in 'ready' state
print()
print(f' Failure test: try abort on a ready (not in_progress) step')
ready_step = job.step_ids.filtered(lambda s: s.state == 'ready')[:1]
if ready_step:
try:
ready_step.action_abort_for_retry(reason='test')
print(f' ❌ Allowed abort on ready step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:100]}')
env.cr.commit()

View File

@@ -0,0 +1,76 @@
# Scenario 12 — Sarah enters SO qty=5. Job spawns with qty=5. Carlos
# starts step 1. Customer calls — they want 8 instead of 5. Sarah edits
# the SO line from 5 to 8.
#
# Question: does the job pick up the change?
# Reality: a stale qty on the job means Carlos plates 5 (per his router)
# but invoice goes for 8 (per the SO).
#
# OR Sarah can't edit a confirmed-SO line (Odoo standard locks it),
# in which case Sarah cancels + reorders, and we have ANOTHER problem.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Build SO with qty=5
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S12-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
sol = so.order_line[:1]
print(f' Initial: SO line qty={sol.product_uom_qty}, job qty={job.qty}')
# Carlos starts the first step
step = job.step_ids.sorted('sequence')[0]
step.button_start()
print(f' Carlos started step "{step.name}" (state={step.state})')
print()
# Customer calls — wants 8 not 5
print(f' 📞 Customer: "Make it 8 instead of 5"')
print(f' Sarah edits SO line qty from 5 to 8...')
try:
sol.product_uom_qty = 8
print(f' Edit succeeded: SO line qty={sol.product_uom_qty}')
except Exception as e:
print(f' ❌ Edit blocked: {e}')
# Did the job qty propagate?
job.invalidate_recordset()
print(f' Job qty AFTER SO edit: {job.qty}')
print()
if job.qty != sol.product_uom_qty:
print(f' ❌ GAP: Job qty stale ({job.qty}) vs SO line qty ({sol.product_uom_qty}).')
print(f' Carlos will plate {job.qty} parts. Invoice ships for {sol.product_uom_qty}.')
print(f' No automatic resync, no warning.')
else:
print(f' ✓ Job qty auto-updated.')
# Try the reverse — what if Sarah tries to LOWER the qty?
print()
print(f' Customer changes mind: now wants 3 instead of 8')
try:
sol.product_uom_qty = 3
job.invalidate_recordset()
print(f' SO line qty={sol.product_uom_qty}, job qty={job.qty}')
except Exception as e:
print(f' ❌ Blocked: {e}')
env.cr.commit()

View File

@@ -0,0 +1,59 @@
# Verify mid-job qty change posts chatter + sync action works.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S12V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
sol = so.order_line[:1]
job.step_ids.sorted('sequence')[0].button_start()
print(f' Initial: SO={sol.product_uom_qty}, job.qty={job.qty}')
before_msgs = len(job.message_ids)
print()
print(f' Sarah edits SO line qty 5 → 8 mid-job')
sol.product_uom_qty = 8
job.invalidate_recordset()
after_msgs = len(job.message_ids)
print(f' Job chatter: {before_msgs}{after_msgs} (delta {after_msgs - before_msgs})')
warn = job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or ''))
print(f' Warning messages on job: {len(warn)}')
if warn:
print(f' ✓ Chatter warning posted')
print(f' Job.qty still: {job.qty} (unchanged — supervisor must explicitly sync)')
print()
print(f' Bob clicks "Sync qty from SO" on the job')
job.action_sync_qty_from_so()
print(f' Job.qty after sync: {job.qty} (expect 8)')
sync_msgs = job.message_ids.filtered(lambda m: 'synced from SO' in (m.body or ''))
print(f' Sync chatter messages: {len(sync_msgs)}')
print()
# Now what about LOWER qty
print(f' Customer reduces to 3...')
sol.product_uom_qty = 3
job.invalidate_recordset()
warn2 = len(job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or '')))
print(f' Warnings now: {warn2}')
job.action_sync_qty_from_so()
print(f' After sync: job.qty={job.qty}')
env.cr.commit()

View File

@@ -0,0 +1,93 @@
# Verify shopfloor scan + tablet_overview now expose step instructions.
from odoo.tests.common import HOST
from odoo import fields
# Build a job with instructions on a step
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S13-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Add detailed instructions to the plating step
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not plating:
plating = job.step_ids.sorted('sequence')[3:4]
plating.instructions = (
'<p><b>Plating bath checklist:</b></p><ul>'
'<li>Verify nickel concentration is 4.05.5 g/L (Fischerscope reading)</li>'
'<li>pH must be 4.44.8 — adjust with ammonium hydroxide if needed</li>'
'<li>Bath temp 8893°C, agitation ON</li>'
'<li>Dwell 45 minutes for 25 µm coating; longer for thicker</li>'
'<li>Rinse for 60s before next station</li></ul>'
)
plating.thickness_target = 25.0
plating.thickness_uom = 'um'
plating.dwell_time_minutes = 45.0
plating.bake_setpoint_temp = 0 # not a bake step
print(f' Step "{plating.name}":')
print(f' instructions length: {len(plating.instructions or "")} chars')
print(f' thickness_target: {plating.thickness_target} {plating.thickness_uom}')
print()
# Now simulate scan endpoint via the controller
from odoo.addons.fusion_plating_shopfloor.controllers import shopfloor_controller as sc
print(f' Tablet operator scans the step QR code (simulating /fp/shopfloor/scan)')
# Build a fake request env
from odoo.http import request as _req
# Call the underlying logic directly
# Find code prefix used
print(f' Step code: {plating.id}, name: {plating.name}')
# Direct call to the scan response builder (no http) — easier approach:
# The scan endpoint builds the dict inline. Verify by replicating its code path.
step = plating
payload = {
'ok': True, 'model': 'fp.job.step',
'id': step.id, 'name': step.name, 'state': step.state,
'duration_actual': step.duration_actual,
'duration_expected': step.duration_expected,
'job_name': step.job_id.name or '',
'product_name': step.job_id.product_id.display_name or '',
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'dwell_time_minutes': step.dwell_time_minutes or 0,
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
}
print(f' Scan payload now includes:')
print(f' instructions: {len(payload["instructions"])} chars')
print(f' thickness_target: {payload["thickness_target"]} {payload["thickness_uom"]}')
print(f' dwell_time_minutes: {payload["dwell_time_minutes"]}')
print(f' duration_expected: {payload["duration_expected"]}')
# Tablet overview check via JSONRPC
# We'll just check the controller method directly
print()
print(f' Tablet overview payload (simulate /fp/shopfloor/tablet_overview):')
# Just verify the field is in _step_payload by introspection
import inspect
src = inspect.getsource(sc.FpShopfloorController)
print(f' _step_payload includes "instructions"? {"instructions" in src and "step.instructions" in src}')
print(f' _step_payload includes "thickness_target"? {"step.thickness_target" in src}')
print(f' _step_payload includes "dwell_time_minutes"? {"step.dwell_time_minutes" in src}')
env.cr.commit()

View File

@@ -0,0 +1,63 @@
# Scenario 14 — Recipe author wants step "Plating" to be hard-blocked
# until step "Acid Etch" finishes. (Real reason: passivation layer
# starts forming on bare metal in seconds; if Plating starts before
# acid etch is done, adhesion fails.)
#
# Today the system allows ANY step to start any time. Out-of-order is
# allowed for parallel work — but for SERIAL-MUST steps, there's no
# enforcement. We need an opt-in flag the recipe author can set per
# step: requires_predecessor_done.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
steps = job.step_ids.sorted('sequence')
print(f' Job {job.name} steps:')
for i, s in enumerate(steps[:6]):
print(f' #{i+1} ({s.sequence}): {s.name} state={s.state}')
# Today: skip step #1, #2, #3, jump to step #4 (plating).
print()
print(' [Operator] Tries to skip earlier steps and start plating directly:')
plating = steps.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if plating:
try:
plating.button_start()
print(f' state={plating.state}')
print(f' ❌ NO PREDECESSOR CHECK. Plating started while Masking/Racking still ready.')
except Exception as e:
print(f' Blocked: {str(e)[:80]}')
# Check if requires_predecessor_done field exists
rec_step = plating.recipe_node_id if plating else False
fields_on_node = list(env['fusion.plating.process.node']._fields.keys())
print()
print(f' Looking for requires_predecessor_done field on fp.process.node:')
print(f' Found: {"requires_predecessor_done" in fields_on_node}')
print(f' Looking for requires_predecessor_done field on fp.job.step:')
print(f' Found: {"requires_predecessor_done" in env["fp.job.step"]._fields}')
print()
print(f' ❌ GAP: No way for the recipe author to mark a step as serial-required.')
env.cr.commit()

View File

@@ -0,0 +1,91 @@
# Verify predecessor enforcement
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14V-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Find plating step + flag its recipe node as serial-required
plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if plating and plating.recipe_node_id:
plating.recipe_node_id.requires_predecessor_done = True
print(f' Recipe author flagged "{plating.name}" requires_predecessor_done')
plating.invalidate_recordset()
print(f' Step picks it up via related: {plating.requires_predecessor_done}')
# Try to start plating with earlier steps still ready
print()
print(f' [Operator] Tries to start plating WITHOUT finishing earlier steps:')
try:
plating.button_start()
print(f' ❌ Allowed early start! state={plating.state}')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:200]}')
# Walk earlier steps to done
print()
print(f' [Operator] Walks earlier steps to done:')
for s in job.step_ids.sorted('sequence'):
if s == plating:
break
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
print(f' Earlier steps now: {set(job.step_ids.filtered(lambda x: x.sequence < plating.sequence).mapped("state"))}')
# Try plating again
print()
print(f' [Operator] Tries plating again after earlier steps done:')
try:
plating.button_start()
print(f' ✓ Allowed: state={plating.state}')
except Exception as e:
print(f' ❌ Still blocked: {e}')
# Test manager bypass via context
print()
print(f' Test manager bypass on a fresh job:')
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S14B-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
Line.create({
'wizard_id': w2.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r2 = w2.action_create_order()
so2 = env['sale.order'].browse(r2['res_id'])
so2.action_confirm()
job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
plating2 = job2.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
# (the recipe_node already has requires_predecessor_done=True from earlier write)
print(f' Plating step requires_predecessor_done: {plating2.requires_predecessor_done}')
try:
plating2.with_context(fp_skip_predecessor_check=True).button_start()
print(f' ✓ Manager bypass: state={plating2.state}')
except Exception as e:
print(f' ❌ Bypass failed: {e}')
env.cr.commit()

View File

@@ -0,0 +1,72 @@
# Scenario 15 — Job has a coating that requires hydrogen embrittlement
# bake. Operator finishes plating step → bake.window auto-spawns
# (state=awaiting_bake). Operator finishes the rest of the steps and
# clicks Mark Done on the job — but never started the bake.
#
# Today: job closes done. Customer ships parts. Field failure 3 weeks
# later. AS9100 auditor: "Show me the bake record for lot X." There's
# no bake record. NCR + customer credit hit.
#
# Want: button_mark_done blocks if any linked bake.window is in state
# awaiting_bake or bake_in_progress. Manager bypass for one-off
# deviations.
import time
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
part = Part.create({
'partner_id': target.id,
'part_number': 'S15-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'substrate_material': 'steel',
'x_fc_default_coating_config_id': coating.id,
})
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S15-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Walk all steps to done — the plating step will spawn a bake.window
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.qty_done = 5 # satisfy reconciliation gate
print(f' Job {job.name}: all steps done')
BW = env['fusion.plating.bake.window']
bws = BW.search([('part_ref', '=', job.name)])
print(f' Bake windows linked to job: {len(bws)}')
for bw in bws:
print(f' {bw.name}: state={bw.state}, required_by={bw.bake_required_by}')
print()
print(f' [Operator — careless] Clicks Mark Done WITHOUT starting bake')
try:
job.button_mark_done()
print(f' ❌ Job closed with bake awaiting! state={job.state}')
print(f' COMPLIANCE BOMB — no bake record but parts ship.')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:200]}')
env.cr.commit()

View File

@@ -0,0 +1,67 @@
# Scenario 16 — Carlos clicked Start on a step. Got pulled to a rush
# job. Forgot to come back. The original step is still in_progress 8
# hours later. The open timelog row is accumulating phantom time. Cost
# rollup is wrong. Manager has no nudge.
#
# Mirror of S10 (stale-paused) but for in_progress.
from datetime import timedelta
from odoo import fields
# Find existing stale in_progress steps in DB to test against
Step = env['fp.job.step']
cutoff = fields.Datetime.now() - timedelta(hours=8)
stale = Step.search([
('state', '=', 'in_progress'),
('date_started', '<', cutoff),
('date_started', '!=', False),
])
print(f' Total in_progress steps started > 8h ago: {len(stale)}')
for s in stale[:5]:
age = (fields.Datetime.now() - s.date_started).total_seconds() / 3600.0
print(f' {s.job_id.name} step "{s.name}": in_progress {age:.1f}h, '
f'started_by={s.started_by_user_id.name or "(none)"}')
if not stale:
print(f' Building one synthetic case...')
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S16-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
s = job.step_ids.sorted('sequence')[0]
s.button_start()
s.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
open_log = s.time_log_ids.filtered(lambda l: not l.date_finished)
if open_log:
open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
print(f' Created stale: {s.job_id.name} step "{s.name}"')
# Look for cron / activity
crons = env['ir.cron'].search([
('name', 'ilike', 'in_progress'), ('name', 'ilike', 'stale'),
])
print()
print(f' Crons matching stale-in_progress: {len(crons)}')
acts = env['mail.activity'].search([('summary', 'like', 'Stale in-progress%')])
print(f' Activities about stale in_progress: {len(acts)}')
if not crons and not acts:
print(f' ❌ GAP: no nudge for phantom in_progress steps either.')
env.cr.commit()

View File

@@ -0,0 +1,60 @@
# Scenario 17 — Mid-job Carlos drops 2 parts (out of 5). Sets
# qty_scrapped from 0 → 2. With my qty-reconciliation gate, he MUST
# update this for the job to close — but there's no NCR / hold record
# explaining WHY 2 parts went away.
#
# Real shop: every scrap event is investigated. Material cost lost,
# customer not told (because the qty_done went down, not the order
# qty), and the AS9100 audit asks "where's the disposition record for
# scrapped parts?"
#
# Want: when qty_scrapped increases on fp.job, auto-create a
# fusion.plating.quality.hold + post chatter for the operator to
# document the cause.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S17-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Carlos starts working
job.step_ids.sorted('sequence')[0].button_start()
# Count holds linked before
Hold = env['fusion.plating.quality.hold']
holds_before = Hold.search_count([('part_ref', '=', part.part_number)])
print(f' Holds for {part.part_number} before scrap: {holds_before}')
# Drop 2 parts
print(f' [Carlos] Drops 2 parts. Updates qty_scrapped 0 → 2')
job.qty_scrapped = 2
holds_after = Hold.search_count([('part_ref', '=', part.part_number)])
print(f' Holds for {part.part_number} after scrap: {holds_after}')
if holds_after > holds_before:
print(f' ✓ Auto-Hold spawned')
else:
print(f' ❌ GAP: qty_scrapped went up but NO hold/NCR auto-created.')
print(f' No record of what happened. AS9100 auditor unhappy.')
env.cr.commit()

View File

@@ -0,0 +1,121 @@
# Scenario 18 — Certificate flow simulation.
# Persona: Sarah (CSR) → Carlos (operator) → Tom (shipper)
# Goal: complete cert issuance from SO entry to customer email.
# Track every gap.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
coating = Coating.search([('spec_reference', '!=', False)], limit=1) \
or Coating.search([], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
print(f' Coating: {coating.name}, spec_reference={coating.spec_reference}')
# Build the SO + walk the full flow
import base64
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-CERT-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 25.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f' Job {job.name} confirmed, qty=5')
# Walk all steps to done
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
# Set qty_done so reconciliation gate passes
job.qty_done = 5
# Bake-window if any
BW = env['fusion.plating.bake.window']
bws = BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')])
for bw in bws:
bw.action_start_bake()
bw.action_end_bake()
# Mark done
print(f' [Carlos] Mark Done')
job.button_mark_done()
print(f' job.state = {job.state}')
# CHECK: was a certificate auto-spawned?
Cert = env['fp.certificate']
certs = Cert.search([('sale_order_id', '=', so.id)])
if not certs:
# try x_fc_job_id link
certs = Cert.search([]) # last resort
print()
print(f' Certificates for this SO: {len(certs)}')
for c in certs[:3]:
print(f' {c.name}: state={c.state}, type={c.certificate_type}')
print(f' partner_id: {c.partner_id.name}')
print(f' spec_reference: {c.spec_reference!r}')
print(f' part_number: {c.part_number!r}')
print(f' quantity_shipped: {c.quantity_shipped}')
print(f' po_number: {c.po_number!r}')
print(f' attachment_id: {c.attachment_id.name if c.attachment_id else None}')
if not certs:
print(f' ❌ GAP: no cert auto-created!')
raise SystemExit
cert = certs[0]
# DISCOVERABILITY — would Tom find the cert from the job form?
print()
print(f' [Tom] Looking at the job form, smart-button row:')
print(f' job.certificate_count = {getattr(job, "certificate_count", "no field")}')
print(f' Smart button visible? (depends on certificate_count > 0)')
# Try to issue
print()
print(f' [Tom] Clicks Issue on the certificate:')
try:
cert.action_issue()
print(f' ✓ Issued: state={cert.state}')
except Exception as e:
print(f' ❌ Blocked: {str(e)[:200]}')
# If blocked due to spec_reference, fix and retry
if cert.state == 'draft' and not cert.spec_reference:
print()
print(f' [Tom] Manually fills spec_reference (workflow gap — should auto-fill from coating)')
cert.spec_reference = coating.spec_reference or 'AMS 2404'
try:
cert.action_issue()
print(f' ✓ Issued after manual fix: state={cert.state}')
except Exception as e:
print(f' ❌ Still blocked: {str(e)[:200]}')
# Try Send to Customer
print()
print(f' [Tom] Clicks Send to Customer:')
print(f' cert.attachment_id = {cert.attachment_id.name if cert.attachment_id else "(none — PDF not generated!)"}')
try:
act = cert.action_send_to_customer()
print(f' Composer opens. Default attachments: '
f'{act.get("context", {}).get("default_attachment_ids", "(none)")}')
except Exception as e:
print(f'{e}')
env.cr.commit()

View File

@@ -0,0 +1,155 @@
# Scenario 19 — Fischerscope thickness report PDF appended to CoC.
#
# Goal: when QC has a thickness_report_pdf_id uploaded by the operator
# on the tablet, action_issue should produce a multi-page CoC with the
# Fischerscope PDF as page 2+.
import base64
from odoo import fields
# Build a fresh job that requires QC + Fischerscope PDF
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Tpl = env['fp.qc.checklist.template']
TplLine = env['fp.qc.checklist.template.line']
QC = env['fusion.plating.quality.check']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
target.x_fc_requires_qc = True
coating = Coating.search([('spec_reference', '!=', False)], limit=1)
part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Make sure default QC template requires Fischerscope PDF
default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
if default_tpl:
default_tpl.require_thickness_report_pdf = True
print(f' Using QC template: {default_tpl.name} (requires PDF)')
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-FISCHER-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
# Find the auto-spawned QC
qc = QC.search([('job_id', '=', job.id)], limit=1)
if not qc:
qc = QC.create_for_job(job)
print(f' QC: {qc.name}, lines={len(qc.line_ids)}')
# Operator uploads a fake "Fischerscope" PDF to the QC
# Use a real minimal PDF so the merge actually parses
minimal_pdf = (
b'%PDF-1.4\n'
b'1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n'
b'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n'
b'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R'
b'/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>>>endobj\n'
b'4 0 obj<</Length 88>>stream\n'
b'BT /F1 14 Tf 100 700 Td (FISCHERSCOPE THICKNESS REPORT) Tj '
b'0 -30 Td (Mean: 25.3 um Std: 1.2) Tj ET\n'
b'endstream endobj\n'
b'xref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n'
b'0000000054 00000 n \n0000000097 00000 n \n0000000189 00000 n \n'
b'trailer<</Size 5/Root 1 0 R>>\nstartxref\n330\n%%EOF\n'
)
att = env['ir.attachment'].create({
'name': 'fischer_test.pdf',
'datas': base64.b64encode(minimal_pdf),
'mimetype': 'application/pdf',
'type': 'binary',
})
qc.thickness_report_pdf_id = att.id
print(f' Uploaded Fischerscope PDF: {qc.thickness_report_pdf_id.name} '
f'({len(minimal_pdf)} bytes)')
# Walk QC lines + pass
for ln in qc.line_ids:
ln.result = 'pass'
qc.action_pass()
print(f' QC state: {qc.state}')
# Walk job to done
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.qty_done = 5
# Bake window
BW = env['fusion.plating.bake.window']
for bw in BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')]):
bw.action_start_bake()
bw.action_end_bake()
job.button_mark_done()
print(f' Job done')
# Fetch the auto-spawned cert
Cert = env['fp.certificate']
cert = Cert.search([('x_fc_job_id', '=', job.id)], limit=1)
print()
print(f' Cert: {cert.name}, state={cert.state}')
# v19.0.6.20.0 — new UI visibility fields (S19 Phase 2). Assert the
# operator would see "Will Append on Issue" badge BEFORE clicking Issue.
print(f' x_fc_thickness_status (pre-Issue): {cert.x_fc_thickness_status!r}')
print(f' x_fc_thickness_qc_id: {cert.x_fc_thickness_qc_id.name if cert.x_fc_thickness_qc_id else "(none)"}')
print(f' x_fc_thickness_pdf_id: {cert.x_fc_thickness_pdf_id.name if cert.x_fc_thickness_pdf_id else "(none)"}')
if cert.x_fc_thickness_status == 'pending':
print(f' ✓ UI banner WILL show "Fischerscope thickness PDF is on file"')
elif cert.x_fc_thickness_status == 'none':
print(f' ❌ UI says no PDF — merge would not run on Issue')
else:
print(f' ⚠️ unexpected status: {cert.x_fc_thickness_status}')
# Issue the cert — should render CoC + merge Fischerscope as page 2
cert.action_issue()
cert.invalidate_recordset(['x_fc_thickness_status', 'x_fc_thickness_qc_id', 'x_fc_thickness_pdf_id'])
print(f' After Issue: state={cert.state}')
print(f' x_fc_thickness_status (post-Issue): {cert.x_fc_thickness_status!r}')
if cert.x_fc_thickness_status == 'merged':
print(f' ✓ UI banner shows "Fischerscope thickness report merged"')
else:
print(f' ❌ UI status not flipping to merged: {cert.x_fc_thickness_status}')
print(f' attachment_id: {cert.attachment_id.name if cert.attachment_id else "(none)"}')
if cert.attachment_id:
pdf_bytes = base64.b64decode(cert.attachment_id.datas)
print(f' Total PDF size: {len(pdf_bytes)} bytes')
# Quick page count via pypdf
import io
try:
from pypdf import PdfReader
except ImportError:
from PyPDF2 import PdfReader
try:
reader = PdfReader(io.BytesIO(pdf_bytes))
print(f' Page count: {len(reader.pages)}')
if len(reader.pages) >= 2:
print(f' ✓ CoC + Fischerscope merged (multi-page)')
else:
print(f' ❌ Only 1 page — merge did not run')
except Exception as e:
print(f' ⚠️ couldn\'t parse output PDF: {e}')
# Look for chatter audit
msgs = cert.message_ids.filtered(lambda m: 'fischerscope' in (m.body or '').lower())
print(f' Chatter mentions Fischerscope: {len(msgs)}')
for m in msgs[:2]:
print(f' - {m.body[:120]}')
target.x_fc_requires_qc = False
env.cr.commit()

View File

@@ -0,0 +1,65 @@
# Scenario 9 — Carlos starts step, Bob (supervisor) reassigns to Mike.
# Verify chatter audit trail.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-S9-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
step = job.step_ids.sorted('sequence')[0]
# Pretend Carlos owns this step.
step.assigned_user_id = env.user.id
step.button_start()
print(f'[Carlos] Started step "{step.name}" (state={step.state})')
print(f' assigned_user_id: {step.assigned_user_id.name}')
# Count chatter messages on the JOB before Bob reassigns.
before_count = len(job.message_ids)
print(f' Job chatter messages before reassign: {before_count}')
# Bob reassigns to "another user" — for the test we just write to ourselves
# to simulate, but the field write IS the operation.
print()
print('[Bob] Reassigning step to a different operator...')
# Use a different user if available.
other = env['res.users'].search([('id', '!=', env.user.id), ('share', '=', False)], limit=1)
if not other:
other = env.user # fallback — at least the write fires
step.assigned_user_id = other.id
step.invalidate_recordset()
job.invalidate_recordset()
after_count = len(job.message_ids)
print(f' After reassign: assigned_user_id={step.assigned_user_id.name}')
print(f' Job chatter messages: {after_count} (delta: {after_count - before_count})')
reassign_msgs = job.message_ids.filtered(
lambda m: 'reassign' in (m.body or '').lower()
)
print(f' Reassign-flagged chatter posts: {len(reassign_msgs)}')
if reassign_msgs:
print(f' ✓ Audit trail captured')
else:
print(f' ❌ GAP: silent reassignment, no chatter trail')
env.cr.commit()

View File

@@ -0,0 +1,395 @@
# -*- 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()

View File

@@ -0,0 +1,60 @@
# Step 1 verification — Direct Order wizard onchange + hold guard fixes.
W = env['fp.direct.order.wizard']
ISD = env['fp.invoice.strategy.default']
P = env['res.partner']
target = P.browse(2529) # 2CM INNOVATIVE
print('Test 1 — customer with NO invoice strategy default:')
ISD.search([('partner_id', '=', target.id)]).unlink()
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy}, payment_term={w.payment_term_id.name if w.payment_term_id else None}')
print('\nTest 2 — customer WITH strategy default but NO payment_term:')
isd = ISD.create({'partner_id': target.id, 'default_strategy': 'net_terms'})
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy} (expect: net_terms)')
print(f' payment_term={w.payment_term_id.name if w.payment_term_id else None}')
isd.unlink()
print('\nTest 3 — customer with strategy + deposit + payment_term:')
pt = env['account.payment.term'].search([], limit=1)
isd = ISD.create({
'partner_id': target.id, 'default_strategy': 'deposit',
'default_deposit_percent': 50.0, 'payment_term_id': pt.id,
})
w = W.new({'partner_id': target.id})
w._onchange_partner_id()
print(f' invoice_strategy={w.invoice_strategy} (expect: deposit)')
print(f' deposit_percent={w.deposit_percent} (expect: 50.0)')
print(f' payment_term={w.payment_term_id.name} (expect: {pt.name})')
isd.unlink()
print('\nTest 4 — account-hold warning fires on partner change:')
target.x_fc_account_hold = True
w = W.new({'partner_id': target.id})
result = w._onchange_partner_id()
warning = (result or {}).get('warning')
print(f' warning title: {warning.get("title") if warning else None}')
print(f' warning msg: {(warning.get("message") or "")[:100] if warning else None}')
print('\nTest 5 — account-hold blocks action_create_order:')
w = W.create({'partner_id': target.id, 'po_pending': True})
# add one line so the line check passes
part = env['fp.part.catalog'].search([], limit=1)
coating = env['fp.coating.config'].search([], limit=1)
env['fp.direct.order.line'].create({
'wizard_id': w.id,
'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 1,
'unit_price': 10.0,
})
try:
w.action_create_order()
print(' ❌ HELD CUSTOMER CREATED ORDER — guard failed')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
target.x_fc_account_hold = False
env.cr.commit()

View File

@@ -0,0 +1,37 @@
# Step 2 verification — picking a part on the wizard line pre-fills coating + treatments.
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
P = env['res.partner']
target = P.browse(2529)
# Pick a part that has a default coating + treatments configured.
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
if not part:
# Build a synthetic one for the test.
coating = Coating.search([], limit=1)
treats = Treat.search([], limit=2)
part = Part.search([], limit=1)
part.x_fc_default_coating_config_id = coating.id
part.x_fc_default_treatment_ids = [(6, 0, treats.ids)]
print(f'Set up part {part.part_number}: default coating={coating.name}, treatments={treats.mapped("name")}')
print(f'Part: {part.part_number} rev {part.revision}')
print(f' default coating: {part.x_fc_default_coating_config_id.name if part.x_fc_default_coating_config_id else None}')
print(f' default treatments: {part.x_fc_default_treatment_ids.mapped("name") if part.x_fc_default_treatment_ids else None}')
# Build a wizard, add an empty line, simulate Sarah picking the part.
w = W.create({'partner_id': target.id})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
print()
print(f'After picking part on line:')
print(f' coating_config_id: {ln.coating_config_id.name if ln.coating_config_id else None}')
print(f' treatment_ids: {ln.treatment_ids.mapped("name") if ln.treatment_ids else None}')
print(f' Pre-fill worked? {bool(ln.coating_config_id) and bool(ln.treatment_ids)}')
env.cr.commit()

View File

@@ -0,0 +1,107 @@
# Step 3 — Sarah hits "Create Order" in wizard, then confirms SO.
# Watch every side-effect: SO state, fp.job auto-spawn, fp.receiving
# auto-spawn, fp.racking.inspection, portal.job mirror, QC check.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529) # 2CM INNOVATIVE
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
# Build the wizard exactly the way Sarah would after Step 1+2 fixes.
w = W.create({
'partner_id': target.id,
'po_number': 'PO-STEP3-001',
'po_pending': False,
'customer_job_number': 'CUSTJOB-STEP3',
'planned_start_date': fields.Date.today(),
'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
'invoice_strategy': 'net_terms',
'delivery_method': 'shipping_partner',
'po_attachment_file': b'fake-pdf-bytes',
'po_attachment_filename': 'po.pdf',
})
w._onchange_partner_id()
print(f'[Sarah] Created wizard {w.name} for {target.display_name}')
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
ln_vals = ln._convert_to_write({n: ln[n] for n in ln._fields})
ln_vals.update({
'wizard_id': w.id,
'quantity': 25,
'unit_price': 12.50,
'line_description': 'EN plating per part default coating',
'internal_description': 'Standard recipe; bake within 4h.',
})
real_line = Line.create(ln_vals)
print(f'[Sarah] Added line: part={real_line.part_catalog_id.part_number}, '
f'coating={real_line.coating_config_id.name}, qty={real_line.quantity}')
# Hit Create Order.
print('[Sarah] Clicking "Create Order"...')
result = w.action_create_order()
so_id = (result or {}).get('res_id')
SO = env['sale.order']
so = SO.browse(so_id) if so_id else SO.search(
[('x_fc_po_number', '=', 'PO-STEP3-001')], order='id desc', limit=1,
)
print(f' -> SO created: {so.name} (state={so.state})')
# Now confirm the SO (Sarah does this from the SO form, not the wizard).
print('[Sarah] Confirming SO...')
try:
so.action_confirm()
print(f' -> SO state={so.state}, x_fc_receiving_status={so.x_fc_receiving_status}')
except Exception as e:
print(f' ❌ confirm failed: {e}')
env.cr.rollback()
raise SystemExit
# Verify side-effects.
print()
print('=== Side effects of SO confirm ===')
Job = env['fp.job']
jobs = Job.search([('sale_order_id', '=', so.id)])
print(f' fp.job auto-spawn: {len(jobs)} job(s)')
for j in jobs:
print(f' {j.name}: state={j.state}, qty={j.qty}, recipe={j.recipe_id.name or "(no recipe)"}, steps={len(j.step_ids)}')
Receiving = env['fp.receiving']
receivings = Receiving.search([('sale_order_id', '=', so.id)])
print(f' fp.receiving auto-spawn: {len(receivings)} record(s)')
for r in receivings:
print(f' {r.name}: state={r.state}, expected_qty={r.expected_qty}')
if 'fp.racking.inspection' in env:
Inspection = env['fp.racking.inspection']
racks = Inspection.search([('sale_order_id', '=', so.id)])
print(f' fp.racking.inspection auto-spawn: {len(racks)} record(s)')
for ri in racks:
print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
PortalJob = env['fusion.plating.portal.job']
portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
print(f' portal.job mirror: {len(portal_jobs)} record(s)')
for pj in portal_jobs:
print(f' {pj.name}: state={pj.state}')
QC = env['fusion.plating.quality.check']
qcs = QC.search([('job_id', 'in', jobs.ids)])
print(f' QC checks: {len(qcs)} (customer x_fc_requires_qc={getattr(target, "x_fc_requires_qc", "NOFIELD")})')
for qc in qcs:
print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
# x_fc_receiving_status check
print()
print(f' SO x_fc_receiving_status (post-confirm, no receipt yet): {so.x_fc_receiving_status}')
print(f' Expected: not_received (parts haven\'t arrived)')
env.cr.commit()
print()
print(f'== Step 3 complete. SO ID for next steps: {so.id} ==')

View File

@@ -0,0 +1,91 @@
# Step 4 — Mike receives parts. Walk the receiving form, fill every
# visible field, walk the state machine, verify SO status updates at
# every transition.
so = env['sale.order'].browse(423)
recv = env['fp.receiving'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Mike] Looking at receiving {recv.name}: state={recv.state}, expected_qty={recv.expected_qty}')
# Mike sees the form. What's required vs optional?
print()
print('Visible fields on the receiving form (per fp_receiving_views.xml):')
print(f' sale_order_id: {recv.sale_order_id.name} (readonly via related)')
print(f' partner_id: {recv.partner_id.name} (related)')
print(f' po_number: {recv.po_number}')
print(f' box_count_in: {recv.box_count_in} <-- Mike must set this')
print(f' expected_qty: {recv.expected_qty}')
print(f' received_qty: {recv.received_qty} <-- defaults to expected_qty per Sub 8')
print(f' qty_match: {recv.qty_match}')
print(f' carrier_name: {recv.carrier_name} <-- Mike fills this')
print(f' carrier_tracking: {recv.carrier_tracking} <-- Mike fills this')
# Mike fills the carrier + tracking + counts the boxes.
print()
print('[Mike] Filling carrier + tracking + box count...')
recv.write({
'carrier_name': 'Purolator Ground',
'carrier_tracking': 'PUR-1Z9999E2E',
'box_count_in': 3,
'received_qty': 25, # all 25 arrived
'notes': '<p>Truck arrived 10am. Boxes look clean.</p>',
})
print(f' recv.qty_match = {recv.qty_match} (expected vs received)')
print(f' SO status BEFORE marking counted: {so.x_fc_receiving_status}')
# Click "Mark Counted"
print()
print('[Mike] Clicks "Mark Counted"')
try:
recv.action_mark_counted()
print(f' recv.state = {recv.state}')
print(f' recv.received_by_id = {recv.received_by_id.name}')
print(f' SO status AFTER mark counted: {so.x_fc_receiving_status}')
print(f' Expected: partial (boxes on dock, racking pending)')
assert so.x_fc_receiving_status == 'partial', 'SO status should be partial!'
print(' ✓ SO status correctly flipped to partial')
except Exception as e:
print(f'{e}')
# Click "Mark Staged"
print()
print('[Mike] Clicks "Mark Staged"')
try:
recv.action_mark_staged()
print(f' recv.state = {recv.state}')
print(f' SO status: {so.x_fc_receiving_status} (should still be partial)')
assert so.x_fc_receiving_status == 'partial'
print(' ✓ Still partial — racking not done yet')
except Exception as e:
print(f'{e}')
# Mike clicks the new "Racking Inspections" smart button (Round 2 fix)
print()
print('[Mike] Clicks the "Racking Inspections" smart button')
try:
act = recv.action_view_racking_inspections()
Inspection = env['fp.racking.inspection']
racks = Inspection.search(act.get('domain') or [])
print(f' Smart-button opens model={act.get("res_model")}, finds {len(racks)} inspection(s)')
for ri in racks:
print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
except Exception as e:
print(f'{e}')
# At this point Mike's done — racking crew takes over.
# But the receiving stays at "staged" until racking crew finishes
# inspection and someone clicks "Close" on the receiving.
# Let's pretend racking is done and close the receiving.
print()
print('[Mike] (or shop manager) Clicks "Close Receiving" once racking is done')
try:
recv.action_close()
print(f' recv.state = {recv.state}')
print(f' SO status AFTER close: {so.x_fc_receiving_status}')
assert so.x_fc_receiving_status == 'received'
print(' ✓ SO status correctly flipped to received')
except Exception as e:
print(f'{e}')
print()
print(f'== Step 4 complete. SO {so.name} status={so.x_fc_receiving_status}, recv {recv.name} state={recv.state} ==')
env.cr.commit()

View File

@@ -0,0 +1,99 @@
# Step 5 — Carlos walks the plating job. Test BOTH paths:
# A) Try to mark_done with steps still ready → must be blocked
# B) Walk every step → mark_done succeeds
# Build a fresh SO + job (don't reuse 423 — its job is already done).
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP5-001',
'planned_start_date': fields.Date.today(),
'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
'invoice_strategy': 'net_terms',
'delivery_method': 'shipping_partner',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': ln.coating_config_id.id,
'quantity': 10, 'unit_price': 15.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Carlos] Fresh job {job.name} for SO {so.name}')
print(f' Steps: {len(job.step_ids)}, all in state: {set(job.step_ids.mapped("state"))}')
# Path A: try mark_done without walking steps.
print()
print('[Carlos] Try Mark Done WITHOUT walking any step (compliance test):')
try:
job.button_mark_done()
print(' ❌ JOB CLOSED WITH ZERO STEPS WALKED — guard failed')
except Exception as e:
print(f' ✓ blocked: {str(e)[:200]}')
# Path B: walk every step then mark_done.
print()
print('[Carlos] Walk every step, then Mark Done:')
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
done_count = len(job.step_ids.filtered(lambda s: s.state == 'done'))
print(f' walked {done_count}/{len(job.step_ids)} to done')
try:
job.button_mark_done()
print(f' ✓ Job marked done — state={job.state}, finished={job.date_finished}')
except Exception as e:
print(f' ❌ Mark Done failed AFTER walking: {e}')
# Verify side effects on this job too.
Delivery = env['fusion.plating.delivery']
deliveries = Delivery.search([('x_fc_job_id', '=', job.id)])
Cert = env['fp.certificate']
certs = Cert.search([('x_fc_job_id', '=', job.id)])
print(f' Side effects: {len(deliveries)} delivery, {len(certs)} certificate')
# Path C: manager bypass (admin is a manager).
print()
print('[Mgr] Test manager bypass via context fp_skip_step_gate=True')
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP5-002',
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
Line.create({
'wizard_id': w2.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 15.0,
})
r2 = w2.action_create_order()
so2 = env['sale.order'].browse(r2['res_id'])
so2.action_confirm()
job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
print(f' Created fresh job {job2.name} with {len(job2.step_ids)} unworked steps')
try:
job2.with_context(fp_skip_step_gate=True).button_mark_done()
print(f' ✓ Manager bypass worked — job state={job2.state}')
except Exception as e:
print(f' ❌ Bypass failed: {e}')
env.cr.commit()
print()
print('== Step 5 complete ==')

View File

@@ -0,0 +1,103 @@
# Step 6 — Lisa walks the QC checklist for a customer that requires QC.
# Test:
# A) Customer requires QC but no template configured → spawn fails gracefully?
# B) Customer requires QC + template configured → check auto-spawns on confirm
# C) Lisa walks the checklist, marks lines, action_pass
# D) Job mark_done now lets through
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Tpl = env['fp.qc.checklist.template']
TplLine = env['fp.qc.checklist.template.line']
QC = env['fusion.plating.quality.check']
# Find or create a QC template (default, no partner_id) for the test.
default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
if not default_tpl:
default_tpl = Tpl.create({
'name': 'Default QC Template (E2E)',
'active': True,
})
TplLine.create({'template_id': default_tpl.id, 'sequence': 10, 'name': 'Visual inspection — appearance'})
TplLine.create({'template_id': default_tpl.id, 'sequence': 20, 'name': 'Thickness measurement (Fischerscope)'})
TplLine.create({'template_id': default_tpl.id, 'sequence': 30, 'name': 'Tape adhesion test'})
print(f'[setup] Created default QC template: {default_tpl.name} ({len(default_tpl.line_ids)} lines)')
else:
print(f'[setup] Using existing default QC template: {default_tpl.name}')
# Mark our test customer as requires_qc.
target = P.browse(2529)
target.x_fc_requires_qc = True
print(f'[setup] Set {target.display_name}.x_fc_requires_qc = True')
# Build a fresh SO + job for QC test.
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP6-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 20.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Sarah] Confirmed SO {so.name} → job {job.name}')
# Did QC auto-spawn?
qcs = QC.search([('job_id', '=', job.id)])
print(f'[Lisa] QC checks auto-spawned: {len(qcs)}')
for qc in qcs:
print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}, partner_id={qc.partner_id.name}')
if not qcs:
print(' ❌ Customer requires QC but no check spawned!')
raise SystemExit
qc = qcs[0]
# Lisa walks every checklist line.
print()
print('[Lisa] Walks the checklist:')
for ln in qc.line_ids.sorted('sequence'):
print(f' - {ln.name}: result before={ln.result}')
ln.result = 'pass'
ln.notes = 'OK on inspection'
# Try to action_pass.
print()
print('[Lisa] Clicks "Pass":')
try:
qc.action_pass()
print(f' ✓ QC state={qc.state}, overall_result={qc.overall_result}')
except Exception as e:
print(f'{e}')
# Now job mark_done should work (steps need to be walked first).
print()
print('[Carlos+Lisa] Walking steps then marking job done:')
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
try:
job.button_mark_done()
print(f' ✓ Job done — state={job.state}')
except Exception as e:
print(f' ❌ Job mark_done blocked: {e}')
# Reset partner flag for test independence.
target.x_fc_requires_qc = False
env.cr.commit()
print()
print('== Step 6 complete ==')

View File

@@ -0,0 +1,93 @@
# Step 7 — Tom (Shipper) walks the delivery from draft to delivered.
# Test:
# A) Delivery exists post-job-done — what fields visible? what state?
# B) Try action_start_route without driver → must block
# C) Assign driver + vehicle + box count, schedule
# D) Try action_mark_delivered without POD → must block
# E) Capture POD, mark delivered, verify cert + chain of custody
so = env['sale.order'].browse(423) # Step 3's SO
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
Delivery = env['fusion.plating.delivery']
delivery = Delivery.search([('x_fc_job_id', '=', job.id)], limit=1)
print(f'[Tom] Looking at delivery {delivery.name}')
print()
print('Visible header on delivery form:')
print(f' partner_id: {delivery.partner_id.name}')
print(f' delivery_address_id: {delivery.delivery_address_id.name if delivery.delivery_address_id else None}')
print(f' contact_name: {delivery.contact_name}')
print(f' contact_phone: {delivery.contact_phone}')
print(f' job_ref: {delivery.job_ref}')
print(f' state: {delivery.state}')
print(f' scheduled_date: {delivery.scheduled_date}')
print(f' assigned_driver_id: {delivery.assigned_driver_id.name if delivery.assigned_driver_id else None}')
print(f' vehicle_id: {delivery.vehicle_id.name if delivery.vehicle_id else None}')
print(f' source_facility_id: {delivery.source_facility_id.name if delivery.source_facility_id else None}')
print(f' tdg_required: {delivery.tdg_required}')
print(f' pod_id: {delivery.pod_id.name if delivery.pod_id else None}')
# Tom schedules.
print()
print('[Tom] Clicks "Schedule"')
delivery.action_schedule()
print(f' state={delivery.state}')
# Tom tries to start route WITHOUT assigning a driver.
print()
print('[Tom] Tries Start Route without driver:')
try:
delivery.action_start_route()
print(' ❌ Got past driver gate without assignment!')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
# Assign a driver (any user).
driver = env.user
delivery.assigned_driver_id = driver.id
delivery.x_fc_box_count_out = 3
print()
print(f'[Tom] Assigned driver: {driver.name}, box_count_out=3')
# Now start route.
print()
print('[Tom] Clicks Start Route:')
try:
delivery.action_start_route()
print(f' state={delivery.state}')
print(f' custody events: {delivery.custody_event_count}')
except Exception as e:
print(f'{e}')
# Tom tries to mark delivered without POD.
print()
print('[Tom] Tries Mark Delivered without POD:')
try:
delivery.action_mark_delivered()
print(' ❌ Got past POD gate without capture!')
except Exception as e:
print(f' ✓ blocked: {str(e)[:120]}')
# Tom captures POD.
POD = env['fusion.plating.proof.of.delivery']
pod = POD.create({
'delivery_id': delivery.id,
'recipient_name': 'Mark at receiving',
})
delivery.pod_id = pod.id
print()
print(f'[Tom] Captured POD: {pod.name}, recipient="{pod.recipient_name}"')
# Mark delivered.
print()
print('[Tom] Clicks Mark Delivered:')
try:
delivery.action_mark_delivered()
print(f' state={delivery.state}, delivered_at={delivery.delivered_at}')
print(f' custody events: {delivery.custody_event_count}')
except Exception as e:
print(f'{e}')
env.cr.commit()
print()
print('== Step 7 complete ==')

View File

@@ -0,0 +1,58 @@
# Step 8 re-verify — fresh SO with net_terms strategy should now get
# Net-30 payment term auto-filled, and the invoice should post.
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
P = env['res.partner']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-STEP8RV-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
w._onchange_invoice_strategy() # also fires _apply_strategy_payment_term
print(f'[Sarah] After invoice_strategy=net_terms, payment_term_id={w.payment_term_id.name if w.payment_term_id else None}')
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 4, 'unit_price': 30.0,
})
r = w.action_create_order()
so = env['sale.order'].browse(r['res_id'])
print(f'[Sarah] SO {so.name} created, payment_term_id={so.payment_term_id.name if so.payment_term_id else None}')
so.action_confirm()
print(f'[Sarah] Confirmed → state={so.state}')
# Walk job to done so it's invoiceable.
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
for s in job.step_ids.sorted('sequence'):
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
job.button_mark_done()
print(f'[Carlos] Job {job.name} done')
# Jane invoices.
print()
print('[Jane] Creating + posting invoice:')
result = so._create_invoices()
inv = env['account.move'].search([('invoice_origin', '=', so.name)], order='id desc', limit=1)
print(f' Invoice {inv.name or "(unnamed draft)"}: state={inv.state}, payment_term={inv.invoice_payment_term_id.name if inv.invoice_payment_term_id else None}')
try:
inv.action_post()
print(f' ✓ Posted: state={inv.state}, payment_state={inv.payment_state}')
print(f' ✓ Invoice name: {inv.name}, due date: {inv.invoice_date_due}')
except Exception as e:
print(f'{e}')
env.cr.commit()
print()
print('== Step 8 re-verify complete ==')

View File

@@ -0,0 +1,76 @@
# Step 8 — Jane creates the invoice for the completed SO and posts it.
# Test:
# A) SO has invoice_status = 'to invoice' after delivery
# B) Jane creates the invoice
# C) Invoice draft has correct lines, taxes, payment terms
# D) Jane posts → invoice posted, account moves balanced
# E) Notification fires (best-effort)
so = env['sale.order'].browse(423)
print(f'[Jane] Looking at SO {so.name}')
print(f' state: {so.state}')
print(f' invoice_status: {so.invoice_status}')
print(f' amount_total: {so.amount_total} {so.currency_id.symbol}')
print(f' payment_term_id: {so.payment_term_id.name if so.payment_term_id else None}')
print(f' x_fc_invoice_strategy: {so.x_fc_invoice_strategy}')
print(f' partner.account hold? {getattr(so.partner_id, "x_fc_account_hold", False)}')
# What's already invoiced?
existing = env['account.move'].search([
('invoice_origin', '=', so.name),
])
print(f' Existing invoices for this SO: {len(existing)}')
for inv in existing:
print(f' {inv.name}: state={inv.state}, type={inv.move_type}, amount={inv.amount_total}')
# Path A: create invoices.
print()
print('[Jane] Clicks "Create Invoice"')
if so.invoice_status == 'to invoice':
try:
result = so._create_invoices()
new_invs = env['account.move'].search([
('invoice_origin', '=', so.name), ('id', 'not in', existing.ids),
])
print(f' Created {len(new_invs)} new invoice(s)')
for inv in new_invs:
print(f' {inv.name}: state={inv.state}, lines={len(inv.invoice_line_ids)}')
for ln in inv.invoice_line_ids:
print(f' - {ln.name[:50]}: qty={ln.quantity}, price={ln.price_unit}, subtotal={ln.price_subtotal}')
except Exception as e:
print(f'{e}')
else:
print(f' Skipped — invoice_status={so.invoice_status} (nothing to invoice)')
new_invs = env['account.move'].browse()
# Path B: post.
if new_invs:
inv = new_invs[0]
print()
print(f'[Jane] Posting invoice {inv.name}:')
try:
inv.action_post()
print(f' ✓ state={inv.state}')
print(f' payment_state={inv.payment_state}')
except Exception as e:
print(f'{e}')
# Verify the SO progress: invoice_status should now show 'invoiced'
print()
print(f'[Jane] After posting:')
print(f' SO invoice_status: {so.invoice_status}')
print(f' Outstanding receivables on partner: {sum(env["account.move"].search([("partner_id", "=", so.partner_id.id), ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("payment_state", "in", ("not_paid", "partial"))]).mapped("amount_residual"))}')
# Notification check.
print()
print(f'[Jane] Notification logs for this SO/invoice:')
NotifLog = env['fp.notification.log'] if 'fp.notification.log' in env else None
if NotifLog and new_invs:
logs = NotifLog.search([('source_record_id', 'in', new_invs.ids)])
print(f' {len(logs)} notification log(s)')
for lg in logs:
print(f' {lg.trigger_event}{lg.partner_id.name if lg.partner_id else "(no partner)"} sent_at={lg.sent_at if "sent_at" in lg._fields else "?"}')
env.cr.commit()
print()
print('== Step 8 complete ==')

View File

@@ -0,0 +1,138 @@
# Verify the auto-push-to-defaults behaviour.
#
# Four scenarios:
# A) Brand-new part (no defaults) → push_to_defaults auto-ticks +
# warning popup fires
# B) Existing part WITH defaults → push_to_defaults stays False (no
# surprise overwrite)
# C) Brand-new part flagged is_one_off → push_to_defaults stays False
# D) End-to-end: enter order with new part → confirm → second order
# with same part auto-pre-fills coating + treatments
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
P = env['res.partner']
target = P.browse(2529)
coating = Coating.search([], limit=1)
treats = Treat.search([], limit=2)
# ====================================================================== A
print('='*72)
print('Scenario A — Brand-new part (no defaults)')
print('='*72)
fresh = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-A-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Fresh part for auto-default test',
})
w = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = fresh
result = ln._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln.push_to_defaults} (expect True)')
print(f' is_one_off: {ln.is_one_off}')
print(f' warning fired? {bool(result and result.get("warning"))}')
if result and result.get('warning'):
w_msg = result['warning']
print(f' title: {w_msg["title"]}')
print(f' message: {w_msg["message"][:90]}...')
# ====================================================================== B
print()
print('='*72)
print('Scenario B — Existing part WITH defaults already set')
print('='*72)
existing = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
print(f' Using part: {existing.display_name} (default coating={existing.x_fc_default_coating_config_id.name})')
w2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w2._onchange_partner_id()
ln2 = Line.new({'wizard_id': w2.id})
ln2.part_catalog_id = existing
result2 = ln2._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln2.push_to_defaults} (expect False — defaults already exist)')
print(f' pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
print(f' warning fired? {bool(result2 and result2.get("warning"))} (expect False)')
# ====================================================================== C
print()
print('='*72)
print('Scenario C — Brand-new part flagged is_one_off (don\'t persist)')
print('='*72)
fresh3 = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-C-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
})
w3 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w3._onchange_partner_id()
ln3 = Line.new({'wizard_id': w3.id, 'is_one_off': True})
ln3.part_catalog_id = fresh3
result3 = ln3._onchange_part_clears_variant()
print(f' push_to_defaults after onchange: {ln3.push_to_defaults} (expect False — is_one_off blocks)')
print(f' warning fired? {bool(result3 and result3.get("warning"))} (expect False)')
# ====================================================================== D
print()
print('='*72)
print('Scenario D — End-to-end: order #1 saves defaults, order #2 pre-fills')
print('='*72)
fresh_d = Part.create({
'partner_id': target.id,
'part_number': 'AUTODEF-D-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
})
print(f' Created fresh part: {fresh_d.part_number}')
# ORDER #1
w_d = W.create({'partner_id': target.id, 'po_pending': True, 'po_number': 'PO-AUTO-D-1', 'invoice_strategy': 'net_terms'})
w_d._onchange_partner_id()
ln_d = Line.new({'wizard_id': w_d.id})
ln_d.part_catalog_id = fresh_d
ln_d._onchange_part_clears_variant()
print(f' Order #1 line — auto-ticked push_to_defaults: {ln_d.push_to_defaults}')
# Sarah picks the coating + treatments she wants
saved = Line.create({
'wizard_id': w_d.id, 'part_catalog_id': fresh_d.id,
'coating_config_id': coating.id,
'treatment_ids': [(6, 0, treats.ids)],
'push_to_defaults': True,
'quantity': 5, 'unit_price': 12.0,
})
print(f' Sarah picked coating={coating.name}, treatments={treats.mapped("name")}')
# Confirm
result = w_d.action_create_order()
print(f' Order created: {env["sale.order"].browse(result["res_id"]).name}')
# Re-fetch the part
fresh_d.invalidate_recordset()
print(f' Part defaults after order #1:')
print(f' x_fc_default_coating_config_id: {fresh_d.x_fc_default_coating_config_id.name if fresh_d.x_fc_default_coating_config_id else "(none)"}')
print(f' x_fc_default_treatment_ids: {fresh_d.x_fc_default_treatment_ids.mapped("name") if fresh_d.x_fc_default_treatment_ids else "(none)"}')
# ORDER #2 — Sarah picks the same part again
print()
print(' Order #2 — Sarah picks the same part:')
w_d2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
w_d2._onchange_partner_id()
ln_d2 = Line.new({'wizard_id': w_d2.id})
ln_d2.part_catalog_id = fresh_d
ln_d2._onchange_part_clears_variant()
print(f' Pre-filled coating: {ln_d2.coating_config_id.name if ln_d2.coating_config_id else "(none)"}')
print(f' Pre-filled treatments: {ln_d2.treatment_ids.mapped("name") if ln_d2.treatment_ids else "(none)"}')
print(f' push_to_defaults: {ln_d2.push_to_defaults} (expect False — defaults exist)')
if ln_d2.coating_config_id == coating:
print(f' ✓ Order #2 correctly auto-filled from order #1\'s saved defaults')
else:
print(f' ❌ Order #2 did NOT pre-fill from order #1\'s defaults')
env.cr.commit()
print()
print('== Auto-default test complete ==')

View File

@@ -0,0 +1,171 @@
# Comprehensive internal-process walk.
#
# Phases:
# A) Pause / resume — multiple intervals merge into duration_actual
# B) Skip an opt-in step
# C) Skipped steps don't block job mark-done
# D) Wet plating step finish auto-spawns bake.window with right window_hours
# E) Bake-window state evolves (awaiting_bake → bake_in_progress → baked)
# F) Failure: try to start a step already done
import time
from datetime import timedelta
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
target = P.browse(2529)
# Find or build a coating that requires bake relief.
coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
if not coating:
coating = Coating.search([], limit=1)
coating.requires_bake_relief = True
coating.bake_window_hours = 4.0
coating.bake_temperature = 375.0
coating.bake_temperature_uom = 'F'
coating.bake_duration_hours = 4.0
print(f'[setup] Configured {coating.name} to require bake relief (4h window @ 375°F for 4h)')
else:
print(f'[setup] Using existing bake-required coating: {coating.name} ({coating.bake_window_hours}h window)')
# Build a part using this coating as default.
part = Part.create({
'partner_id': target.id,
'part_number': 'INT-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Internal-process test bracket',
'substrate_material': 'steel',
'x_fc_default_coating_config_id': coating.id,
})
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-INT-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': coating.id,
'quantity': 5, 'unit_price': 22.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[setup] Job {job.name} with {len(job.step_ids)} steps')
# ====================================================================== A
print()
print('='*72)
print('A — Pause + resume on a step. Multiple intervals must merge.')
print('='*72)
masking = job.step_ids.sorted('sequence')[0]
masking.button_start()
print(f' start → state={masking.state}, open logs={len(masking.time_log_ids)}')
time.sleep(2)
masking.button_pause()
print(f' pause → state={masking.state}, logs={len(masking.time_log_ids)}, '
f'log[0]={masking.time_log_ids[0].duration_minutes:.3f} min')
time.sleep(2)
masking.button_start() # resume
print(f' resume → state={masking.state}, logs={len(masking.time_log_ids)}')
time.sleep(2)
masking.button_finish()
print(f' finish → state={masking.state}, logs={len(masking.time_log_ids)}')
total = sum(masking.time_log_ids.mapped('duration_minutes'))
print(f' duration_actual={masking.duration_actual:.3f} min (sum of logs={total:.3f} min)')
if abs(masking.duration_actual - total) < 0.001:
print(f' ✓ Pause/resume merged correctly')
else:
print(f' ❌ Mismatch')
# ====================================================================== B
print()
print('='*72)
print('B — Skip an opt-in step')
print('='*72)
racking = job.step_ids.sorted('sequence')[1]
print(f' Step: {racking.name} state={racking.state}')
racking.button_skip()
print(f' After Skip: state={racking.state}')
if racking.state == 'skipped':
print(f' ✓ Skip works')
# ====================================================================== C — walk rest, then mark-done
print()
print('='*72)
print('C — Walk remaining steps (some will spawn bake-window). Mark job done.')
print('='*72)
spawn_count_before = env['fusion.plating.bake.window'].search_count([])
for s in job.step_ids.sorted('sequence'):
if s.state in ('done', 'skipped', 'cancelled'):
continue
if s.state in ('pending', 'ready'):
s.button_start()
if s.state == 'in_progress':
s.button_finish()
spawn_count_after = env['fusion.plating.bake.window'].search_count([])
created_bw = spawn_count_after - spawn_count_before
print(f' Walked all remaining steps to done')
print(f' Bake windows spawned during walk: {created_bw}')
bws = env['fusion.plating.bake.window'].search([('part_ref', '=', job.name)])
for bw in bws:
print(f' {bw.name}: state={bw.state}, plate_exit={bw.plate_exit_time}, required_by={bw.bake_required_by}, time_remaining={bw.time_remaining_display}')
# ====================================================================== D — try to mark job done
print()
print('='*72)
print('D — Mark job done (skipped+done steps both count as terminal)')
print('='*72)
try:
job.button_mark_done()
print(f' ✓ Job done — state={job.state}')
except Exception as e:
print(f'{e}')
# ====================================================================== E — bake-window lifecycle
if bws:
bw = bws[0]
print()
print('='*72)
print('E — Bake-window lifecycle: start → end')
print('='*72)
print(f' Before start: state={bw.state}, color={bw.status_color}')
bw.action_start_bake()
print(f' After start_bake: state={bw.state}, bake_start={bw.bake_start_time}, color={bw.status_color}')
time.sleep(1)
bw.action_end_bake()
print(f' After end_bake: state={bw.state}, bake_end={bw.bake_end_time}, duration_h={bw.bake_duration_hours:.4f}')
# ====================================================================== F — failure: start a done step
print()
print('='*72)
print('F — Failure paths')
print('='*72)
done_step = job.step_ids.filtered(lambda s: s.state == 'done')[:1]
if done_step:
try:
done_step.button_start()
print(f' ❌ Allowed re-start of a done step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
# Try to skip an already-done step
try:
done_step.button_skip()
print(f' ❌ Allowed skip of done step')
except Exception as e:
print(f' ✓ Blocked: {str(e)[:80]}')
env.cr.commit()
print()
print('== Internal-process walk complete ==')

View File

@@ -0,0 +1,146 @@
# Internal-process walk — test time tracking, pause, skip, bake-window
# auto-spawn, duration overrun. Persona: Carlos (operator) walking the
# tablet station for a real plating job.
#
# Goals:
# 1) Time tracking captures every start/stop interval correctly
# 2) Multiple intervals (start/finish/start/finish) sum to duration_actual
# 3) Pause / resume flow works (currently NOT implemented — gap to fix)
# 4) Skip flow works for opt-in steps (currently NOT implemented)
# 5) Wet plating step finishing auto-spawns a bake.window when the
# coating requires hydrogen embrittlement relief
# 6) Bake-window state machine reflects elapsed time
import time
from datetime import timedelta
from odoo import fields
# Set up a fresh job to walk.
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
P = env['res.partner']
Part = env['fp.part.catalog']
target = P.browse(2529)
part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-INTERNAL-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
Line.create({
'wizard_id': w.id, 'part_catalog_id': part.id,
'coating_config_id': part.x_fc_default_coating_config_id.id,
'quantity': 5, 'unit_price': 18.0,
})
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[setup] Fresh job {job.name} with {len(job.step_ids)} steps')
# ====================================================================== STEP 1
print()
print('='*72)
print('STEP 1 — Carlos opens the first step on the tablet, clicks Start')
print('='*72)
first = job.step_ids.sorted('sequence')[0]
print(f' Step: {first.name} (kind={first.kind}, state={first.state})')
print(f' duration_expected: {first.duration_expected} min')
before = fields.Datetime.now()
first.button_start()
print(f' After Start: state={first.state}, date_started={first.date_started}, started_by={first.started_by_user_id.name}')
print(f' Open time-log rows: {len(first.time_log_ids.filtered(lambda l: not l.date_finished))}')
# ====================================================================== STEP 2
print()
print('='*72)
print('STEP 2 — Carlos works for 6 seconds, then clicks Finish')
print('='*72)
time.sleep(6)
first.button_finish()
print(f' After Finish: state={first.state}, date_finished={first.date_finished}')
print(f' Time-log rows: {len(first.time_log_ids)}')
for log in first.time_log_ids:
print(f' - {log.user_id.name} {log.date_started}{log.date_finished or "OPEN"} = {log.duration_minutes:.3f} min')
print(f' duration_actual: {first.duration_actual:.3f} min')
print(f' ✓ Single interval captured cleanly')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Test pause/resume on the next step (currently NotImplementedError)')
print('='*72)
second = job.step_ids.sorted('sequence')[1]
second.button_start()
print(f' Started step: {second.name} (state={second.state})')
print(f' Carlos now needs a smoke break — clicks Pause')
try:
second.button_pause()
print(f' ✓ Paused: state={second.state}, open timelog={len(second.time_log_ids.filtered(lambda l: not l.date_finished))}')
except NotImplementedError as e:
print(f' ❌ button_pause not implemented: {e}')
except Exception as e:
print(f'{type(e).__name__}: {e}')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Test skip (currently NotImplementedError)')
print('='*72)
third = job.step_ids.sorted('sequence')[2]
print(f' Step: {third.name}, state={third.state}')
print(f' Planner wants to skip this opt-in step')
try:
third.button_skip()
print(f' ✓ Skipped: state={third.state}')
except NotImplementedError as e:
print(f' ❌ button_skip not implemented: {e}')
except Exception as e:
print(f'{type(e).__name__}: {e}')
# ====================================================================== STEP 5
print()
print('='*72)
print('STEP 5 — Wet plating step finishes, does a bake.window auto-spawn?')
print('='*72)
# Find a step with kind='wet' (or use step #4 as plating analog)
wet_step = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
if not wet_step:
wet_step = job.step_ids.sorted('sequence')[3:4]
print(f' Using as plating step: {wet_step.name} (kind={wet_step.kind})')
coating = job.coating_config_id
print(f' Coating: {coating.name}')
print(f' coating.requires_bake_relief: {coating.requires_bake_relief}')
print(f' coating.bake_window_hours: {coating.bake_window_hours}')
# Count bake.window before
BW = env['fusion.plating.bake.window']
bw_before = BW.search_count([('part_ref', '=', job.name)])
print(f' Bake windows for this job BEFORE finish: {bw_before}')
# Skip if currently in_progress (it is — paused step #2 still open)
if wet_step.state in ('pending', 'ready'):
wet_step.button_start()
if wet_step.state == 'in_progress':
wet_step.button_finish()
print(f' After Finish: state={wet_step.state}')
bw_after = BW.search_count([('part_ref', '=', job.name)])
print(f' Bake windows for this job AFTER finish: {bw_after}')
if coating.requires_bake_relief and bw_after == bw_before:
print(f' ❌ Coating requires bake relief BUT no bake.window was auto-created!')
elif not coating.requires_bake_relief:
print(f' (coating doesn\'t require bake relief — auto-spawn would skip anyway)')
else:
print(f' ✓ Bake window spawned')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,158 @@
# Walk: Sarah opens Direct Order, creates a brand-new part inline, attaches a process.
#
# Personas:
# Sarah (CSR) — driving the wizard
#
# What we're testing:
# 1) Wizard now allows creating a new part (no_quick_create lets the
# "Create and edit..." popup through)
# 2) Sarah enters a brand-new part number for the customer
# 3) Sarah picks coating + treatments
# 4) Variant dropdown is empty for the brand-new part (no variants yet)
# 5) On confirm, the part is saved to catalog + the SO line links to it
# 6) The job uses the coating's recipe as fallback (no variant means
# coating.recipe_id wins)
# 7) Sarah can THEN go to the part form, hit Compose, attach 1+ variants,
# and the next order can pick one
from odoo import fields
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
P = env['res.partner']
target = P.browse(2529) # Cyclone Manufacturing
default_coating = Coating.search([], limit=1)
print(f'[Sarah] Customer: {target.display_name}')
print(f'[Sarah] Picking coating: {default_coating.name}')
print()
# ====================================================================== STEP 2
print('='*72)
print('STEP 2 — Sarah opens wizard, hits "Create and edit..." on Part field')
print('='*72)
w = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-NEWPART-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
print(f'[Sarah] Wizard {w.name} created.')
# In the UI, Sarah types a new part number → dropdown shows nothing →
# clicks "Create and edit..." → popup opens with partner pre-filled →
# fills part_number + name + revision (default A) → saves.
# Programmatic equivalent: just create the part directly.
new_part = Part.create({
'partner_id': target.id,
'part_number': 'NEW-INLINE-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'Inline-created bracket',
'substrate_material': 'aluminium',
})
print(f'[Sarah] Filled popup → created part: {new_part.display_name}')
print(f' partner_id correctly set: {new_part.partner_id.name}')
print(f' part_number: {new_part.part_number}')
print(f' revision: {new_part.revision}')
print(f' default_process_id: {new_part.default_process_id.name if new_part.default_process_id else "(none — no variants composed yet)"}')
print(f' process_variant_count: {new_part.process_variant_count}')
# Now Sarah adds a line with the new part.
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = new_part
ln._onchange_part_clears_variant()
print()
print(f'[Sarah] Adds line with new part:')
print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none — new part has no defaults)"}')
print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
# Sarah picks coating manually (since new part has no defaults).
print(f'[Sarah] Manually picks coating: {default_coating.name}')
# Save the line.
real_line = Line.create({
'wizard_id': w.id,
'part_catalog_id': new_part.id,
'coating_config_id': default_coating.id,
'quantity': 8,
'unit_price': 18.0,
})
# Check variant dropdown availability
print()
print(f'[Sarah] Variant dropdown for new part:')
print(f' Available variants: {len(new_part.process_variant_ids)} (expect 0 — none composed yet)')
print(f' → Sarah leaves variant blank; coating.recipe_id will drive job')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Sarah confirms order, verify part landed in catalog + job uses coating recipe')
print('='*72)
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
print(f'[Sarah] SO {so.name} created.')
# Confirm
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f'[Sarah] Confirmed → job {job.name}')
print(f' job.partner_id: {job.partner_id.name}')
print(f' job.part_catalog_id: {job.part_catalog_id.display_name}')
print(f' job.coating_config_id: {job.coating_config_id.name}')
print(f' job.recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
print(f' → Coating recipe used as fallback (correct, no variant picked)')
# Verify part is in catalog
print()
fetched = Part.search([('part_number', '=', new_part.part_number)], limit=1)
print(f' Part survives in catalog: {fetched.display_name} (id={fetched.id})')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Bob attaches a variant to the new part (compose flow)')
print('='*72)
from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
import _clone_subtree
Node = env['fusion.plating.process.node']
template = Node.search([
('node_type', '=', 'recipe'),
('parent_id', '=', False),
('part_catalog_id', '=', False),
], limit=1)
v1 = _clone_subtree(env, template, new_part, parent=False)
v1.variant_label = 'Standard'
v1.is_default_variant = True
new_part.default_process_id = v1.id
print(f'[Bob] Composed 1 variant: "{v1.variant_label}" (root id={v1.id})')
new_part.invalidate_recordset()
print(f' process_variant_count now: {new_part.process_variant_count}')
print(f' default_process_id: {new_part.default_process_id.name}')
# Now Sarah enters a SECOND order — this time variant dropdown should show "Standard"
print()
print('='*72)
print('STEP 5 — Sarah enters a follow-up order; variant dropdown should now show "Standard"')
print('='*72)
w2 = W.create({
'partner_id': target.id, 'po_pending': True,
'po_number': 'PO-NEWPART-2-' + fields.Datetime.now().strftime('%H%M%S'),
'invoice_strategy': 'net_terms',
})
w2._onchange_partner_id()
ln2 = Line.new({'wizard_id': w2.id})
ln2.part_catalog_id = new_part
ln2._onchange_part_clears_variant()
print(f'[Sarah] Picked the same part again. Variant dropdown:')
for v in new_part.process_variant_ids:
flag = '' if v.is_default_variant else ' '
print(f' {flag} {v.variant_label or v.name}')
print(f' Pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,190 @@
# Walk part creation + 4 process variants step by step.
# Personas:
# Bob (Estimator) — owns the part catalog, designs process variants
# Sarah (CSR) — picks a variant on order entry
#
# Goal: prove that
# 1) Bob can create a part
# 2) Bob can attach 4 distinct process variants via the Composer flow
# 3) One is flagged default; switching default works
# 4) Sarah opens a Direct Order, picks the part — variant dropdown lists ALL FOUR
# 5) Sarah picks a non-default variant; the SO + job actually use it
from odoo import fields
from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
import _list_variants, _clone_subtree
P = env['res.partner']
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
Treat = env['fp.treatment']
Node = env['fusion.plating.process.node']
Tpl = Node # template recipes are also fp.process.node records
# ====================================================================== STEP 2
print('='*72)
print('STEP 2 — Bob creates a brand-new part')
print('='*72)
target_partner = P.browse(2529) # 2CM INNOVATIVE
default_coating = Coating.search([], limit=1)
default_treats = Treat.search([], limit=2)
part = Part.create({
'partner_id': target_partner.id,
'part_number': 'E2E-VAR-' + fields.Datetime.now().strftime('%H%M%S'),
'revision': 'A',
'name': 'E2E variant test bracket',
'substrate_material': 'aluminium',
'surface_area': 12.5,
'surface_area_uom': 'sq_in',
'weight': 0.45,
'complexity': 'simple',
'masking_zones': 1,
'x_fc_default_coating_config_id': default_coating.id,
'x_fc_default_treatment_ids': [(6, 0, default_treats.ids)],
})
print(f'[Bob] Created part: {part.display_name} (id={part.id})')
print(f' default coating: {part.x_fc_default_coating_config_id.name}')
print(f' default treatments: {default_treats.mapped("name")}')
print(f' process_variant_count (BEFORE adding any): {part.process_variant_count}')
# Find a shared template recipe to clone from. Templates = fp.process.node
# records with node_type='recipe', parent_id=False, part_catalog_id=False.
template = Node.search([
('node_type', '=', 'recipe'),
('parent_id', '=', False),
('part_catalog_id', '=', False),
], limit=1)
if not template:
print(' ❌ No shared template recipes available — cannot continue!')
raise SystemExit
print(f'[Bob] Will clone from shared template: {template.name} ({len(template.child_ids)} root children)')
# ====================================================================== STEP 3
print()
print('='*72)
print('STEP 3 — Bob adds variant #1: Standard Production')
print('='*72)
v1 = _clone_subtree(env, template, part, parent=False)
v1.variant_label = 'Standard Production'
v1.is_default_variant = True
part.default_process_id = v1.id
print(f'[Bob] Created variant: {v1.variant_label} (root node id={v1.id}, name="{v1.name}")')
print(f' is_default: {v1.is_default_variant}')
print(f' child nodes cloned: {len(v1.child_ids)}')
# ====================================================================== STEP 4
print()
print('='*72)
print('STEP 4 — Bob adds variant #2: Aerospace Cert (AS9100)')
print('='*72)
v2 = _clone_subtree(env, template, part, parent=False)
v2.variant_label = 'Aerospace Cert (AS9100)'
print(f'[Bob] Created variant: {v2.variant_label} (root id={v2.id})')
print(f' is_default: {v2.is_default_variant} (correct — first one stays default)')
# ====================================================================== STEP 5
print()
print('='*72)
print('STEP 5 — Bob adds variant #3: Quick-turn (no bake)')
print('='*72)
v3 = _clone_subtree(env, template, part, parent=False)
v3.variant_label = 'Quick-turn (no bake)'
print(f'[Bob] Created variant: {v3.variant_label} (root id={v3.id})')
# ====================================================================== STEP 6
print()
print('='*72)
print('STEP 6 — Bob adds variant #4: Heavy build (wear)')
print('='*72)
v4 = _clone_subtree(env, template, part, parent=False)
v4.variant_label = 'Heavy build (wear)'
print(f'[Bob] Created variant: {v4.variant_label} (root id={v4.id})')
# Refresh the part and inspect what the form would show.
part.invalidate_recordset()
print()
print(f'[Bob] After 4 adds — part {part.display_name}:')
print(f' process_variant_count: {part.process_variant_count}')
print(f' default_process_id: {part.default_process_id.name if part.default_process_id else None}')
print(f' Variants list (per Composer endpoint /fp/part/composer/state):')
for entry in _list_variants(part):
flag = '★ default' if entry['is_default'] else ' '
print(f' {flag} id={entry["id"]:>5} "{entry["label"]}"{entry["node_count"]} nodes')
# ====================================================================== STEP 7
print()
print('='*72)
print('STEP 7 — Sarah enters a Direct Order, picks the part, picks a variant')
print('='*72)
W = env['fp.direct.order.wizard']
Line = env['fp.direct.order.line']
w = W.create({
'partner_id': target_partner.id, 'po_pending': True,
'po_number': 'PO-VARTEST-001',
'invoice_strategy': 'net_terms',
})
w._onchange_partner_id()
# Sarah adds a line, picks the part. Onchange should pre-fill default coating.
ln = Line.new({'wizard_id': w.id})
ln.part_catalog_id = part
ln._onchange_part_clears_variant()
print(f'[Sarah] Picked part {part.part_number}.')
print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none)"}')
print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
# What variants would the dropdown show? Inspect process_variant_id field domain.
print()
print(f'[Sarah] Looking at the Variant dropdown on the line:')
# Domain on x_fc_process_variant_id (defined on sale.order.line) is part-scoped.
# For the wizard line it's process_variant_id with the same domain.
visible_variants = part.process_variant_ids
print(f' Domain: part_scoped (id, child_of, ...). Visible variants: {len(visible_variants)}')
for v in visible_variants:
flag = '' if v.is_default_variant else ' '
print(f' {flag} {v.variant_label or v.name} (id={v.id})')
# Sarah picks variant #3 (Quick-turn).
ln.process_variant_id = v3
print()
print(f'[Sarah] Picked variant: {ln.process_variant_id.variant_label}')
# Persist via Line.create with the chosen variant.
new_line = Line.create({
'wizard_id': w.id,
'part_catalog_id': part.id,
'coating_config_id': default_coating.id,
'process_variant_id': v3.id,
'quantity': 5,
'unit_price': 25.0,
})
print(f' Saved line: process_variant_id={new_line.process_variant_id.variant_label}')
# ====================================================================== STEP 8
print()
print('='*72)
print('STEP 8 — Confirm SO; verify the JOB uses variant #3, not the default')
print('='*72)
result = w.action_create_order()
so = env['sale.order'].browse(result['res_id'])
print(f'[Sarah] SO created: {so.name}')
# Inspect the SO line's variant.
sol = so.order_line[:1]
print(f' SO line process_variant_id: {sol.x_fc_process_variant_id.variant_label if sol.x_fc_process_variant_id else "(none)"}')
# Confirm the SO.
so.action_confirm()
job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
print(f' Job created: {job.name}')
print(f' Job recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
print(f' EXPECTED: recipe_id should match variant #3 root (id={v3.id}, name="{v3.name}")')
print(f' ACTUAL: recipe_id={job.recipe_id.id} (name="{job.recipe_id.name}")')
if job.recipe_id.id == v3.id:
print(f' ✓ Job correctly inherited the picked variant')
else:
print(f' ❌ Job did NOT use the picked variant! Recipe is {job.recipe_id.name}, expected {v3.name}')
env.cr.commit()
print()
print('== Walk complete ==')

View File

@@ -0,0 +1,399 @@
# -*- 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()

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Sub 12 Phase F — end-to-end smoke test.
#
# Run via odoo-shell:
# /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin
# >>> exec(open('/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_smoke_test.py').read())
#
# Walks the full Sub 12 lifecycle and asserts at each step.
import logging
_log = logging.getLogger(__name__)
def _resolve_test_partner(env):
"""Pick a partner with at least one sale order so the RMA can bind."""
so = env['sale.order'].search([('state', 'in', ('sale', 'done'))], limit=1)
if not so:
raise RuntimeError('No confirmed sale.order found — seed one first.')
return so.partner_id, so
def smoke():
e = env # noqa -- env injected by odoo-shell
print('--- Sub 12 smoke test ---')
# Reset any stale half-completed test artefacts so the script is idempotent.
e['fusion.plating.rma'].search([('name', 'like', 'RMA/SUB12-SMOKE-%')]).unlink()
partner, so = _resolve_test_partner(e)
print(f' Using partner: {partner.display_name} (SO {so.name})')
# 1. Create RMA (draft)
rma = e['fusion.plating.rma'].create({
'partner_id': partner.id,
'sale_order_id': so.id,
'sale_order_line_ids': [(6, 0, so.order_line[:1].ids)] if so.order_line else False,
'trigger_source': 'customer_complaint',
'severity': 'high',
'qty_returned': 5,
'complaint_description': '<p>Smoke-test RMA. Auto-issued for Sub 12 verification.</p>',
})
print(f' ✓ Created RMA {rma.name} (state={rma.state})')
assert rma.state == 'draft', f'Expected draft, got {rma.state}'
# 2. Authorise
rma.action_authorise()
assert rma.state == 'authorised'
print(f' ✓ Authorised — state={rma.state}, qr_code present={bool(rma.qr_code)}')
# 3. Mark shipped
rma.action_mark_shipped_to_us()
assert rma.state == 'shipped_to_us'
print(f' ✓ Customer shipped — state={rma.state}')
# 4. Auto-receive via fp.receiving create. The RMA-link create-hook
# walks the receiving from draft → counted → staged → closed in one
# shot so the SO's x_fc_receiving_status flips to "received".
receiving = e['fp.receiving'].create({
'sale_order_id': so.id,
'rma_id': rma.id,
'box_count_in': 1,
'expected_qty': 5,
'received_qty': 5,
})
print(f' ✓ Created fp.receiving {receiving.name} → RMA state={rma.state}, recv state={receiving.state}, SO status={so.x_fc_receiving_status}')
assert rma.state == 'received', f'Expected received, got {rma.state}'
assert receiving.state == 'closed', f'Expected receiving closed, got {receiving.state}'
assert so.x_fc_receiving_status == 'received', \
f'Expected SO status received, got {so.x_fc_receiving_status}'
# 5. Verify auto-spawn fired
assert rma.linked_ncr_ids, 'Auto-NCR was not spawned'
assert rma.linked_hold_ids, 'Auto-Hold was not spawned'
ncr = rma.linked_ncr_ids[0]
hold = rma.linked_hold_ids[0]
print(f' ✓ Auto-spawned NCR {ncr.name} + Hold {hold.name}')
# 6. Set resolution + triage
rma.resolution_type = 'rework'
rma.action_triage_complete()
assert rma.state == 'triaged'
print(f' ✓ Triage complete — state={rma.state}, resolution=rework')
# 7. Start resolving
rma.action_start_resolving()
assert rma.state == 'resolving'
print(f' ✓ Resolving — state={rma.state}')
# 8. NCR walk: open → containment → root cause → close
ncr.action_open()
ncr.action_containment()
ncr.containment = '<p>Smoke-test: parts segregated for re-rack.</p>'
ncr.action_disposition()
ncr.disposition = 'rework'
ncr.root_cause = '<p>Smoke-test: rack contact loss during transit.</p>'
# 9. Spawn CAPA from NCR (uses severity gate — high passes)
spawn_action = ncr.action_spawn_capa()
capa = e['fusion.plating.capa'].browse(spawn_action.get('res_id'))
print(f' ✓ Spawned CAPA {capa.name} from NCR')
assert capa.ncr_id == ncr, 'CAPA not linked to NCR'
# 10. Walk CAPA: analysis → implementation → verification → effective
capa.action_start_analysis()
capa.root_cause_analysis = '<p>Smoke-test: 5 Whys → packaging gap.</p>'
capa.action_start_implementation()
capa.action_plan = '<p>Smoke-test: revise packaging SOP.</p>'
capa.action_start_verification()
capa.effectiveness_notes = '<p>Smoke-test: 30 days no recurrence.</p>'
capa.action_mark_effective()
print(f' ✓ CAPA marked effective — state={capa.state}')
assert capa.state == 'effective'
# 11. Close NCR
ncr.action_close()
assert ncr.state == 'closed'
print(f' ✓ NCR closed — state={ncr.state}')
# 11b. Release the auto-spawned Hold (rework path) so the RMA close
# gate doesn't block. action_close on RMA refuses if any Hold is
# still on_hold or under_review.
hold.action_send_to_rework()
print(f' ✓ Hold sent to rework — state={hold.state}')
# 12. Resolve RMA (will spawn replacement job for rework)
rma.action_resolve()
print(f' ✓ RMA resolved — state={rma.state}, replacement_job={rma.replacement_job_id.name if rma.replacement_job_id else None}')
assert rma.state == 'resolved'
# 13. Close RMA
rma.action_close()
assert rma.state == 'closed'
print(f' ✓ RMA closed — state={rma.state}')
# 14. Stage_id sync sanity
print(f' ✓ NCR stage_id={ncr.stage_id.name if ncr.stage_id else "(none)"}')
print(f' ✓ RMA stage_id={rma.stage_id.name if rma.stage_id else "(none)"}')
# 15. Counts smoke (read directly — controller needs http context).
open_holds = e['fusion.plating.quality.hold'].search_count([
('state', 'in', ('on_hold', 'under_review')),
])
open_ncrs = e['fusion.plating.ncr'].search_count([
('state', 'in', ('open', 'containment', 'disposition')),
])
open_rmas = e['fusion.plating.rma'].search_count([
('state', 'not in', ('closed', 'cancelled')),
])
print(f' ✓ Dashboard counts (post-test): holds={open_holds}, ncrs={open_ncrs}, rmas={open_rmas}')
e.cr.commit()
print('--- Sub 12 smoke test PASSED ---')
smoke()