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

@@ -40,19 +40,23 @@ part = Part.create({
}) })
part.invalidate_recordset() part.invalidate_recordset()
assert part.x_fc_customer_requires_contract_review assert part.x_fc_customer_requires_contract_review
assert part.x_fc_contract_review_banner_visible, 'banner should be visible' # v19.0.4.10.0 — create() now auto-stages the review when the customer
assert not part.x_fc_contract_review_id # has enforcement enabled, so the review record exists immediately and
print('[OK] Banner visible on fresh part') # 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() action = part.action_start_contract_review()
part.invalidate_recordset() part.invalidate_recordset()
review = part.x_fc_contract_review_id 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.state == 'assistant_review'
assert review.customer_id == cust assert review.customer_id == cust
assert review.part_number == 'SUB4-SMOKE-001' 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) ----------------------------- # ---- Sign section 2.0 as admin (roster ok) -----------------------------
review.with_user(admin).action_sign_section_20() review.with_user(admin).action_sign_section_20()

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.14.5', 'version': '19.0.8.14.6',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -342,6 +342,15 @@ class FpJobStep(models.Model):
"Step '%s' is in state '%s' — start it before clicking Finish." "Step '%s' is in state '%s' — start it before clicking Finish."
) % (self.name, self.state)) ) % (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 # Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured # recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being # 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): 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() self.ensure_one()
cr_action = self._fp_contract_review_redirect()
if cr_action:
return cr_action
return { return {
'type': 'ir.actions.act_window', 'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.input.wizard', '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 # Live duration helper — view binds to a non-stored compute that
# ticks each time the form re-reads. For a true live ticking clock # ticks each time the form re-reads. For a true live ticking clock

View File

@@ -681,3 +681,148 @@ class TestPhase7Migration(TransactionCase):
Path(__file__).parent.parent / 'scripts' / '__init__.py' Path(__file__).parent.parent / 'scripts' / '__init__.py'
) )
self.assertTrue(init.exists()) 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,
)

View File

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

View File

@@ -3,9 +3,13 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
import logging
from odoo import _, api, fields, models from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpPartCatalog(models.Model): class FpPartCatalog(models.Model):
_inherit = 'fp.part.catalog' _inherit = 'fp.part.catalog'
@@ -86,6 +90,134 @@ class FpPartCatalog(models.Model):
and not completed and not in_production 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 ------------------------------------------------------------- # ---- Actions -------------------------------------------------------------
def action_start_contract_review(self): 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)