fix(contract-review): WO step routes to QA-005 + auto-stage on part create

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>
This commit is contained in:
gsinghpal
2026-05-03 20:02:52 -04:00
parent 1da27ed6bf
commit ee80673579
8 changed files with 584 additions and 9 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.4.9.0',
'version': '19.0.4.10.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -3,9 +3,13 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpPartCatalog(models.Model):
_inherit = 'fp.part.catalog'
@@ -86,6 +90,134 @@ class FpPartCatalog(models.Model):
and not completed and not in_production
)
# ---- Create override — auto-stage + alert -------------------------------
@api.model_create_multi
def create(self, vals_list):
"""Auto-stage the contract review and alert the user when a new
part is added under a customer that has contract-review
enforcement enabled (res.partner.x_fc_contract_review_required).
Fires only on .create() — write/update flows never re-trigger
the alert. Existing parts in the system are unaffected.
Three surfaces, in increasing persistence:
1. Sticky warning toast via bus.bus (instant, visible top-right).
2. mail.activity scheduled on the part for the current user
(lives in their Activities inbox until the review is complete).
3. Smart button on the part form lights up because the review
record was auto-created (always visible while the part is open).
The original info banner from Sub 4's spec also still renders,
which makes the part form itself self-explanatory.
"""
parts = super().create(vals_list)
# Defer side-effects so the create transaction commits cleanly
# even if the bus/activity layer is unavailable (e.g. during
# tests with a stripped-down environment).
parts._fp_enforce_contract_review_on_create()
return parts
def _fp_enforce_contract_review_on_create(self):
"""Per-record handler called from the create() override.
Idempotent: skips parts that already have a review record (e.g.
duplicated parts that copied the link, or parts created via an
import wizard that pre-staged them). Soft-fails on each side
effect so a bus or activity outage never blocks part creation.
"""
Review = self.env['fp.contract.review']
Activity = self.env['mail.activity']
Bus = self.env['bus.bus']
activity_type = self.env.ref(
'mail.mail_activity_data_todo', raise_if_not_found=False,
)
# Resolve the model id once for all parts in the batch.
try:
model_id = self.env['ir.model']._get('fp.part.catalog').id
except Exception:
model_id = False
for part in self:
if not part.partner_id or not part.partner_id.x_fc_contract_review_required:
continue
if part.x_fc_contract_review_id:
# Already linked to a review (carried over from a copy
# or pre-set in vals). Don't replace; just notify.
review = part.x_fc_contract_review_id
else:
# Lazy-create the review record so the smart button on
# the part form lights up immediately.
review = Review.create({
'part_id': part.id,
'state': 'assistant_review',
})
part.x_fc_contract_review_id = review.id
part_label = (
part.part_number
or part.name
or _('this part')
)
customer_label = part.partner_id.display_name
# 1) Persistent activity — sits in the user's Activities
# inbox + shows on the part record's chatter clock until
# the user marks it done.
if activity_type and model_id:
try:
Activity.create({
'res_model_id': model_id,
'res_id': part.id,
'activity_type_id': activity_type.id,
'summary': _(
'Complete Contract Review (QA-005) for %s'
) % part_label,
'note': _(
'Customer <b>%(c)s</b> requires a Contract '
'Review on new parts. Open the QA-005 form '
'using the <b>Contract Review</b> smart '
'button at the top of this part, then sign '
'Sections 2.0 and 3.0 to complete the '
'review.'
) % {'c': customer_label},
'user_id': self.env.user.id,
})
except Exception:
_logger.warning(
'fp.part.catalog %s: could not schedule '
'contract-review activity',
part.id, exc_info=True,
)
# 2) Sticky warning toast — visible immediately to the
# user who created the part. Doesn't auto-dismiss.
try:
Bus._sendone(
self.env.user.partner_id,
'simple_notification',
{
'title': _('Contract Review Required — %s')
% part_label,
'message': _(
'Customer %(c)s requires a Contract Review '
'(QA-005) on new parts. The review record '
'has been pre-created — open it using the '
'Contract Review smart button at the top '
'of the part form, or from your Activities '
'inbox.'
) % {'c': customer_label},
'type': 'warning',
'sticky': True,
},
)
except Exception:
_logger.warning(
'fp.part.catalog %s: could not push contract-'
'review notification', part.id, exc_info=True,
)
# ---- Actions -------------------------------------------------------------
def action_start_contract_review(self):

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_part_catalog_contract_review_enforcement

View File

@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Regression coverage for v19.0.4.10.0 contract-review enforcement on
fp.part.catalog create.
The create() override must:
1. Auto-create an fp.contract.review record for new parts under a
customer that has x_fc_contract_review_required = True.
2. Skip parts whose customer has the toggle off.
3. Skip parts that already carry a review id (idempotent on copy/import).
4. Schedule a mail.activity on the part for the current user.
5. Push a sticky bus.bus warning notification.
6. NOT fire on plain .write() (i.e. update flows do not re-trigger).
"""
from unittest.mock import patch
from odoo.tests.common import TransactionCase
class TestContractReviewEnforcementOnCreate(TransactionCase):
def setUp(self):
super().setUp()
# Customer A — enforcement ON
self.cust_enforced = self.env['res.partner'].create({
'name': 'Enforced Customer',
'is_company': True,
'customer_rank': 1,
'x_fc_contract_review_required': True,
})
# Customer B — enforcement OFF (control)
self.cust_unenforced = self.env['res.partner'].create({
'name': 'Unenforced Customer',
'is_company': True,
'customer_rank': 1,
'x_fc_contract_review_required': False,
})
# -- Core auto-creation -----------------------------------------
def test_review_auto_created_for_enforced_customer(self):
part = self.env['fp.part.catalog'].create({
'partner_id': self.cust_enforced.id,
'part_number': 'AUTO-001',
'revision': 'A',
})
self.assertTrue(
part.x_fc_contract_review_id,
'Review must be auto-created on .create() when the customer '
'has x_fc_contract_review_required=True.',
)
self.assertEqual(
part.x_fc_contract_review_id.state, 'assistant_review',
'Auto-created review should start in assistant_review.',
)
self.assertEqual(
part.x_fc_contract_review_id.part_id, part,
'Review must be linked back to the part it was created for.',
)
def test_no_review_for_unenforced_customer(self):
part = self.env['fp.part.catalog'].create({
'partner_id': self.cust_unenforced.id,
'part_number': 'NONE-001',
'revision': 'A',
})
self.assertFalse(
part.x_fc_contract_review_id,
'Review must NOT be created for customers without enforcement.',
)
def test_existing_review_is_not_replaced(self):
# Pre-create a review and pass it in vals — create() must not
# overwrite it (idempotency on copy/import flows).
existing_part = self.env['fp.part.catalog'].create({
'partner_id': self.cust_enforced.id,
'part_number': 'EXIST-001',
'revision': 'A',
})
existing_review = existing_part.x_fc_contract_review_id
self.assertTrue(existing_review)
# Now create a SECOND part that already references the same
# review (simulating a copy with carried-over m2o).
part2 = self.env['fp.part.catalog'].create({
'partner_id': self.cust_enforced.id,
'part_number': 'EXIST-002',
'revision': 'A',
'x_fc_contract_review_id': existing_review.id,
})
self.assertEqual(
part2.x_fc_contract_review_id, existing_review,
'Pre-set review id must be preserved, not replaced.',
)
def test_batch_create_each_part_gets_own_review(self):
# Batch create — each enforced part gets its own review.
parts = self.env['fp.part.catalog'].create([
{'partner_id': self.cust_enforced.id,
'part_number': 'BATCH-001', 'revision': 'A'},
{'partner_id': self.cust_enforced.id,
'part_number': 'BATCH-002', 'revision': 'A'},
{'partner_id': self.cust_unenforced.id,
'part_number': 'BATCH-003', 'revision': 'A'},
])
self.assertTrue(parts[0].x_fc_contract_review_id)
self.assertTrue(parts[1].x_fc_contract_review_id)
self.assertFalse(parts[2].x_fc_contract_review_id)
self.assertNotEqual(
parts[0].x_fc_contract_review_id,
parts[1].x_fc_contract_review_id,
'Each enforced part must get its OWN review record.',
)
# -- Activity surface -------------------------------------------
def test_activity_scheduled_on_part(self):
part = self.env['fp.part.catalog'].create({
'partner_id': self.cust_enforced.id,
'part_number': 'ACT-001',
'revision': 'A',
})
activities = self.env['mail.activity'].search([
('res_model', '=', 'fp.part.catalog'),
('res_id', '=', part.id),
])
self.assertTrue(
activities,
'A mail.activity must be scheduled on the part for the '
'current user.',
)
self.assertIn(
'Contract Review',
activities[0].summary or '',
'Activity summary must mention Contract Review.',
)
def test_no_activity_for_unenforced_customer(self):
part = self.env['fp.part.catalog'].create({
'partner_id': self.cust_unenforced.id,
'part_number': 'NOACT-001',
'revision': 'A',
})
activities = self.env['mail.activity'].search([
('res_model', '=', 'fp.part.catalog'),
('res_id', '=', part.id),
])
self.assertFalse(
activities,
'No activity should be scheduled for unenforced customers.',
)
# -- Bus notification surface -----------------------------------
def test_bus_notification_pushed_on_create(self):
# Spy on bus.bus._sendone to verify a sticky warning was pushed.
with patch.object(
self.env.registry['bus.bus'], '_sendone', autospec=True,
) as mock_send:
self.env['fp.part.catalog'].create({
'partner_id': self.cust_enforced.id,
'part_number': 'BUS-001',
'revision': 'A',
})
self.assertTrue(
mock_send.called,
'bus.bus._sendone must be called on enforced create.',
)
# Inspect the call payload — type=warning + sticky=True.
# Args: (self, target, type, payload)
call_args = mock_send.call_args
payload = call_args.args[3] if len(call_args.args) >= 4 else call_args.kwargs.get('notification')
self.assertEqual(payload.get('type'), 'warning')
self.assertTrue(payload.get('sticky'))
self.assertIn('Contract Review', payload.get('title', ''))
def test_no_bus_notification_for_unenforced_customer(self):
with patch.object(
self.env.registry['bus.bus'], '_sendone', autospec=True,
) as mock_send:
self.env['fp.part.catalog'].create({
'partner_id': self.cust_unenforced.id,
'part_number': 'BUS-002',
'revision': 'A',
})
self.assertFalse(
mock_send.called,
'bus.bus._sendone must NOT be called for unenforced customers.',
)
# -- Write must NOT re-trigger ----------------------------------
def test_write_does_not_retrigger_alert(self):
# Pre-existing part under unenforced customer — no review yet.
part = self.env['fp.part.catalog'].create({
'partner_id': self.cust_unenforced.id,
'part_number': 'WRITE-001',
'revision': 'A',
})
self.assertFalse(part.x_fc_contract_review_id)
# Now flip the customer's flag and update the part. The create
# gate is .create()-only by design — write/update must NOT
# auto-create a review or push a notification.
self.cust_unenforced.x_fc_contract_review_required = True
with patch.object(
self.env.registry['bus.bus'], '_sendone', autospec=True,
) as mock_send:
part.write({'revision_note': 'updated after enforcement enabled'})
self.assertFalse(
mock_send.called,
'write() must NOT push a contract-review notification — '
'enforcement only applies on first creation.',
)
self.assertFalse(
part.x_fc_contract_review_id,
'write() must NOT auto-create a review; existing parts '
'keep their state.',
)
# -- Soft-fail safety -------------------------------------------
def test_create_succeeds_even_if_bus_fails(self):
# If bus.bus.\_sendone raises, the part create must still succeed.
with patch.object(
self.env.registry['bus.bus'], '_sendone', autospec=True,
side_effect=RuntimeError('bus offline'),
):
part = self.env['fp.part.catalog'].create({
'partner_id': self.cust_enforced.id,
'part_number': 'SOFT-001',
'revision': 'A',
})
# Part was created; review was auto-staged BEFORE the bus push
# so it survives a bus failure.
self.assertTrue(part.id)
self.assertTrue(part.x_fc_contract_review_id)