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:
@@ -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()
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
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