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:
@@ -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.',
|
||||
|
||||
@@ -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):
|
||||
|
||||
2
fusion_plating/fusion_plating_quality/tests/__init__.py
Normal file
2
fusion_plating/fusion_plating_quality/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_part_catalog_contract_review_enforcement
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user