chore(plating): de-dash shipped code + intake-neutral customer emails
Replace em-dashes and en-dashes with hyphens across 789 shipped source files (py/xml/js/scss) so the delivered module reads as human-written; em-dashes had become a recognizable AI-generated tell. Internal .md dev notes are excluded. The WO-sticker mojibake strippers keep their dash search targets (now written — / –). No logic changes: comments and display strings only; validated with py_compile + lxml parse. Rewrite the 7 customer notification emails to be intake-neutral (ship-in / drop-off / pickup) and repair-aware, and fix the Shipped email documents line (packing slip vs bill of lading; certificate only when issued). Subjects use a hyphen separator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Battle test — real shop failure modes.
|
||||
# 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.
|
||||
@@ -41,7 +41,7 @@ def make_job(po_suffix):
|
||||
|
||||
# ====================================================================== 1
|
||||
print('='*72)
|
||||
print('SCENARIO 1 — Carlos forgot to click Start. Realizes 2 hours later.')
|
||||
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]
|
||||
@@ -58,7 +58,7 @@ 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('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]
|
||||
@@ -77,7 +77,7 @@ 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('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]
|
||||
@@ -97,7 +97,7 @@ except Exception as e:
|
||||
# ====================================================================== 4
|
||||
print()
|
||||
print('='*72)
|
||||
print('SCENARIO 4 — Operator finishes step #6 before #5 is started.')
|
||||
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')
|
||||
@@ -119,7 +119,7 @@ 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('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]
|
||||
@@ -141,7 +141,7 @@ print(f' Bob finishes: state={step.state}, finished_by={step.finished_by_user_i
|
||||
# ====================================================================== 6
|
||||
print()
|
||||
print('='*72)
|
||||
print('SCENARIO 6 — Bake window expired (operator at lunch). Override?')
|
||||
print('SCENARIO 6 - Bake window expired (operator at lunch). Override?')
|
||||
print('='*72)
|
||||
BW = env['fusion.plating.bake.window']
|
||||
Bath = env['fusion.plating.bath']
|
||||
@@ -169,7 +169,7 @@ except Exception as e:
|
||||
# ====================================================================== 7
|
||||
print()
|
||||
print('='*72)
|
||||
print('SCENARIO 7 — Operator clocks 6 hours on a step expected to take 30 min.')
|
||||
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]
|
||||
@@ -186,7 +186,7 @@ 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('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}')
|
||||
@@ -196,7 +196,7 @@ for s in job.step_ids.sorted('sequence'):
|
||||
s.button_start()
|
||||
if s.state == 'in_progress':
|
||||
s.button_finish()
|
||||
# Try to mark done — qty_done is still 0
|
||||
# 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}')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Battle test v2 — re-verify after fixes for: bake-window override,
|
||||
# Battle test v2 - re-verify after fixes for: bake-window override,
|
||||
# duration overrun chatter, qty reconciliation, recompute-duration.
|
||||
|
||||
from datetime import timedelta
|
||||
@@ -32,7 +32,7 @@ def make_job(po_suffix):
|
||||
|
||||
# ====================================================================== Fix 1
|
||||
print('='*72)
|
||||
print('FIX 1 — Bake-window: missed_window blocks, manager override allowed + audited')
|
||||
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']
|
||||
@@ -47,7 +47,7 @@ BW._cron_update_states()
|
||||
expired.invalidate_recordset()
|
||||
print(f' Window {expired.name} state: {expired.state}')
|
||||
|
||||
# Naive operator (no override) — should fail
|
||||
# Naive operator (no override) - should fail
|
||||
try:
|
||||
expired.action_start_bake()
|
||||
print(f' ❌ start_bake worked without override')
|
||||
@@ -67,7 +67,7 @@ except Exception as e:
|
||||
# ====================================================================== Fix 2
|
||||
print()
|
||||
print('='*72)
|
||||
print('FIX 2 — Duration overrun: > 1.5x expected posts chatter warning')
|
||||
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]
|
||||
@@ -90,7 +90,7 @@ if overrun_msgs:
|
||||
# ====================================================================== Fix 3
|
||||
print()
|
||||
print('='*72)
|
||||
print('FIX 3 — Qty reconciliation: job mark-done blocks if qty mismatch')
|
||||
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'):
|
||||
@@ -120,7 +120,7 @@ except Exception as e:
|
||||
# ====================================================================== Fix 4
|
||||
print()
|
||||
print('='*72)
|
||||
print('FIX 4 — Supervisor edits timelog → Recompute Duration action picks it up')
|
||||
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]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Battle test — post-shop state machine (awaiting_cert + awaiting_ship).
|
||||
"""Battle test - post-shop state machine (awaiting_cert + awaiting_ship).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md
|
||||
@@ -16,7 +16,7 @@ The script rolls back at the end so it leaves the DB clean.
|
||||
2. Walk every step to terminal → assert state='awaiting_cert' (or
|
||||
'awaiting_ship' if no cert required)
|
||||
3. Assert card appears in plant_kanban under 'inspection' / 'shipping'
|
||||
4. Assert activity scheduled on a QM (notification fire is async —
|
||||
4. Assert activity scheduled on a QM (notification fire is async -
|
||||
skip strict email assertion in this test)
|
||||
5. As a Technician, call cert.action_issue() → assert AccessError
|
||||
6. As a QM, call cert.action_issue() → state='issued', job state→'awaiting_ship'
|
||||
@@ -51,7 +51,7 @@ _assert(bool(partner), 'partner exists')
|
||||
product = env['product.product'].search([], limit=1)
|
||||
_assert(bool(product), 'product exists')
|
||||
|
||||
# Role lookups (transitive via all_group_ids — Owners reach QM via implication)
|
||||
# Role lookups (transitive via all_group_ids - Owners reach QM via implication)
|
||||
qm_gid = env.ref('fusion_plating.group_fp_quality_manager').id
|
||||
mgr_gid = env.ref('fusion_plating.group_fp_manager').id
|
||||
tech_gid = env.ref('fusion_plating.group_fp_technician').id
|
||||
@@ -88,7 +88,7 @@ step = env['fp.job.step'].create({
|
||||
_assert(job.state == 'in_progress', 'job created in_progress')
|
||||
|
||||
# ---- 2. Finish the step → auto-advance -----------------------------
|
||||
# Bypass other gates that aren't relevant here (qty, bake, qc — not
|
||||
# Bypass other gates that aren't relevant here (qty, bake, qc - not
|
||||
# the system under test).
|
||||
ctx = {
|
||||
'fp_skip_required_inputs_gate': True,
|
||||
@@ -135,11 +135,11 @@ if cert_required_path:
|
||||
except AccessError:
|
||||
print('OK - Technician issue raised AccessError')
|
||||
except UserError as e:
|
||||
# Tech might hit a UserError gate before the ACL check fires —
|
||||
# Tech might hit a UserError gate before the ACL check fires -
|
||||
# accept that as "tech blocked" too.
|
||||
print(f'OK - Technician blocked: UserError: {str(e)[:80]}')
|
||||
|
||||
# QM issues — first pre-fill the gates so action_issue can proceed
|
||||
# QM issues - first pre-fill the gates so action_issue can proceed
|
||||
if not cert.spec_reference:
|
||||
cert.spec_reference = 'TEST-SPEC'
|
||||
if not cert.process_description:
|
||||
@@ -201,7 +201,7 @@ if cert_required_path and job.state == 'awaiting_cert':
|
||||
try:
|
||||
new_cert.with_user(qm).action_issue()
|
||||
job.invalidate_recordset()
|
||||
# Need ALL required certs issued for the advance — there may be
|
||||
# Need ALL required certs issued for the advance - there may be
|
||||
# a remaining voided cert from step 8 that's still in draft/etc.
|
||||
# Just check that state has moved off awaiting_cert.
|
||||
print(f'OK - re-issue path: job state now {job.state}')
|
||||
@@ -218,6 +218,6 @@ if job.state == 'awaiting_ship':
|
||||
print()
|
||||
print('--- bt_post_shop_states: ALL PASS ---')
|
||||
|
||||
# Leave DB clean — rollback the test data.
|
||||
# Leave DB clean - rollback the test data.
|
||||
env.cr.rollback()
|
||||
print('rolled back test data')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Quality Dashboard redesign — entech smoke.
|
||||
"""Quality Dashboard redesign - entech smoke.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-25-quality-dashboard-redesign-plan.md
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Scenario 10 — Carlos paused for lunch. Got pulled to another job. Step
|
||||
# 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,
|
||||
# Real shop pattern: this happens daily - interruptions, shift change,
|
||||
# operator pulled to rush job.
|
||||
#
|
||||
# What we want:
|
||||
@@ -42,7 +42,7 @@ 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
|
||||
# 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')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Scenario 11 — Carlos plating step #4 in tank 3. 8 minutes in, the
|
||||
# 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
|
||||
# 1. Abort the current step (parts not finished - but partial work
|
||||
# already happened)
|
||||
# 2. Switch to backup tank 5
|
||||
# 3. Restart the step there
|
||||
@@ -14,7 +14,7 @@
|
||||
#
|
||||
# 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.
|
||||
# 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
|
||||
@@ -88,7 +88,7 @@ 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' 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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
# 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?
|
||||
@@ -42,7 +42,7 @@ step.button_start()
|
||||
print(f' Carlos started step "{step.name}" (state={step.state})')
|
||||
print()
|
||||
|
||||
# Customer calls — wants 8 not 5
|
||||
# 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:
|
||||
@@ -63,7 +63,7 @@ if job.qty != sol.product_uom_qty:
|
||||
else:
|
||||
print(f' ✓ Job qty auto-updated.')
|
||||
|
||||
# Try the reverse — what if Sarah tries to LOWER the qty?
|
||||
# Try the reverse - what if Sarah tries to LOWER the qty?
|
||||
print()
|
||||
print(f' Customer changes mind: now wants 3 instead of 8')
|
||||
try:
|
||||
|
||||
@@ -37,7 +37,7 @@ 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(f' Job.qty still: {job.qty} (unchanged - supervisor must explicitly sync)')
|
||||
|
||||
print()
|
||||
print(f' Bob clicks "Sync qty from SO" on the job')
|
||||
|
||||
@@ -32,9 +32,9 @@ 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>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>'
|
||||
)
|
||||
@@ -57,7 +57,7 @@ from odoo.http import request as _req
|
||||
# Find code prefix used
|
||||
print(f' Step code: {plating.id}, name: {plating.name}')
|
||||
|
||||
# Direct call to the scan response builder (no http) — easier approach:
|
||||
# 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 = {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Scenario 14 — Recipe author wants step "Plating" to be hard-blocked
|
||||
# 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
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Scenario 15 — Job has a coating that requires hydrogen embrittlement
|
||||
# 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.
|
||||
# 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
|
||||
@@ -44,7 +44,7 @@ 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
|
||||
# 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()
|
||||
@@ -61,11 +61,11 @@ 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')
|
||||
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.')
|
||||
print(f' COMPLIANCE BOMB - no bake record but parts ship.')
|
||||
except Exception as e:
|
||||
print(f' ✓ Blocked: {str(e)[:200]}')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Scenario 16 — Carlos clicked Start on a step. Got pulled to a rush
|
||||
# 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Scenario 17 — Mid-job Carlos drops 2 parts (out of 5). Sets
|
||||
# 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
|
||||
# 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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Scenario 18 — Certificate flow simulation.
|
||||
# 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.
|
||||
@@ -81,7 +81,7 @@ if not certs:
|
||||
|
||||
cert = certs[0]
|
||||
|
||||
# DISCOVERABILITY — would Tom find the cert from the job form?
|
||||
# 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")}')
|
||||
@@ -99,7 +99,7 @@ except Exception as e:
|
||||
# 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)')
|
||||
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()
|
||||
@@ -110,7 +110,7 @@ if cert.state == 'draft' and not cert.spec_reference:
|
||||
# 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!)"}')
|
||||
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: '
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Scenario 19 — Fischerscope thickness report PDF appended to CoC.
|
||||
# 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
|
||||
@@ -104,7 +104,7 @@ 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
|
||||
# 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)"}')
|
||||
@@ -112,11 +112,11 @@ print(f' x_fc_thickness_pdf_id: {cert.x_fc_thickness_pdf_id.name
|
||||
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')
|
||||
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
|
||||
# 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}')
|
||||
@@ -141,7 +141,7 @@ if cert.attachment_id:
|
||||
if len(reader.pages) >= 2:
|
||||
print(f' ✓ CoC + Fischerscope merged (multi-page)')
|
||||
else:
|
||||
print(f' ❌ Only 1 page — merge did not run')
|
||||
print(f' ❌ Only 1 page - merge did not run')
|
||||
except Exception as e:
|
||||
print(f' ⚠️ couldn\'t parse output PDF: {e}')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Battle test S24 — Live step priority chain + board state filter.
|
||||
"""Battle test S24 - Live step priority chain + board state filter.
|
||||
|
||||
Run end-to-end via odoo shell with stdin redirection:
|
||||
|
||||
@@ -32,7 +32,7 @@ def run():
|
||||
partner = env['res.partner'].search([('customer_rank', '>', 0)], limit=1)
|
||||
if not partner:
|
||||
raise AssertionError(
|
||||
'No customer partner found — seed test data first'
|
||||
'No customer partner found - seed test data first'
|
||||
)
|
||||
|
||||
recipe = env['fusion.plating.process.node'].search([
|
||||
@@ -40,7 +40,7 @@ def run():
|
||||
('child_ids', '!=', False),
|
||||
], limit=1)
|
||||
if not recipe:
|
||||
raise AssertionError('No recipe found — seed test data first')
|
||||
raise AssertionError('No recipe found - seed test data first')
|
||||
|
||||
job = env['fp.job'].create({
|
||||
'partner_id': partner.id,
|
||||
@@ -52,7 +52,7 @@ def run():
|
||||
if len(steps) < 3:
|
||||
raise AssertionError('Need at least 3 steps for the test')
|
||||
|
||||
# === Phase A — between-step assertion ===
|
||||
# === Phase A - between-step assertion ===
|
||||
s1 = steps[0]
|
||||
s2 = steps[1]
|
||||
s1.button_start()
|
||||
@@ -68,9 +68,9 @@ def run():
|
||||
'Card column should match s2.area_kind=%s, got %s'
|
||||
% (s2.area_kind, _resolve_card_area(job))
|
||||
)
|
||||
_logger.info('[bt_s24] Phase A OK — between-step routing correct')
|
||||
_logger.info('[bt_s24] Phase A OK - between-step routing correct')
|
||||
|
||||
# === Phase B — paused step assertion ===
|
||||
# === Phase B - paused step assertion ===
|
||||
s2.button_start()
|
||||
s2.button_pause('lunch break')
|
||||
job.invalidate_recordset(['active_step_id', 'card_state'])
|
||||
@@ -79,9 +79,9 @@ def run():
|
||||
'Paused step should remain the live step, got %s'
|
||||
% job.active_step_id.id
|
||||
)
|
||||
_logger.info('[bt_s24] Phase B OK — paused step stays live')
|
||||
_logger.info('[bt_s24] Phase B OK - paused step stays live')
|
||||
|
||||
# === Phase C — done job filter ===
|
||||
# === Phase C - done job filter ===
|
||||
for s in steps:
|
||||
if s.state != 'done':
|
||||
if s.state == 'paused':
|
||||
@@ -104,7 +104,7 @@ def run():
|
||||
raise AssertionError(
|
||||
'Done job %s should be filtered off board' % job.id
|
||||
)
|
||||
_logger.info('[bt_s24] Phase C OK — done jobs filtered off board')
|
||||
_logger.info('[bt_s24] Phase C OK - done jobs filtered off board')
|
||||
|
||||
_logger.info('[bt_s24] ALL ASSERTIONS PASSED')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Scenario 9 — Carlos starts step, Bob (supervisor) reassigns to Mike.
|
||||
# Scenario 9 - Carlos starts step, Bob (supervisor) reassigns to Mike.
|
||||
# Verify chatter audit trail.
|
||||
|
||||
from odoo import fields
|
||||
@@ -36,14 +36,14 @@ print(f' assigned_user_id: {step.assigned_user_id.name}')
|
||||
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
|
||||
# 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
|
||||
other = env.user # fallback - at least the write fires
|
||||
step.assigned_user_id = other.id
|
||||
step.invalidate_recordset()
|
||||
job.invalidate_recordset()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# E2E persona walk — order entry from start to finish.
|
||||
# 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
|
||||
# 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,
|
||||
@@ -40,7 +40,7 @@ def e2e(env):
|
||||
findings = []
|
||||
|
||||
# ----- pick a real partner with a recipe-able product -----
|
||||
section('SETUP — pick a customer + a part already in the catalog')
|
||||
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']
|
||||
@@ -59,11 +59,11 @@ def e2e(env):
|
||||
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')
|
||||
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')
|
||||
section('PHASE 1 - Sarah (CSR) creates the sale order')
|
||||
SO = env['sale.order']
|
||||
SOL = env['sale.order.line']
|
||||
so_vals = {
|
||||
@@ -79,13 +79,13 @@ def e2e(env):
|
||||
'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_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
|
||||
# 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')
|
||||
@@ -94,7 +94,7 @@ def e2e(env):
|
||||
'order_id': so.id,
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': 25,
|
||||
'name': f'{part.part_number or part.name} — Plating per coating spec',
|
||||
'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.',
|
||||
@@ -103,7 +103,7 @@ def e2e(env):
|
||||
line = SOL.create(line_vals)
|
||||
step('Sarah', f'Added line: {line.product_uom_qty} × {line.name[:40]}')
|
||||
|
||||
# confirm — does account hold block?
|
||||
# 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:
|
||||
@@ -129,7 +129,7 @@ def e2e(env):
|
||||
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.')
|
||||
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
|
||||
@@ -140,7 +140,7 @@ def e2e(env):
|
||||
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')
|
||||
section('PHASE 2 - Mike (Receiver) processes inbound parts')
|
||||
receiving = receivings[:1]
|
||||
if not receiving:
|
||||
receiving = Receiving.create({
|
||||
@@ -148,7 +148,7 @@ def e2e(env):
|
||||
'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')
|
||||
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})')
|
||||
@@ -199,17 +199,17 @@ def e2e(env):
|
||||
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.')
|
||||
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')
|
||||
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.')
|
||||
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)"}')
|
||||
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')
|
||||
@@ -244,7 +244,7 @@ def e2e(env):
|
||||
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
|
||||
# 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:
|
||||
@@ -258,7 +258,7 @@ def e2e(env):
|
||||
findings.append(f'button_mark_done: {e}')
|
||||
|
||||
# ----- Lisa runs QC -----
|
||||
section('PHASE 4 — Lisa (QC) walks the checklist (if any)')
|
||||
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)}')
|
||||
@@ -292,7 +292,7 @@ def e2e(env):
|
||||
findings.append(f'Job blocked post-QC: {e}')
|
||||
|
||||
# ----- Tom ships -----
|
||||
section('PHASE 5 — Tom (Shipper) prepares the delivery')
|
||||
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 []),
|
||||
@@ -325,14 +325,14 @@ def e2e(env):
|
||||
findings.append('Certificate auto-create missing')
|
||||
|
||||
# ----- Jane invoices -----
|
||||
section('PHASE 6 — Jane (Accounting) creates and posts invoice')
|
||||
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')
|
||||
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:
|
||||
@@ -340,7 +340,7 @@ def e2e(env):
|
||||
findings.append(f'invoice creation: {e}')
|
||||
|
||||
# ----- common-sense edge case sweeps -----
|
||||
section('PHASE 7 — 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'
|
||||
@@ -381,7 +381,7 @@ def e2e(env):
|
||||
for i, f in enumerate(findings, 1):
|
||||
print(f' {i}. {f}')
|
||||
else:
|
||||
print(' ✅ No findings — workflow is clean end-to-end.')
|
||||
print(' ✅ No findings - workflow is clean end-to-end.')
|
||||
|
||||
env.cr.commit()
|
||||
return findings
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Step 1 verification — Direct Order wizard onchange + hold guard fixes.
|
||||
# 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:')
|
||||
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:')
|
||||
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()
|
||||
@@ -18,7 +18,7 @@ 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:')
|
||||
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',
|
||||
@@ -31,7 +31,7 @@ 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:')
|
||||
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()
|
||||
@@ -39,7 +39,7 @@ 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:')
|
||||
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)
|
||||
@@ -53,7 +53,7 @@ env['fp.direct.order.line'].create({
|
||||
})
|
||||
try:
|
||||
w.action_create_order()
|
||||
print(' ❌ HELD CUSTOMER CREATED ORDER — guard failed')
|
||||
print(' ❌ HELD CUSTOMER CREATED ORDER - guard failed')
|
||||
except Exception as e:
|
||||
print(f' ✓ blocked: {str(e)[:120]}')
|
||||
target.x_fc_account_hold = False
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Step 2 verification — picking a part on the wizard line pre-fills coating + treatments.
|
||||
# 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']
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Step 3 — Sarah hits "Create Order" in wizard, then confirms SO.
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Step 4 — Mike receives parts. Walk the receiving form, fill every
|
||||
# Step 4 - Mike receives parts. Walk the receiving form, fill every
|
||||
# visible field, walk the state machine, verify SO status updates at
|
||||
# every transition.
|
||||
|
||||
@@ -54,7 +54,7 @@ try:
|
||||
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')
|
||||
print(' ✓ Still partial - racking not done yet')
|
||||
except Exception as e:
|
||||
print(f' ❌ {e}')
|
||||
|
||||
@@ -71,7 +71,7 @@ try:
|
||||
except Exception as e:
|
||||
print(f' ❌ {e}')
|
||||
|
||||
# At this point Mike's done — racking crew takes over.
|
||||
# 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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Step 5 — Carlos walks the plating job. Test BOTH paths:
|
||||
# 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).
|
||||
# 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']
|
||||
@@ -41,7 +41,7 @@ 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')
|
||||
print(' ❌ JOB CLOSED WITH ZERO STEPS WALKED - guard failed')
|
||||
except Exception as e:
|
||||
print(f' ✓ blocked: {str(e)[:200]}')
|
||||
|
||||
@@ -58,7 +58,7 @@ 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}')
|
||||
print(f' ✓ Job marked done - state={job.state}, finished={job.date_finished}')
|
||||
except Exception as e:
|
||||
print(f' ❌ Mark Done failed AFTER walking: {e}')
|
||||
|
||||
@@ -90,7 +90,7 @@ 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}')
|
||||
print(f' ✓ Manager bypass worked - job state={job2.state}')
|
||||
except Exception as e:
|
||||
print(f' ❌ Bypass failed: {e}')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Step 6 — Lisa walks the QC checklist for a customer that requires QC.
|
||||
# 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
|
||||
@@ -21,7 +21,7 @@ if not default_tpl:
|
||||
'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': 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)')
|
||||
@@ -91,7 +91,7 @@ for s in job.step_ids.sorted('sequence'):
|
||||
s.button_finish()
|
||||
try:
|
||||
job.button_mark_done()
|
||||
print(f' ✓ Job done — state={job.state}')
|
||||
print(f' ✓ Job done - state={job.state}')
|
||||
except Exception as e:
|
||||
print(f' ❌ Job mark_done blocked: {e}')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Step 7 — Tom (Shipper) walks the delivery from draft to delivered.
|
||||
# Step 7 - Tom (Shipper) walks the delivery from draft to delivered.
|
||||
# Test:
|
||||
# A) Delivery exists post-job-done — what fields visible? what state?
|
||||
# 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Step 8 re-verify — fresh SO with net_terms strategy should now get
|
||||
# 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Step 8 — Jane creates the invoice for the completed SO and posts it.
|
||||
# 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
|
||||
@@ -40,7 +40,7 @@ if so.invoice_status == 'to invoice':
|
||||
except Exception as e:
|
||||
print(f' ❌ {e}')
|
||||
else:
|
||||
print(f' Skipped — invoice_status={so.invoice_status} (nothing to invoice)')
|
||||
print(f' Skipped - invoice_status={so.invoice_status} (nothing to invoice)')
|
||||
new_invs = env['account.move'].browse()
|
||||
|
||||
# Path B: post.
|
||||
|
||||
@@ -23,7 +23,7 @@ treats = Treat.search([], limit=2)
|
||||
|
||||
# ====================================================================== A
|
||||
print('='*72)
|
||||
print('Scenario A — Brand-new part (no defaults)')
|
||||
print('Scenario A - Brand-new part (no defaults)')
|
||||
print('='*72)
|
||||
fresh = Part.create({
|
||||
'partner_id': target.id,
|
||||
@@ -47,7 +47,7 @@ if result and result.get('warning'):
|
||||
# ====================================================================== B
|
||||
print()
|
||||
print('='*72)
|
||||
print('Scenario B — Existing part WITH defaults already set')
|
||||
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})')
|
||||
@@ -56,14 +56,14 @@ 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' 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('Scenario C - Brand-new part flagged is_one_off (don\'t persist)')
|
||||
print('='*72)
|
||||
fresh3 = Part.create({
|
||||
'partner_id': target.id,
|
||||
@@ -75,13 +75,13 @@ 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' 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('Scenario D - End-to-end: order #1 saves defaults, order #2 pre-fills')
|
||||
print('='*72)
|
||||
fresh_d = Part.create({
|
||||
'partner_id': target.id,
|
||||
@@ -96,7 +96,7 @@ 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}')
|
||||
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,
|
||||
@@ -117,9 +117,9 @@ 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
|
||||
# ORDER #2 - Sarah picks the same part again
|
||||
print()
|
||||
print(' Order #2 — Sarah picks the same part:')
|
||||
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})
|
||||
@@ -127,7 +127,7 @@ 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)')
|
||||
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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Comprehensive internal-process walk.
|
||||
#
|
||||
# Phases:
|
||||
# A) Pause / resume — multiple intervals merge into duration_actual
|
||||
# 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
|
||||
@@ -65,7 +65,7 @@ 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('A - Pause + resume on a step. Multiple intervals must merge.')
|
||||
print('='*72)
|
||||
masking = job.step_ids.sorted('sequence')[0]
|
||||
masking.button_start()
|
||||
@@ -90,7 +90,7 @@ else:
|
||||
# ====================================================================== B
|
||||
print()
|
||||
print('='*72)
|
||||
print('B — Skip an opt-in step')
|
||||
print('B - Skip an opt-in step')
|
||||
print('='*72)
|
||||
racking = job.step_ids.sorted('sequence')[1]
|
||||
print(f' Step: {racking.name} state={racking.state}')
|
||||
@@ -99,10 +99,10 @@ print(f' After Skip: state={racking.state}')
|
||||
if racking.state == 'skipped':
|
||||
print(f' ✓ Skip works')
|
||||
|
||||
# ====================================================================== C — walk rest, then mark-done
|
||||
# ====================================================================== C - walk rest, then mark-done
|
||||
print()
|
||||
print('='*72)
|
||||
print('C — Walk remaining steps (some will spawn bake-window). Mark job done.')
|
||||
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'):
|
||||
@@ -121,23 +121,23 @@ 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
|
||||
# ====================================================================== D - try to mark job done
|
||||
print()
|
||||
print('='*72)
|
||||
print('D — Mark job done (skipped+done steps both count as terminal)')
|
||||
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}')
|
||||
print(f' ✓ Job done - state={job.state}')
|
||||
except Exception as e:
|
||||
print(f' ❌ {e}')
|
||||
|
||||
# ====================================================================== E — bake-window lifecycle
|
||||
# ====================================================================== E - bake-window lifecycle
|
||||
if bws:
|
||||
bw = bws[0]
|
||||
print()
|
||||
print('='*72)
|
||||
print('E — Bake-window lifecycle: start → end')
|
||||
print('E - Bake-window lifecycle: start → end')
|
||||
print('='*72)
|
||||
print(f' Before start: state={bw.state}, color={bw.status_color}')
|
||||
bw.action_start_bake()
|
||||
@@ -146,10 +146,10 @@ if bws:
|
||||
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
|
||||
# ====================================================================== F - failure: start a done step
|
||||
print()
|
||||
print('='*72)
|
||||
print('F — Failure paths')
|
||||
print('F - Failure paths')
|
||||
print('='*72)
|
||||
done_step = job.step_ids.filtered(lambda s: s.state == 'done')[:1]
|
||||
if done_step:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Internal-process walk — test time tracking, pause, skip, bake-window
|
||||
# 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)
|
||||
# 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
|
||||
@@ -46,7 +46,7 @@ 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('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})')
|
||||
@@ -60,7 +60,7 @@ print(f' Open time-log rows: {len(first.time_log_ids.filtered(lambda l: not l.d
|
||||
# ====================================================================== STEP 2
|
||||
print()
|
||||
print('='*72)
|
||||
print('STEP 2 — Carlos works for 6 seconds, then clicks Finish')
|
||||
print('STEP 2 - Carlos works for 6 seconds, then clicks Finish')
|
||||
print('='*72)
|
||||
time.sleep(6)
|
||||
first.button_finish()
|
||||
@@ -74,12 +74,12 @@ print(f' ✓ Single interval captured cleanly')
|
||||
# ====================================================================== STEP 3
|
||||
print()
|
||||
print('='*72)
|
||||
print('STEP 3 — Test pause/resume on the next step (currently NotImplementedError)')
|
||||
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')
|
||||
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))}')
|
||||
@@ -91,7 +91,7 @@ except Exception as e:
|
||||
# ====================================================================== STEP 4
|
||||
print()
|
||||
print('='*72)
|
||||
print('STEP 4 — Test skip (currently NotImplementedError)')
|
||||
print('STEP 4 - Test skip (currently NotImplementedError)')
|
||||
print('='*72)
|
||||
third = job.step_ids.sorted('sequence')[2]
|
||||
print(f' Step: {third.name}, state={third.state}')
|
||||
@@ -107,7 +107,7 @@ except Exception as e:
|
||||
# ====================================================================== STEP 5
|
||||
print()
|
||||
print('='*72)
|
||||
print('STEP 5 — Wet plating step finishes, does a bake.window auto-spawn?')
|
||||
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]
|
||||
@@ -125,7 +125,7 @@ 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)
|
||||
# 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':
|
||||
@@ -137,7 +137,7 @@ 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)')
|
||||
print(f' (coating doesn\'t require bake relief - auto-spawn would skip anyway)')
|
||||
else:
|
||||
print(f' ✓ Bake window spawned')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Walk: Sarah opens Direct Order, creates a brand-new part inline, attaches a process.
|
||||
#
|
||||
# Personas:
|
||||
# Sarah (CSR) — driving the wizard
|
||||
# Sarah (CSR) - driving the wizard
|
||||
#
|
||||
# What we're testing:
|
||||
# 1) Wizard now allows creating a new part (no_quick_create lets the
|
||||
@@ -30,7 +30,7 @@ print()
|
||||
|
||||
# ====================================================================== STEP 2
|
||||
print('='*72)
|
||||
print('STEP 2 — Sarah opens wizard, hits "Create and edit..." on Part field')
|
||||
print('STEP 2 - Sarah opens wizard, hits "Create and edit..." on Part field')
|
||||
print('='*72)
|
||||
|
||||
w = W.create({
|
||||
@@ -56,7 +56,7 @@ 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' 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.
|
||||
@@ -65,7 +65,7 @@ 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 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).
|
||||
@@ -83,13 +83,13 @@ real_line = Line.create({
|
||||
# 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' 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('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'])
|
||||
@@ -113,7 +113,7 @@ 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('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
|
||||
@@ -133,10 +133,10 @@ 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"
|
||||
# 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('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,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
@@ -23,7 +23,7 @@ Tpl = Node # template recipes are also fp.process.node records
|
||||
|
||||
# ====================================================================== STEP 2
|
||||
print('='*72)
|
||||
print('STEP 2 — Bob creates a brand-new part')
|
||||
print('STEP 2 - Bob creates a brand-new part')
|
||||
print('='*72)
|
||||
target_partner = P.browse(2529) # 2CM INNOVATIVE
|
||||
default_coating = Coating.search([], limit=1)
|
||||
@@ -55,14 +55,14 @@ template = Node.search([
|
||||
('part_catalog_id', '=', False),
|
||||
], limit=1)
|
||||
if not template:
|
||||
print(' ❌ No shared template recipes available — cannot continue!')
|
||||
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('STEP 3 - Bob adds variant #1: Standard Production')
|
||||
print('='*72)
|
||||
v1 = _clone_subtree(env, template, part, parent=False)
|
||||
v1.variant_label = 'Standard Production'
|
||||
@@ -75,17 +75,17 @@ 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('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)')
|
||||
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('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)'
|
||||
@@ -94,7 +94,7 @@ 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('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)'
|
||||
@@ -103,18 +103,18 @@ 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'[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')
|
||||
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('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']
|
||||
@@ -163,7 +163,7 @@ print(f' Saved line: process_variant_id={new_line.process_variant_id.variant_la
|
||||
# ====================================================================== STEP 8
|
||||
print()
|
||||
print('='*72)
|
||||
print('STEP 8 — Confirm SO; verify the JOB uses variant #3, not the default')
|
||||
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'])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# End-to-end order walkthrough — simulates each role on the shop floor.
|
||||
# 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())' \
|
||||
@@ -24,7 +24,7 @@ def walk():
|
||||
print('====================== E2E ORDER WALKTHROUGH ======================')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROLE: Sales / Estimator — open Plating > Sales > Quotations
|
||||
# ROLE: Sales / Estimator - open Plating > Sales > Quotations
|
||||
# ------------------------------------------------------------------
|
||||
print('\n[ROLE: Estimator] Plating > Sales > Quotations > New Quote')
|
||||
|
||||
@@ -47,7 +47,7 @@ def walk():
|
||||
part = Part.search([], limit=1)
|
||||
if not part:
|
||||
gap('Estimator', 'fp.part.catalog',
|
||||
'No parts in catalog — estimator has nothing to quote against')
|
||||
'No parts in catalog - estimator has nothing to quote against')
|
||||
return
|
||||
print(f' Part chosen: {part.display_name} '
|
||||
f'(part#={getattr(part, "part_number", "?")} '
|
||||
@@ -68,7 +68,7 @@ def walk():
|
||||
coating = e['fp.coating.config'].search([], limit=1)
|
||||
if not coating:
|
||||
gap('Estimator', 'fp.coating.config',
|
||||
'No coating configs defined — estimator cannot configure quote')
|
||||
'No coating configs defined - estimator cannot configure quote')
|
||||
else:
|
||||
print(f' Coating chosen: {coating.display_name}')
|
||||
|
||||
@@ -91,7 +91,7 @@ def walk():
|
||||
gap('Estimator', 'fp.quote.configurator.create', str(ex))
|
||||
return
|
||||
|
||||
# 4a. Try the "Create Quotation" path — what action confirms the SO?
|
||||
# 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'):
|
||||
@@ -112,7 +112,7 @@ def walk():
|
||||
# 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')
|
||||
'- estimator has no button to make a quote')
|
||||
# Manual SO creation for the rest of the walkthrough
|
||||
SO = e['sale.order']
|
||||
try:
|
||||
@@ -155,7 +155,7 @@ def walk():
|
||||
if so.state == 'draft':
|
||||
try:
|
||||
so.action_confirm()
|
||||
print(f' ✓ SO confirmed — state={so.state}')
|
||||
print(f' ✓ SO confirmed - state={so.state}')
|
||||
except Exception as ex:
|
||||
gap('Estimator', 'sale.order.action_confirm', str(ex))
|
||||
return
|
||||
@@ -167,7 +167,7 @@ def walk():
|
||||
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 '
|
||||
'No fp.job auto-created on SO confirm - planner has nothing '
|
||||
'to plan against')
|
||||
else:
|
||||
print(f' ✓ {len(jobs)} fp.job(s) created: '
|
||||
@@ -178,7 +178,7 @@ def walk():
|
||||
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 '
|
||||
'No fp.receiving auto-created on SO confirm - receiver has '
|
||||
'nothing to count against')
|
||||
else:
|
||||
print(f' ✓ Receiving record(s): {", ".join(receivings.mapped("name"))}')
|
||||
@@ -188,7 +188,7 @@ def walk():
|
||||
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')
|
||||
'jobs exist but no racking inspection - racker walks empty')
|
||||
elif insps:
|
||||
print(f' ✓ Racking inspection(s): '
|
||||
f'{", ".join(insps.mapped("name"))}')
|
||||
@@ -202,10 +202,10 @@ def walk():
|
||||
f'{", ".join(pjs.mapped("name"))}')
|
||||
else:
|
||||
gap('Portal', 'portal job auto-create',
|
||||
'No portal.job mirror — customer sees nothing on portal')
|
||||
'No portal.job mirror - customer sees nothing on portal')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROLE: Receiver — Plating > Receiving > All Receiving
|
||||
# ROLE: Receiver - Plating > Receiving > All Receiving
|
||||
# ------------------------------------------------------------------
|
||||
print('\n[ROLE: Receiver] Open the receiving record, count boxes')
|
||||
if receivings:
|
||||
@@ -218,7 +218,7 @@ def walk():
|
||||
if hasattr(r, 'action_mark_counted'):
|
||||
try:
|
||||
r.action_mark_counted()
|
||||
print(f' ✓ Marked counted — state={r.state}')
|
||||
print(f' ✓ Marked counted - state={r.state}')
|
||||
except Exception as ex:
|
||||
gap('Receiver', 'action_mark_counted', str(ex))
|
||||
else:
|
||||
@@ -227,7 +227,7 @@ def walk():
|
||||
if hasattr(r, 'action_mark_staged'):
|
||||
try:
|
||||
r.action_mark_staged()
|
||||
print(f' ✓ Marked staged — state={r.state}')
|
||||
print(f' ✓ Marked staged - state={r.state}')
|
||||
except Exception as ex:
|
||||
gap('Receiver', 'action_mark_staged', str(ex))
|
||||
# Smart button to racking inspection?
|
||||
@@ -239,7 +239,7 @@ def walk():
|
||||
'no smart button; receiver navigates manually')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROLE: Racking Crew — open the linked racking inspection
|
||||
# ROLE: Racking Crew - open the linked racking inspection
|
||||
# ------------------------------------------------------------------
|
||||
print('\n[ROLE: Racker] Open the racking inspection from receiving smart button')
|
||||
if insps:
|
||||
@@ -253,18 +253,18 @@ def walk():
|
||||
if hasattr(insp, 'action_start'):
|
||||
try:
|
||||
insp.action_start()
|
||||
print(f' ✓ Inspection started — state={insp.state}')
|
||||
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}')
|
||||
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
|
||||
# ROLE: Operator - runs the plating job step-by-step
|
||||
# ------------------------------------------------------------------
|
||||
print('\n[ROLE: Operator] Open the job, run each step')
|
||||
if jobs:
|
||||
@@ -272,7 +272,7 @@ def walk():
|
||||
steps = job.step_ids.sorted('sequence')
|
||||
if not steps:
|
||||
gap('Operator', 'fp.job.step_ids',
|
||||
'job has no steps — recipe not generated')
|
||||
'job has no steps - recipe not generated')
|
||||
else:
|
||||
print(f' Job {job.name} has {len(steps)} steps')
|
||||
ran = 0
|
||||
@@ -286,17 +286,17 @@ def walk():
|
||||
gap('Operator', f'step.{step.name}', str(ex))
|
||||
else:
|
||||
gap('Operator', f'step.{step.name}',
|
||||
f"state={step.state} — operator can't start it")
|
||||
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
|
||||
# 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.
|
||||
# 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}')
|
||||
@@ -309,7 +309,7 @@ def walk():
|
||||
print(f' ✓ QC check found: {check.name}')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROLE: Operator — try to mark job done (will hit QC gate if applicable)
|
||||
# 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:
|
||||
@@ -329,7 +329,7 @@ def walk():
|
||||
pass
|
||||
try:
|
||||
job.with_context(fp_skip_qc_gate=True).button_mark_done()
|
||||
print(f' ✓ Job marked done (with QC bypass) — state={job.state}')
|
||||
print(f' ✓ Job marked done (with QC bypass) - state={job.state}')
|
||||
except Exception as ex:
|
||||
gap('Operator', 'fp.job.button_mark_done', str(ex))
|
||||
|
||||
@@ -344,7 +344,7 @@ def walk():
|
||||
f'{", ".join(deliveries.mapped("name") or ["(none)"])}')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROLE: Driver — picks up the delivery
|
||||
# ROLE: Driver - picks up the delivery
|
||||
# ------------------------------------------------------------------
|
||||
print('\n[ROLE: Driver] Find the linked fusion.plating.delivery')
|
||||
if Del is not None and jobs:
|
||||
@@ -355,14 +355,14 @@ def walk():
|
||||
if hasattr(d, 'action_mark_delivered'):
|
||||
try:
|
||||
d.action_mark_delivered()
|
||||
print(f' ✓ Marked delivered — state={d.state}')
|
||||
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')
|
||||
print(' No delivery linked to job - checking by SO')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROLE: Accountant — invoice the SO
|
||||
# ROLE: Accountant - invoice the SO
|
||||
# ------------------------------------------------------------------
|
||||
print('\n[ROLE: Accountant] Generate invoice')
|
||||
print(f' invoice_status={so.invoice_status}')
|
||||
@@ -376,7 +376,7 @@ def walk():
|
||||
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
|
||||
# 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). '
|
||||
@@ -388,7 +388,7 @@ def walk():
|
||||
# ------------------------------------------------------------------
|
||||
print('\n=========================== SUMMARY ===========================')
|
||||
if not GAPS:
|
||||
print('NO GAPS FOUND — workflow walked end-to-end clean')
|
||||
print('NO GAPS FOUND - workflow walked end-to-end clean')
|
||||
else:
|
||||
print(f'{len(GAPS)} GAP(S) FOUND:')
|
||||
for role, where, msg in GAPS:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Sub 12 Phase F — end-to-end smoke test.
|
||||
# Sub 12 Phase F - end-to-end smoke test.
|
||||
#
|
||||
# Run via odoo-shell:
|
||||
# /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin
|
||||
@@ -16,7 +16,7 @@ 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.')
|
||||
raise RuntimeError('No confirmed sale.order found - seed one first.')
|
||||
return so.partner_id, so
|
||||
|
||||
|
||||
@@ -46,12 +46,12 @@ def smoke():
|
||||
# 2. Authorise
|
||||
rma.action_authorise()
|
||||
assert rma.state == 'authorised'
|
||||
print(f' ✓ Authorised — state={rma.state}, qr_code present={bool(rma.qr_code)}')
|
||||
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}')
|
||||
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
|
||||
@@ -80,12 +80,12 @@ def smoke():
|
||||
rma.resolution_type = 'rework'
|
||||
rma.action_triage_complete()
|
||||
assert rma.state == 'triaged'
|
||||
print(f' ✓ Triage complete — state={rma.state}, resolution=rework')
|
||||
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}')
|
||||
print(f' ✓ Resolving - state={rma.state}')
|
||||
|
||||
# 8. NCR walk: open → containment → root cause → close
|
||||
ncr.action_open()
|
||||
@@ -95,7 +95,7 @@ def smoke():
|
||||
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)
|
||||
# 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')
|
||||
@@ -109,35 +109,35 @@ def smoke():
|
||||
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}')
|
||||
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}')
|
||||
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}')
|
||||
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}')
|
||||
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}')
|
||||
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).
|
||||
# 15. Counts smoke (read directly - controller needs http context).
|
||||
open_holds = e['fusion.plating.quality.hold'].search_count([
|
||||
('state', 'in', ('on_hold', 'under_review')),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user