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

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

View File

@@ -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

View File

@@ -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,
)