diff --git a/fusion_plating/docs/superpowers/tests/2026-04-22-sub4-smoke.py b/fusion_plating/docs/superpowers/tests/2026-04-22-sub4-smoke.py
index f10be09f..9371347e 100644
--- a/fusion_plating/docs/superpowers/tests/2026-04-22-sub4-smoke.py
+++ b/fusion_plating/docs/superpowers/tests/2026-04-22-sub4-smoke.py
@@ -40,19 +40,23 @@ part = Part.create({
})
part.invalidate_recordset()
assert part.x_fc_customer_requires_contract_review
-assert part.x_fc_contract_review_banner_visible, 'banner should be visible'
-assert not part.x_fc_contract_review_id
-print('[OK] Banner visible on fresh part')
+# 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 → record created + state = assistant_review
+# ---- 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 be created'
+assert review, 'review should exist'
assert review.state == 'assistant_review'
assert review.customer_id == cust
assert review.part_number == 'SUB4-SMOKE-001'
-print('[OK] Review created by action_start_contract_review')
+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()
diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py
index 10ecde87..b9d11ad8 100644
--- a/fusion_plating/fusion_plating_jobs/__manifest__.py
+++ b/fusion_plating/fusion_plating_jobs/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
- 'version': '19.0.8.14.5',
+ 'version': '19.0.8.14.6',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
index df4fa6d7..33c24222 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
@@ -342,6 +342,15 @@ class FpJobStep(models.Model):
"Step '%s' is in state '%s' — start it before clicking Finish."
) % (self.name, self.state))
+ # Contract Review (QA-005) routing — when the recipe step is
+ # flagged as a contract-review step, the operator should land on
+ # the part's QA-005 form rather than the generic measurement
+ # wizard. Once the review is complete or dismissed we fall
+ # through to the normal finish path so the step can advance.
+ cr_action = self._fp_contract_review_redirect()
+ if cr_action:
+ return cr_action
+
# Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being
@@ -833,8 +842,16 @@ class FpJobStep(models.Model):
}
def action_open_input_wizard(self):
- """Open the Input Recording wizard for this step."""
+ """Open the Input Recording wizard for this step.
+
+ Contract-review steps redirect to the QA-005 form (same gate as
+ action_finish_and_advance) so the per-row "Record" button stays
+ consistent with "Finish & Next".
+ """
self.ensure_one()
+ cr_action = self._fp_contract_review_redirect()
+ if cr_action:
+ return cr_action
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.input.wizard',
@@ -846,6 +863,41 @@ class FpJobStep(models.Model):
},
}
+ def _fp_contract_review_redirect(self):
+ """Return an ir.actions.act_window opening the part's QA-005
+ Contract Review form, or False to indicate "no redirect needed".
+
+ Triggers when:
+ * the recipe node is flagged default_kind='contract_review', AND
+ * the linked part has no review yet OR the review is still in
+ a non-terminal state (draft / assistant_review / manager_review).
+
+ Once the review reaches state 'complete' or 'dismissed' the step
+ is allowed to finish through the normal path, which is how the
+ operator clears the contract-review gate after signing QA-005.
+
+ Soft-fail: if the job has no part_catalog_id we cannot route to
+ a per-part review, so we fall through to the standard wizard
+ rather than blocking the operator.
+ """
+ self.ensure_one()
+ node = self.recipe_node_id
+ if not node or node.default_kind != 'contract_review':
+ return False
+ part = self.job_id.part_catalog_id
+ if not part:
+ _logger.warning(
+ "Contract-review step '%s' on job %s has no part_catalog_id "
+ "— cannot redirect to QA-005 form, falling through to "
+ "standard wizard.",
+ self.name, self.job_id.name,
+ )
+ return False
+ review = part.x_fc_contract_review_id
+ if review and review.state in ('complete', 'dismissed'):
+ return False
+ return part.action_start_contract_review()
+
# ------------------------------------------------------------------
# Live duration helper — view binds to a non-stored compute that
# ticks each time the form re-reads. For a true live ticking clock
diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py
index 4196c1b4..f33cf458 100644
--- a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py
+++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py
@@ -681,3 +681,148 @@ class TestPhase7Migration(TransactionCase):
Path(__file__).parent.parent / 'scripts' / '__init__.py'
)
self.assertTrue(init.exists())
+
+
+class TestContractReviewStepRouting(TransactionCase):
+ """Regression coverage for the contract-review step gate added on
+ fp.job.step.action_finish_and_advance / action_open_input_wizard.
+
+ Pre-fix behaviour: clicking Finish & Next or Record on a recipe step
+ flagged default_kind='contract_review' opened the generic Record
+ Inputs measurement wizard. Operators could not actually capture the
+ QA-005 review from the work order.
+
+ Post-fix behaviour: both buttons return the part's
+ action_start_contract_review() action when the linked review is not
+ yet complete/dismissed; once signed off they fall through to the
+ normal finish path so the WO can advance.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.partner = self.env['res.partner'].create({
+ 'name': 'CR-Test Customer',
+ 'x_fc_contract_review_required': True,
+ })
+ self.product = self.env['product.product'].create({'name': 'CR-W'})
+ self.part = self.env['fp.part.catalog'].create({
+ 'name': 'CR-Test Part',
+ 'partner_id': self.partner.id,
+ 'part_number': 'CR-001',
+ })
+ self.wc = self.env['fp.work.centre'].create({
+ 'name': 'QA', 'code': 'QA', 'kind': 'inspection',
+ })
+ # Recipe with a single contract-review operation node
+ self.recipe = self.env['fusion.plating.process.node'].create({
+ 'name': 'CR-Recipe',
+ 'node_type': 'recipe',
+ })
+ self.cr_node = self.env['fusion.plating.process.node'].create({
+ 'name': 'Contract Review',
+ 'node_type': 'operation',
+ 'parent_id': self.recipe.id,
+ 'sequence': 10,
+ 'default_kind': 'contract_review',
+ })
+ # Job + matching step (skip generator, build directly so the
+ # test stays focused on the routing logic)
+ self.job = self.env['fp.job'].create({
+ 'partner_id': self.partner.id,
+ 'product_id': self.product.id,
+ 'qty': 1.0,
+ 'part_catalog_id': self.part.id,
+ 'recipe_id': self.recipe.id,
+ })
+ self.step = self.env['fp.job.step'].create({
+ 'job_id': self.job.id,
+ 'name': 'Contract Review',
+ 'recipe_node_id': self.cr_node.id,
+ 'work_centre_id': self.wc.id,
+ 'sequence': 10,
+ 'kind': 'other',
+ 'state': 'in_progress',
+ })
+
+ def _is_contract_review_action(self, action):
+ return (
+ isinstance(action, dict)
+ and action.get('res_model') == 'fp.contract.review'
+ and action.get('type') == 'ir.actions.act_window'
+ )
+
+ def test_finish_and_advance_routes_to_qa005_when_no_review(self):
+ action = self.step.action_finish_and_advance()
+ self.assertTrue(
+ self._is_contract_review_action(action),
+ 'Finish & Next on a contract_review step must open the '
+ 'fp.contract.review form, got: %r' % action,
+ )
+ # Side effect: lazy-create the review record
+ self.assertTrue(self.part.x_fc_contract_review_id)
+
+ def test_open_input_wizard_routes_to_qa005_when_no_review(self):
+ action = self.step.action_open_input_wizard()
+ self.assertTrue(
+ self._is_contract_review_action(action),
+ 'Record on a contract_review step must open the '
+ 'fp.contract.review form, got: %r' % action,
+ )
+
+ def test_finish_and_advance_falls_through_when_review_complete(self):
+ # Pre-create a complete review so the gate clears
+ review = self.env['fp.contract.review'].create({
+ 'part_id': self.part.id,
+ 'state': 'complete',
+ })
+ self.part.x_fc_contract_review_id = review.id
+ # With the gate cleared, Finish & Next should NOT return the
+ # contract review action. Either it finishes the step (returns
+ # True) or it opens the input wizard if prompts exist (returns
+ # an act_window for fp.job.step.input.wizard). Both are
+ # acceptable; what matters is it's not the contract review form.
+ action = self.step.action_finish_and_advance()
+ self.assertFalse(
+ self._is_contract_review_action(action),
+ 'When review is complete the gate must clear, got: %r'
+ % action,
+ )
+
+ def test_finish_and_advance_falls_through_when_review_dismissed(self):
+ review = self.env['fp.contract.review'].create({
+ 'part_id': self.part.id,
+ 'state': 'dismissed',
+ })
+ self.part.x_fc_contract_review_id = review.id
+ action = self.step.action_finish_and_advance()
+ self.assertFalse(
+ self._is_contract_review_action(action),
+ 'When review is dismissed the gate must clear, got: %r'
+ % action,
+ )
+
+ def test_non_contract_review_step_unaffected(self):
+ # A regular operation step (no default_kind=contract_review)
+ # must not be intercepted by the new gate.
+ plate_node = self.env['fusion.plating.process.node'].create({
+ 'name': 'Plating',
+ 'node_type': 'operation',
+ 'parent_id': self.recipe.id,
+ 'sequence': 20,
+ 'default_kind': 'plate',
+ })
+ plate_step = self.env['fp.job.step'].create({
+ 'job_id': self.job.id,
+ 'name': 'Plating',
+ 'recipe_node_id': plate_node.id,
+ 'work_centre_id': self.wc.id,
+ 'sequence': 20,
+ 'kind': 'wet',
+ 'state': 'in_progress',
+ })
+ action = plate_step.action_open_input_wizard()
+ self.assertFalse(
+ self._is_contract_review_action(action),
+ 'Non-CR steps must NOT be redirected to QA-005, got: %r'
+ % action,
+ )
diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py
index 0714c771..39f1c088 100644
--- a/fusion_plating/fusion_plating_quality/__manifest__.py
+++ b/fusion_plating/fusion_plating_quality/__manifest__.py
@@ -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.',
diff --git a/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py b/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
index 5f33facb..a1027064 100644
--- a/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
+++ b/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
@@ -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 %(c)s requires a Contract '
+ 'Review on new parts. Open the QA-005 form '
+ 'using the Contract Review 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):
diff --git a/fusion_plating/fusion_plating_quality/tests/__init__.py b/fusion_plating/fusion_plating_quality/tests/__init__.py
new file mode 100644
index 00000000..1e698a17
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/tests/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import test_part_catalog_contract_review_enforcement
diff --git a/fusion_plating/fusion_plating_quality/tests/test_part_catalog_contract_review_enforcement.py b/fusion_plating/fusion_plating_quality/tests/test_part_catalog_contract_review_enforcement.py
new file mode 100644
index 00000000..56d193cb
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/tests/test_part_catalog_contract_review_enforcement.py
@@ -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)