# 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 ==')