211 lines
8.3 KiB
Python
211 lines
8.3 KiB
Python
# 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 ==')
|