changes
This commit is contained in:
210
fusion_plating/fusion_plating_quality/scripts/battle_test.py
Normal file
210
fusion_plating/fusion_plating_quality/scripts/battle_test.py
Normal 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 ==')
|
||||
150
fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
Normal file
150
fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
Normal 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 ==')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.0–5.5 g/L (Fischerscope reading)</li>'
|
||||
'<li>pH must be 4.4–4.8 — adjust with ammonium hydroxide if needed</li>'
|
||||
'<li>Bath temp 88–93°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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
107
fusion_plating/fusion_plating_quality/scripts/step3_verify.py
Normal file
107
fusion_plating/fusion_plating_quality/scripts/step3_verify.py
Normal 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} ==')
|
||||
@@ -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()
|
||||
@@ -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 ==')
|
||||
103
fusion_plating/fusion_plating_quality/scripts/step6_verify.py
Normal file
103
fusion_plating/fusion_plating_quality/scripts/step6_verify.py
Normal 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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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 ==')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user