Two bugs fixed in one drop, both targeting the contract review (QA-005)
enforcement gap reported on entech.
## Bug 1 — WO step routed to wrong wizard
Symptom: clicking Finish & Next or Record on a Contract Review step in
WH/JOB/00339 opened the generic measurement wizard with three fake
prompts (Reviewer Initials / Date Reviewed / QA-005 Approved). No path
to the actual QA-005 form from the work order.
Root cause: action_finish_and_advance + action_open_input_wizard had no
branch for recipe_node.default_kind == 'contract_review'. The step.kind
mapping collapses contract_review -> 'other' so kind-based detection
wouldn't have worked either; gate has to live at the recipe-node layer.
Fix in fusion_plating_jobs/models/fp_job_step.py (v19.0.8.14.6):
- action_finish_and_advance:329 calls _fp_contract_review_redirect
before the input-wizard branch
- action_open_input_wizard:844 same gate, keeps Record button consistent
- _fp_contract_review_redirect:866 (new) returns the part's
action_start_contract_review() unless review.state in
(complete, dismissed) — gate clears so the step can finish after
the operator signs QA-005.
## Bug 2 — Part create did not enforce contract review
Symptom: spec called for a banner-only UX. User wanted true automatic
enforcement on first part creation under an enforced customer.
Fix in fusion_plating_quality/models/fp_part_catalog.py (v19.0.4.10.0):
- @api.model_create_multi def create() override
- _fp_enforce_contract_review_on_create() helper auto-stages the
fp.contract.review record AND surfaces three prominent reminders:
1. Sticky bus.bus warning toast (top-right, doesn't auto-dismiss)
2. mail.activity (To Do) on the part for the current user
3. Smart button on the part form lights up (review now exists)
- Idempotent: skips parts that already carry a review id
- Soft-fails: bus or activity outage doesn't block part creation
- create()-only — write/update flows never re-trigger
Sub 4's existing info banner stays as a fourth surface.
## Tests
- fusion_plating_jobs/tests/test_fp_job_extensions.py:
+TestContractReviewStepRouting (5 tests covering both routing methods,
the complete/dismissed gate-clear, and non-CR step regression)
- fusion_plating_quality/tests/test_part_catalog_contract_review_enforcement.py
(NEW): 9 tests covering auto-create, batch create, idempotency,
activity surface, bus surface, write-must-not-retrigger, soft-fail.
- docs/superpowers/tests/2026-04-22-sub4-smoke.py: flipped the
"no review yet" assertion to "review auto-created" to match new
behavior. Sign-flow assertions unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
7.3 KiB
Python
190 lines
7.3 KiB
Python
"""Sub 4 smoke test — runs inside odoo-shell on entech.
|
|
|
|
Verifies: toggle-triggered banner, lazy review creation, roster-enforced
|
|
sign flow, compute-driven auto-dismiss on confirmed MO, re-open flow,
|
|
risk-band matrix.
|
|
"""
|
|
env = env # odoo-shell injects this
|
|
|
|
Partner = env['res.partner']
|
|
Part = env['fp.part.catalog']
|
|
Review = env['fp.contract.review']
|
|
Users = env['res.users']
|
|
Company = env.company
|
|
|
|
# ---- Roster: add admin to both rosters ---------------------------------
|
|
admin = env.ref('base.user_admin')
|
|
Company.write({
|
|
'x_fc_qa_assistant_user_ids': [(6, 0, [admin.id])],
|
|
'x_fc_qa_manager_user_ids': [(6, 0, [admin.id])],
|
|
})
|
|
assert admin in Company.x_fc_qa_assistant_user_ids
|
|
assert admin in Company.x_fc_qa_manager_user_ids
|
|
print('[OK] Company rosters populated')
|
|
|
|
# ---- Customer with contract_review_required ----------------------------
|
|
cust = Partner.create({
|
|
'name': 'Sub4 Smoke Customer',
|
|
'is_company': True,
|
|
'customer_rank': 1,
|
|
'x_fc_contract_review_required': True,
|
|
})
|
|
assert cust.x_fc_contract_review_required
|
|
print('[OK] Customer toggle set')
|
|
|
|
# ---- Part under that customer → banner visible -------------------------
|
|
part = Part.create({
|
|
'partner_id': cust.id,
|
|
'part_number': 'SUB4-SMOKE-001',
|
|
'revision': 'A',
|
|
})
|
|
part.invalidate_recordset()
|
|
assert part.x_fc_customer_requires_contract_review
|
|
# v19.0.4.10.0 — create() now auto-stages the review when the customer
|
|
# has enforcement enabled, so the review record exists immediately and
|
|
# the banner correspondingly hides (banner = "no review yet"). The
|
|
# action_start_contract_review path remains valid for parts created
|
|
# before enforcement was enabled or via flows that bypass create().
|
|
assert part.x_fc_contract_review_id, 'review should be auto-created on create()'
|
|
print('[OK] Review auto-created on part create')
|
|
|
|
# ---- Start contract review → opens the existing record ---------------
|
|
action = part.action_start_contract_review()
|
|
part.invalidate_recordset()
|
|
review = part.x_fc_contract_review_id
|
|
assert review, 'review should exist'
|
|
assert review.state == 'assistant_review'
|
|
assert review.customer_id == cust
|
|
assert review.part_number == 'SUB4-SMOKE-001'
|
|
print('[OK] action_start_contract_review opens the auto-created review')
|
|
|
|
# ---- Sign section 2.0 as admin (roster ok) -----------------------------
|
|
review.with_user(admin).action_sign_section_20()
|
|
review.invalidate_recordset()
|
|
assert review.s20_locked
|
|
assert review.s20_signed_by == admin
|
|
assert review.state == 'manager_review'
|
|
print('[OK] Section 2.0 signed; state advanced')
|
|
|
|
# ---- Sign section 3.0 → state = complete -------------------------------
|
|
review.s30_risk_consequence = '4'
|
|
review.s30_risk_likelihood = '4'
|
|
assert review.s30_risk_band == 'red', f'expected red, got {review.s30_risk_band}'
|
|
review.with_user(admin).action_sign_section_30()
|
|
review.invalidate_recordset()
|
|
assert review.s30_locked
|
|
assert review.state == 'complete'
|
|
print('[OK] Section 3.0 signed; review complete; risk matrix computed')
|
|
|
|
# ---- Banner now hidden (review complete) -------------------------------
|
|
part.invalidate_recordset()
|
|
assert not part.x_fc_contract_review_banner_visible
|
|
print('[OK] Banner hidden after completion')
|
|
|
|
# ---- Re-open (admin is manager) ----------------------------------------
|
|
review.action_reopen()
|
|
review.invalidate_recordset()
|
|
assert review.state == 'assistant_review'
|
|
assert not review.s20_locked
|
|
assert not review.s30_locked
|
|
print('[OK] Re-open cleared both sections')
|
|
|
|
# ---- Risk matrix parity with paper form --------------------------------
|
|
expected = {
|
|
(1, 1): 'green', (1, 2): 'green', (1, 3): 'green', (1, 4): 'green', (1, 5): 'green',
|
|
(2, 1): 'green', (2, 2): 'green', (2, 3): 'yellow', (2, 4): 'yellow', (2, 5): 'yellow',
|
|
(3, 1): 'green', (3, 2): 'yellow', (3, 3): 'yellow', (3, 4): 'yellow', (3, 5): 'red',
|
|
(4, 1): 'yellow', (4, 2): 'yellow', (4, 3): 'yellow', (4, 4): 'red', (4, 5): 'red',
|
|
(5, 1): 'yellow', (5, 2): 'yellow', (5, 3): 'red', (5, 4): 'red', (5, 5): 'red',
|
|
}
|
|
for (c, l), band in expected.items():
|
|
review.s30_risk_consequence = str(c)
|
|
review.s30_risk_likelihood = str(l)
|
|
assert review.s30_risk_band == band, (
|
|
f'matrix mismatch at c={c} l={l}: got {review.s30_risk_band} expected {band}'
|
|
)
|
|
print('[OK] 25-cell risk matrix matches paper form')
|
|
|
|
# ---- Dismiss flow ------------------------------------------------------
|
|
part2 = Part.create({
|
|
'partner_id': cust.id,
|
|
'part_number': 'SUB4-SMOKE-002',
|
|
'revision': 'A',
|
|
})
|
|
part2.invalidate_recordset()
|
|
assert part2.x_fc_contract_review_banner_visible
|
|
part2.action_dismiss_contract_review()
|
|
part2.invalidate_recordset()
|
|
assert part2.x_fc_contract_review_dismissed
|
|
assert not part2.x_fc_contract_review_banner_visible
|
|
print('[OK] Dismiss hides banner')
|
|
|
|
# ---- Non-roster user blocked ------------------------------------------
|
|
demo_user = Users.search([('login', '=', 'demo')], limit=1)
|
|
if demo_user:
|
|
part3 = Part.create({
|
|
'partner_id': cust.id,
|
|
'part_number': 'SUB4-SMOKE-003',
|
|
'revision': 'A',
|
|
})
|
|
part3.action_start_contract_review()
|
|
part3.invalidate_recordset()
|
|
rev3 = part3.x_fc_contract_review_id
|
|
try:
|
|
rev3.with_user(demo_user).action_sign_section_20()
|
|
assert False, 'non-roster user should have been blocked'
|
|
except Exception as e:
|
|
assert 'authorised' in str(e) or 'Plating Manager' in str(e)
|
|
print('[OK] Non-roster user blocked')
|
|
else:
|
|
print('[SKIP] No demo user for non-roster check')
|
|
|
|
# ---- Bulk-toggle (Check All / Clear All) buttons ----------------------
|
|
part4 = Part.create({
|
|
'partner_id': cust.id,
|
|
'part_number': 'SUB4-SMOKE-004',
|
|
'revision': 'A',
|
|
})
|
|
part4.action_start_contract_review()
|
|
part4.invalidate_recordset()
|
|
rev4 = part4.x_fc_contract_review_id
|
|
|
|
rev4.action_check_all_section_20()
|
|
for f in rev4._SECTION_20_CHECKLIST:
|
|
assert rev4[f] is True, f'{f} should be True after Check All'
|
|
print('[OK] Section 2.0 Check All ticks all 10 boxes')
|
|
|
|
rev4.action_clear_all_section_20()
|
|
for f in rev4._SECTION_20_CHECKLIST:
|
|
assert rev4[f] is False, f'{f} should be False after Clear All'
|
|
print('[OK] Section 2.0 Clear All clears all 10 boxes')
|
|
|
|
rev4.action_check_all_section_30()
|
|
for f in rev4._SECTION_30_CHECKLIST:
|
|
assert rev4[f] is True, f'{f} should be True after Check All'
|
|
print('[OK] Section 3.0 Check All ticks all 11 boxes')
|
|
|
|
rev4.action_clear_all_section_30()
|
|
for f in rev4._SECTION_30_CHECKLIST:
|
|
assert rev4[f] is False, f'{f} should be False after Clear All'
|
|
print('[OK] Section 3.0 Clear All clears all 11 boxes')
|
|
|
|
# Lock section 2.0, bulk toggle should refuse
|
|
rev4.with_user(admin).action_sign_section_20()
|
|
try:
|
|
rev4.action_check_all_section_20()
|
|
assert False, 'bulk toggle should fail on locked section'
|
|
except Exception as e:
|
|
assert 'locked' in str(e).lower() or 'signed' in str(e).lower()
|
|
print('[OK] Bulk toggle blocked on locked section')
|
|
|
|
# ---- QWeb render -------------------------------------------------------
|
|
report = env.ref('fusion_plating_quality.action_report_contract_review')
|
|
pdf_bytes, mime = report._render_qweb_pdf('fusion_plating_quality.report_contract_review_qa005', [review.id])
|
|
assert pdf_bytes and pdf_bytes[:4] == b'%PDF'
|
|
print(f'[OK] QA-005 PDF rendered ({len(pdf_bytes)} bytes)')
|
|
|
|
# ---- Cleanup -----------------------------------------------------------
|
|
env.cr.rollback()
|
|
print('\n=== SUB 4 SMOKE PASS — all assertions held ===')
|