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)