Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md
gsinghpal 051094813e docs(plan): post-shop cert + shipping job states implementation plan
23 tasks across 8 phases:
  Phase 1 — State machine foundation (extend Selection, advance helpers)
  Phase 2 — Step-level gating hooks (button_finish gates + advance)
  Phase 3 — Certificate-side hooks + ACL (action_issue guard + cert
            void regress + x_fc_age_hours + view groups gating)
  Phase 4 — Plant Kanban visibility (domain, _resolve_card_area,
            chips, SCSS, KPI tiles + filter chips, mini-timeline)
  Phase 5 — Quality Dashboard sixth tab (Certificates)
  Phase 6 — Notification + Activity (events, resolver, templates,
            mail.activity schedule + auto-resolve)
  Phase 7 — Migration 19.0.11.0.0 — backfill mid-flight jobs
  Phase 8 — Battle test + entech deploy

Implements: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:15:27 -04:00

88 KiB

Post-Shop Cert + Shipping Job States Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stop completed-but-uncertified jobs from disappearing off the Plant Kanban; route them through two new fp.job.state values (awaiting_certawaiting_ship) with auto-transitions, a Quality Dashboard "Certificates" tab, hard-gated CoC issuance (QM/Manager/Owner only), and email + in-app activity notifications.

Architecture: Add two intermediate fp.job.state values via Selection extension in fusion_plating_jobs. Auto-advance on last-step finish (gates moved into fp.job.step.button_finish so failures surface as UserError on the operator's click, not silently swallowed). Cert action_issue advances to awaiting_ship; cert void regresses to awaiting_cert. Plant kanban widens its state domain; new _resolve_card_area rules pin the two states to the right-most two columns. Six-tab Quality Dashboard adds a Certificates kanban. ACL is enforced both as a Python AccessError and a view-level groups= clause. Notifications go through the existing fp.notification.template framework with a new group-membership recipient resolver; mail.activity is the belt-and-suspenders fallback.

Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Tech Stack: Odoo 19, Python (api/orm/migrations), PostgreSQL, QWeb XML, OWL components, SCSS.


File Inventory (what each task touches)

Path Responsibility
fusion_plating_jobs/models/fp_job.py Selection extension for state; auto-advance helpers; button_mark_shipped; activity helpers; mini-timeline update; card-state extension
fusion_plating_jobs/models/fp_job_step.py Hook button_finish to fire post-shop gates on the last open step
fusion_plating_jobs/views/fp_job_views.xml Mark Shipped button (replaces Mark Done for end-of-line); groups gating
fusion_plating_jobs/data/fp_activity_types_data.xml NEW — mail.activity.type activity_type_issue_coc
fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py NEW — backfill mid-flight jobs into new states
fusion_plating_jobs/tests/test_post_shop_states.py NEW — unit coverage for the new transitions + helpers
fusion_plating_jobs/__manifest__.py Version bump 19.0.10.31.019.0.11.0.0
fusion_plating_certificates/models/fp_certificate.py action_issue ACL guard + post-issue hook; write({'state':'voided'}) override; x_fc_age_hours computed
fusion_plating_certificates/views/fp_certificate_views.xml groups= on Issue button
fusion_plating_certificates/__manifest__.py Version bump
fusion_plating_shopfloor/controllers/plant_kanban.py Domain widen; _resolve_card_area extension; new chips; KPI compute; new sort priorities
fusion_plating_shopfloor/static/src/js/plant_kanban.js Two new KPI tiles + filter chips
fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss New SCSS tokens (light + dark via $o-webclient-color-scheme)
fusion_plating_shopfloor/static/src/scss/_plant_card.scss New .state-awaiting_cert / .state-awaiting_ship modifier classes
fusion_plating_shopfloor/__manifest__.py Version bump
fusion_plating_quality/controllers/fp_quality_dashboard.py Add certificates block to counts response
fusion_plating_quality/static/src/js/fp_quality_dashboard.js Sixth tab; ?tab=certificates deep-link parsing
fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml Sixth tab template
fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss Tab styling
fusion_plating_quality/__manifest__.py Version bump; add fusion_plating_certificates to depends
fusion_plating_notifications/models/fp_notification_template.py New trigger_event selection values; recipient resolver helper
fusion_plating_notifications/data/fp_cert_authority_templates.xml NEW — seeded templates for the two events
fusion_plating_notifications/__manifest__.py Version bump
fusion_plating_quality/scripts/bt_post_shop_states.py NEW — battle-test script for entech smoke

Phase 1 — State machine foundation

Task 1: Extend fp.job.state Selection with two new values

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Step 1: Inspect the existing fp.job state field

The canonical model lives in fusion_plating/models/fp_job.py:85-95 and defines state with values ('draft', 'confirmed', 'in_progress', 'on_hold', 'done', 'cancelled'). We extend in the inheriting class without rewriting the field.

  • Step 2: Add the Selection extension at the top of FpJob in fusion_plating_jobs/models/fp_job.py

Open fusion_plating_jobs/models/fp_job.py and find the class FpJob(models.Model) block at line 34. Add this field right BEFORE the existing x_fc_delivery_method = fields.Selection(...) field (around line 41) so the state extension is the first declaration:

    # ===== Post-shop state extension (spec 2026-05-25) =================
    # Two intermediate states between in_progress and done so completed
    # jobs awaiting cert + shipping stay visible on the Shop Floor board.
    # See docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
    state = fields.Selection(
        selection_add=[
            ('awaiting_cert', 'Awaiting Cert'),
            ('awaiting_ship', 'Awaiting Ship'),
        ],
        ondelete={'awaiting_cert': 'set default', 'awaiting_ship': 'set default'},
    )

selection_add appends new values WITHOUT replacing the existing Selection. ondelete is required when adding values to an extended Selection — it tells Odoo what to do with records in the new state if the module is uninstalled (set default falls back to draft).

  • Step 3: Verify the module still loads

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --stop-after-init 2>&1 | tail -30 Expected: no traceback; the log ends with Modules loaded. or Initiating shutdown.

  • Step 4: Commit
git add fusion_plating_jobs/models/fp_job.py
git commit -m "feat(fp.job): extend state with awaiting_cert + awaiting_ship

Per spec docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md.
Selection extension only; transitions wired in subsequent tasks.
"

Task 2: Add the two auto-advance helpers on fp.job

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Test: fusion_plating_jobs/tests/test_post_shop_states.py

  • Step 1: Create the test file with a failing test for _fp_check_advance_post_shop

Create fusion_plating_jobs/tests/test_post_shop_states.py:

# -*- coding: utf-8 -*-
"""Post-shop state transitions (awaiting_cert + awaiting_ship).

Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
"""
from odoo.tests.common import TransactionCase


class TestPostShopAdvance(TransactionCase):
    def setUp(self):
        super().setUp()
        self.partner = self.env['res.partner'].create({'name': 'Cust'})
        self.product = self.env['product.product'].create({'name': 'Widget'})

    def _make_job(self, state='in_progress', **kw):
        vals = {
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 1.0,
            'state': state,
        }
        vals.update(kw)
        return self.env['fp.job'].create(vals)

    def test_advance_helper_exists(self):
        job = self._make_job()
        self.assertTrue(hasattr(job, '_fp_check_advance_post_shop'))

    def test_advance_noop_when_state_not_in_progress(self):
        # confirmed jobs should not be auto-advanced
        job = self._make_job(state='confirmed')
        job._fp_check_advance_post_shop()
        self.assertEqual(job.state, 'confirmed')

    def test_advance_noop_when_no_steps(self):
        # job with zero steps stays put — nothing to evaluate
        job = self._make_job(state='in_progress')
        self.assertFalse(job.step_ids)
        job._fp_check_advance_post_shop()
        self.assertEqual(job.state, 'in_progress')
  • Step 2: Run the test — expect failure (method doesn't exist)

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -40 Expected: AttributeError or test failure on hasattr(job, '_fp_check_advance_post_shop').

  • Step 3: Add the helper to fusion_plating_jobs/models/fp_job.py

Find a good location — near the existing _fp_create_certificates method (around line 2132). Insert this BEFORE it:

    # ==================================================================
    # Post-shop auto-advance helpers (spec 2026-05-25)
    # ------------------------------------------------------------------
    # When the last open recipe step finishes, the job auto-advances to
    # awaiting_cert (if any cert is required) or awaiting_ship (if not).
    # Cert issue auto-advances awaiting_cert → awaiting_ship. Cert void
    # regresses awaiting_ship → awaiting_cert. All helpers are
    # idempotent — safe to call from any hook.
    # ==================================================================
    def _fp_check_advance_post_shop(self):
        """Auto-advance in_progress jobs whose recipe steps are all
        terminal. Called from fp.job.step.button_finish post-super().

        Does NOT raise — gate failures (bake/qty/QC) are surfaced by
        fp.job.step.button_finish BEFORE this is called (per D12). At
        this point the step IS finished and the transition is safe.
        """
        for job in self:
            if job.state != 'in_progress':
                continue
            if not job.step_ids:
                continue
            if any(s.state not in ('done', 'skipped', 'cancelled')
                   for s in job.step_ids):
                continue
            required = job._resolve_required_cert_types() or set()
            new_state = 'awaiting_cert' if required else 'awaiting_ship'
            job.state = new_state
            # Side effects that used to run in button_mark_done — still
            # need to fire here so cert + delivery records exist.
            if new_state == 'awaiting_cert':
                job._fp_create_certificates()
                job._fp_fire_notification('cert_awaiting_issuance')
                job._fp_schedule_cert_activity()
            else:
                job._fp_create_delivery()
                job._fp_fire_notification('job_complete')
  • Step 4: Run the test — expect pass

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -40 Expected: 3 tests pass.

  • Step 5: Commit
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job): add _fp_check_advance_post_shop helper

Auto-advances in_progress → awaiting_cert (or awaiting_ship if no
certs required) when every recipe step is terminal. Idempotent;
does not raise.
"

Task 3: Add the cert-issue + cert-void transition helpers

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Modify: fusion_plating_jobs/tests/test_post_shop_states.py

  • Step 1: Add failing tests for both helpers

Append to test_post_shop_states.py:

    def test_advance_after_cert_issue_helper_exists(self):
        job = self._make_job()
        self.assertTrue(hasattr(job, '_fp_check_advance_after_cert_issue'))

    def test_regress_after_cert_void_helper_exists(self):
        job = self._make_job()
        self.assertTrue(hasattr(job, '_fp_check_regress_after_cert_void'))

    def test_advance_after_cert_issue_idempotent_when_state_wrong(self):
        # Calling on a draft job is a no-op.
        job = self._make_job(state='draft')
        job._fp_check_advance_after_cert_issue()
        self.assertEqual(job.state, 'draft')
  • Step 2: Run tests — expect failure

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: failures on the three new tests.

  • Step 3: Add both helpers below _fp_check_advance_post_shop

In fusion_plating_jobs/models/fp_job.py, immediately after the helper from Task 2:

    def _fp_check_advance_after_cert_issue(self):
        """Called from fp.certificate.action_issue. If every required
        cert for this job is now `issued`, advance awaiting_cert →
        awaiting_ship. Idempotent — safe to call repeatedly.
        """
        for job in self:
            if job.state != 'awaiting_cert':
                continue
            if 'fp.certificate' not in self.env:
                continue
            required = job._resolve_required_cert_types() or set()
            if not required:
                # Edge case: required set went empty after creation
                # (e.g. partner flag toggled). Treat as "ready to ship".
                job.state = 'awaiting_ship'
                job._fp_create_delivery()
                job._fp_resolve_cert_activities()
                continue
            Cert = self.env['fp.certificate'].sudo()
            outstanding = Cert.search_count([
                ('x_fc_job_id', '=', job.id),
                ('certificate_type', 'in', list(required)),
                ('state', '!=', 'issued'),
            ])
            if outstanding == 0:
                job.state = 'awaiting_ship'
                job._fp_create_delivery()
                job._fp_resolve_cert_activities()

    def _fp_check_regress_after_cert_void(self):
        """Called from fp.certificate.write when state=voided. If a
        previously-issued cert is no longer issued, slide the job back
        to awaiting_cert so it reappears in Final Inspection and the
        QM is re-notified.
        """
        for job in self:
            if job.state != 'awaiting_ship':
                continue
            if 'fp.certificate' not in self.env:
                continue
            required = job._resolve_required_cert_types() or set()
            if not required:
                continue
            Cert = self.env['fp.certificate'].sudo()
            outstanding = Cert.search_count([
                ('x_fc_job_id', '=', job.id),
                ('certificate_type', 'in', list(required)),
                ('state', '!=', 'issued'),
            ])
            if outstanding > 0:
                job.state = 'awaiting_cert'
                job._fp_fire_notification('cert_voided_re_notify')
                job._fp_schedule_cert_activity()
  • Step 4: Run tests — expect pass

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: 6 tests pass.

  • Step 5: Commit
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job): cert-issue + cert-void state transition helpers

_fp_check_advance_after_cert_issue: bumps awaiting_cert → awaiting_ship
when every required cert is issued.
_fp_check_regress_after_cert_void: bumps awaiting_ship → awaiting_cert
when a previously-issued cert goes back to draft/voided.
"

Phase 2 — Step-level gating hooks

Task 4: Hook fp.job.step.button_finish to gate + trigger advance

Files:

  • Modify: fusion_plating_jobs/models/fp_job_step.py

  • Modify: fusion_plating_jobs/tests/test_post_shop_states.py

  • Step 1: Inspect the current button_finish in fusion_plating_jobs/models/fp_job_step.py

Read the file from offset 1, find the existing button_finish override. We'll wrap it: pre-super gate-on-last-step check, post-super advance trigger.

  • Step 2: Append a failing test
    def test_button_finish_on_last_step_triggers_advance(self):
        """End-to-end: finishing the only step of an in_progress job
        flips state to awaiting_cert (cert required) or awaiting_ship
        (no cert required)."""
        # Skip if recipe-step infra isn't loaded
        if 'fp.job.step' not in self.env:
            self.skipTest('fp.job.step not available')
        job = self._make_job(state='in_progress')
        step = self.env['fp.job.step'].create({
            'job_id': job.id,
            'name': 'Final Inspection',
            'state': 'in_progress',
            'sequence': 10,
        })
        # No certs required (partner has no flags) → awaiting_ship
        step.button_finish()
        self.assertEqual(job.state, 'awaiting_ship')
  • Step 3: Run — expect fail

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: assertEqual(job.state, 'awaiting_ship') fails — state stays in_progress.

  • Step 4: Wrap button_finish in fusion_plating_jobs/models/fp_job_step.py

Locate the existing def button_finish override (or add one if absent — file has an _inherit = 'fp.job.step' class). Add this method (or merge into existing):

    def button_finish(self):
        """Wrap super() with two behaviors:
        1. PRE-super: if this finish would terminalize every step on the
           job AND job is in_progress, run the bake/qty/QC gates that
           used to live in fp.job.button_mark_done. Failure surfaces as
           UserError on this click — operator fixes + retries.
        2. POST-super: call job._fp_check_advance_post_shop() so the
           state auto-advances cleanly.
        """
        # Pre-super: gate on last-step finish per spec D12.
        for step in self:
            if step.state not in ('in_progress', 'paused', 'ready'):
                continue
            job = step.job_id
            if not job or job.state != 'in_progress':
                continue
            # Would this finish leave every step terminal?
            siblings_open = job.step_ids.filtered(
                lambda s: s.id != step.id
                and s.state not in ('done', 'skipped', 'cancelled')
            )
            if siblings_open:
                continue  # not the last open step — skip the gates
            # Run the gates that used to live in button_mark_done.
            # Each raises UserError on failure; operator stays on the
            # step, fixes the issue, retries the click. Manager bypasses
            # via the same context flags as before.
            job._fp_check_finish_gates()
        result = super().button_finish()
        # Post-super: trigger advance for any jobs whose step-finish
        # left them all-terminal.
        jobs = self.mapped('job_id')
        for job in jobs:
            job._fp_check_advance_post_shop()
        return result
  • Step 5: Add the _fp_check_finish_gates helper to fusion_plating_jobs/models/fp_job.py

Right before _fp_check_advance_post_shop, add:

    def _fp_check_finish_gates(self):
        """Run the bake-window / qty-reconciliation / QC gates that
        used to live in button_mark_done. Called from
        fp.job.step.button_finish when the operator is finishing the
        LAST open step on the job.

        Raises UserError on failure — operator stays on the step, fixes
        the issue, retries the click. Manager bypass via the same
        context flags as button_mark_done.
        """
        self.ensure_one()
        # Re-uses the existing button_mark_done body for gate side
        # effects. The state transition + cert/delivery side effects
        # are NOT triggered here — they fire from
        # _fp_check_advance_post_shop after super().button_finish.
        # Set the no-side-effect context to short-circuit the state
        # flip + create_delivery/create_certificates blocks below the
        # gates in button_mark_done.
        try:
            self.with_context(
                fp_check_gates_only=True,
            ).button_mark_done()
        except UserError:
            raise
  • Step 6: Tighten button_mark_done to honor fp_check_gates_only

In fusion_plating_jobs/models/fp_job.py, find the existing button_mark_done method (around line 1799). After all gates pass and BEFORE job.state = 'done' (line 1972), add:

            # When called as a gate-check from fp.job.step.button_finish
            # (per spec D12), exit BEFORE flipping state — the post-shop
            # advance helper handles the actual transition.
            if self.env.context.get('fp_check_gates_only'):
                continue
  • Step 7: Run the test — expect pass

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: all tests pass; the new test confirms state → awaiting_ship.

  • Step 8: Commit
git add fusion_plating_jobs/models/fp_job_step.py fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job.step): wrap button_finish with gate + advance

Pre-super: when finishing the last open step on an in_progress job,
run the bake/qty/QC gates from button_mark_done so failures surface as
UserError on the click. Post-super: trigger _fp_check_advance_post_shop
so the state auto-advances cleanly. Spec D12.
"

Task 5: Add button_mark_shipped and repurpose button_mark_done

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Modify: fusion_plating_jobs/views/fp_job_views.xml

  • Modify: fusion_plating_jobs/tests/test_post_shop_states.py

  • Step 1: Add a failing test for the new button

Append to test_post_shop_states.py:

    def test_button_mark_shipped_requires_awaiting_ship(self):
        from odoo.exceptions import UserError
        job = self._make_job(state='in_progress')
        with self.assertRaises(UserError):
            job.button_mark_shipped()

    def test_button_mark_shipped_from_awaiting_ship_lands_done(self):
        job = self._make_job(state='awaiting_ship')
        job.button_mark_shipped()
        self.assertEqual(job.state, 'done')
        self.assertTrue(job.date_finished)
  • Step 2: Run — expect fail

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: AttributeError: 'fp.job' object has no attribute 'button_mark_shipped'.

  • Step 3: Add button_mark_shipped to fusion_plating_jobs/models/fp_job.py

Right after button_mark_done:

    def button_mark_shipped(self):
        """Manual transition awaiting_ship → done. Operator-facing
        button on the job form; restricted to Manager / Owner via
        groups= on the view button.

        Does NOT run the bake/qty/QC gates — those passed when the job
        first transitioned to awaiting_cert (or awaiting_ship if no
        certs were required). This is just the "yes, shipped" stamp.
        Future hook: delivery.action_mark_delivered will call this
        automatically — out of scope for this iteration.
        """
        for job in self:
            if job.state != 'awaiting_ship':
                raise UserError(_(
                    'Job %s cannot be marked Shipped — state is "%s" '
                    '(expected "awaiting_ship").'
                ) % (job.name, job.state))
            job.state = 'done'
            job.date_finished = fields.Datetime.now()
            job._fp_fire_notification('job_shipped')
            job.message_post(body=_(
                'Marked shipped by %s.'
            ) % self.env.user.name)
        return True
  • Step 4: Run — expect pass

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: 2 new tests pass.

  • Step 5: Add the form-view button

Open fusion_plating_jobs/views/fp_job_views.xml. Find the existing Mark Done button (search for name="button_mark_done"). Add a NEW button right after it (do not remove button_mark_done — it's used by _fp_check_finish_gates internally):

<button name="button_mark_shipped"
        string="Mark Shipped"
        type="object"
        class="oe_highlight"
        invisible="state != 'awaiting_ship'"
        groups="fusion_plating.group_fp_manager,fusion_plating.group_fp_owner"/>

And gate the existing Mark Done button to be invisible (legacy paths still call it programmatically, but operators don't click it):

<!-- Find the existing Mark Done button and add invisible="True" -->
<button name="button_mark_done"
        string="Mark Done"
        type="object"
        invisible="True"/>
  • Step 6: Reload the module and confirm view loads

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --stop-after-init 2>&1 | tail -20 Expected: no view-load errors.

  • Step 7: Commit
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/views/fp_job_views.xml fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job): button_mark_shipped + repurpose button_mark_done

Mark Shipped is the new operator-facing button (Manager/Owner only,
visible from awaiting_ship). button_mark_done stays as an internal
method called by _fp_check_finish_gates; view-level button hidden.
"

Phase 3 — Certificate-side hooks + ACL

Task 6: Add the ACL guard to fp.certificate.action_issue

Files:

  • Modify: fusion_plating_certificates/models/fp_certificate.py

  • Step 1: Read the current action_issue (around line 422 of fp_certificate.py)

Confirm the method signature is def action_issue(self) with a for rec in self loop.

  • Step 2: Inject the guard at the top of the for loop

In fusion_plating_certificates/models/fp_certificate.py, find line 423 (for rec in self:). Inject the guard right BEFORE the existing if rec.state != 'draft' check:

        # ===== ACL guard (spec 2026-05-25 §ACL changes) ==============
        # Only QM / Manager / Owner can issue certificates. Two-layer
        # enforcement; view-level groups= on the button is the other
        # layer. Manager bypass via context for cron / scripted issuance.
        if not self.env.context.get('fp_skip_cert_authority_gate'):
            cert_authority_gids = []
            for xmlid in (
                'fusion_plating.group_fp_quality_manager',
                'fusion_plating.group_fp_manager',
                'fusion_plating.group_fp_owner',
            ):
                grp = self.env.ref(xmlid, raise_if_not_found=False)
                if grp:
                    cert_authority_gids.append(grp.id)
            if cert_authority_gids and not (
                set(self.env.user.all_group_ids.ids)
                & set(cert_authority_gids)
            ):
                from odoo.exceptions import AccessError
                raise AccessError(_(
                    'Only Quality Managers, Managers, and Owners can '
                    'issue certificates. Ask your QM to review and '
                    'issue this CoC.'
                ))
        else:
            from markupsafe import Markup
            self.message_post(body=Markup(_(
                'Cert authority gate <b>bypassed</b> by '
                '<b>%(u)s</b> (context flag fp_skip_cert_authority_gate).'
            )) % {'u': self.env.user.name})
        for rec in self:

Wait — we're INSIDE the for loop already. Re-read carefully: the guard goes BEFORE the loop. Find line 422 def action_issue(self): and the line 423 for rec in self:. Insert the guard block BETWEEN these two lines (so it runs once per call, not per record).

After insertion the structure should be:

    def action_issue(self):
        # === ACL guard (the block above) ===
        for rec in self:
            if rec.state != 'draft':
                raise UserError(_('Only draft certificates can be issued.'))
            # ...existing body...
  • Step 3: Verify the module reloads

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_certificates --stop-after-init 2>&1 | tail -20 Expected: clean load.

  • Step 4: Smoke-test the guard manually via odoo-shell

Run:

docker exec -i odoo-modsdev-app bash -c "echo \"
cert = env['fp.certificate'].search([], limit=1)
if cert:
    print('Found cert:', cert.name, 'state:', cert.state)
    # Try as a non-manager user (use a Technician if present)
    tech = env['res.users'].search([
        ('all_group_ids', 'in', env.ref('fusion_plating.group_fp_technician').id),
        ('share', '=', False),
    ], limit=1)
    if tech:
        print('Trying as Technician:', tech.name)
        try:
            cert.with_user(tech).action_issue()
            print('FAIL: should have raised AccessError')
        except Exception as e:
            print('OK guard raised:', type(e).__name__, str(e)[:100])
\" | odoo shell -c /etc/odoo/odoo.conf -d modsdev --no-http"

Expected output: OK guard raised: AccessError ...Only Quality Managers, Managers, and Owners...

  • Step 5: Commit
git add fusion_plating_certificates/models/fp_certificate.py
git commit -m "feat(fp.certificate): ACL guard on action_issue

QM/Manager/Owner only. Two-layer enforcement; view-level groups= in
next task. Manager bypass via fp_skip_cert_authority_gate context flag
with chatter audit. Per spec 2026-05-25.
"

Task 7: Hook cert state changes back into the job state machine

Files:

  • Modify: fusion_plating_certificates/models/fp_certificate.py

  • Step 1: Add the post-issue callback at the END of action_issue

In fusion_plating_certificates/models/fp_certificate.py, locate the end of action_issue — after rec.state = 'issued' and the existing PDF render + attachment work, add:

            # Post-issue: ask the job to check whether ALL required
            # certs are now issued. If so it'll auto-advance to
            # awaiting_ship and resolve the QM activity. Spec 2026-05-25.
            if 'x_fc_job_id' in rec._fields and rec.x_fc_job_id:
                rec.x_fc_job_id._fp_check_advance_after_cert_issue()

(Place this inside the for rec in self: loop, after all existing state-flip work.)

  • Step 2: Add a write override that detects voiding

After the action_issue method, add:

    def write(self, vals):
        """Override to detect cert voiding and trigger the job state
        regress (awaiting_ship → awaiting_cert). Spec 2026-05-25.
        """
        was_issued = {}
        if 'state' in vals and vals['state'] == 'voided':
            was_issued = {
                rec.id: (rec.state == 'issued')
                for rec in self
            }
        result = super().write(vals)
        if was_issued:
            for rec in self:
                if not was_issued.get(rec.id):
                    continue  # wasn't issued to begin with — no regress
                if 'x_fc_job_id' in rec._fields and rec.x_fc_job_id:
                    rec.x_fc_job_id._fp_check_regress_after_cert_void()
        return result
  • Step 3: Reload and verify clean load

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_certificates --stop-after-init 2>&1 | tail -20 Expected: clean load.

  • Step 4: Commit
git add fusion_plating_certificates/models/fp_certificate.py
git commit -m "feat(fp.certificate): wire state changes back to fp.job

action_issue calls job._fp_check_advance_after_cert_issue so the job
auto-advances to awaiting_ship when all certs are issued.
write({'state':'voided'}) calls job._fp_check_regress_after_cert_void
so the job slides back to awaiting_cert and the QM is re-notified.
"

Task 8: Add x_fc_age_hours computed field + view-level groups gating

Files:

  • Modify: fusion_plating_certificates/models/fp_certificate.py

  • Modify: fusion_plating_certificates/views/fp_certificate_views.xml

  • Modify: fusion_plating_certificates/__manifest__.py

  • Step 1: Add the field to fp.certificate

In fusion_plating_certificates/models/fp_certificate.py, near the other compute fields (e.g. around the _compute_reading_stats block), add:

    x_fc_age_hours = fields.Float(
        string='Age (hours)',
        compute='_compute_x_fc_age_hours',
        help='Hours since the cert was created. Drives the Quality '
             'Dashboard age chip and overdue filter.',
    )

    def _compute_x_fc_age_hours(self):
        from datetime import datetime
        now = datetime.now()
        for rec in self:
            if not rec.create_date:
                rec.x_fc_age_hours = 0.0
                continue
            delta = now - rec.create_date
            rec.x_fc_age_hours = delta.total_seconds() / 3600.0
  • Step 2: Add groups= to the Issue button

In fusion_plating_certificates/views/fp_certificate_views.xml, find the Issue button (search for name="action_issue"). Add the groups= attribute:

<button name="action_issue"
        string="Issue"
        type="object"
        class="oe_highlight"
        invisible="state != 'draft'"
        groups="fusion_plating.group_fp_quality_manager,fusion_plating.group_fp_manager,fusion_plating.group_fp_owner"/>

(Preserve existing attributes; only add groups=.)

  • Step 3: Bump version in fusion_plating_certificates/__manifest__.py

Find the existing version line. Bump to 19.0.6.0.0 (major bump for the ACL + state changes per spec).

  • Step 4: Reload + verify

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_certificates --stop-after-init 2>&1 | tail -20 Expected: clean load; Issue button hidden for non-authority users.

  • Step 5: Commit
git add fusion_plating_certificates/
git commit -m "feat(fp.certificate): x_fc_age_hours + view-level Issue gating

x_fc_age_hours: non-stored Float, drives Quality Dashboard age chip +
overdue filter.
View-level groups= on Issue button so non-QMs don't even see it.
Version bump 19.0.6.0.0.
"

Phase 4 — Plant Kanban visibility

Task 9: Widen the kanban domain + extend _resolve_card_area

Files:

  • Modify: fusion_plating_shopfloor/controllers/plant_kanban.py

  • Step 1: Widen the domain

In fusion_plating_shopfloor/controllers/plant_kanban.py around line 73, replace:

        domain = [
            ('state', 'in', ('confirmed', 'in_progress')),
        ]

with:

        domain = [
            ('state', 'in', ('confirmed', 'in_progress',
                             'awaiting_cert', 'awaiting_ship')),
        ]

Also update the comment block above (lines 69-72) to reflect spec 2026-05-25:

        # Base domain — in-flight jobs.
        # 2026-05-25 (spec post-shop-cert-shipping-job-states): awaiting_cert
        # + awaiting_ship are included so completed-but-uncertified /
        # ready-to-ship jobs stay visible in the Final inspection /
        # Shipping columns.
  • Step 2: Extend _resolve_card_area

Around line 165, find the _resolve_card_area function. Replace its body with:

def _resolve_card_area(job):
    """Pick the column a card lives in.

    Priority (spec 2026-05-25):
      1. no_parts cards → Receiving (the receiver acts there)
      2. awaiting_cert → Final inspection (state drives column)
      3. awaiting_ship → Shipping (state drives column)
      4. active step's area_kind (in_progress jobs with live work)
      5. orphan fallback → Receiving

    See specs 2026-05-24-shopfloor-live-step-fix-design.md Change 4
    and 2026-05-25-post-shop-cert-shipping-job-states-design.md.
    """
    if job.card_state == 'no_parts':
        return 'receiving'
    if job.state == 'awaiting_cert':
        return 'inspection'
    if job.state == 'awaiting_ship':
        return 'shipping'
    if job.active_step_id and job.active_step_id.area_kind:
        return job.active_step_id.area_kind
    return 'receiving'  # orphan fallback
  • Step 3: Reload + smoke test

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20 Expected: clean load.

  • Step 4: Commit
git add fusion_plating_shopfloor/controllers/plant_kanban.py
git commit -m "feat(plant_kanban): include awaiting_cert + awaiting_ship

Widens the domain so completed-but-uncertified / ready-to-ship jobs
stay visible. _resolve_card_area pins them to Final inspection /
Shipping columns regardless of (now-empty) active_step_id.
"

Task 10: Add new card_state values, chips, and sort priorities

Files:

  • Modify: fusion_plating_shopfloor/controllers/plant_kanban.py

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Step 1: Add the two new entries to _SORT_PRIORITY in plant_kanban.py

Around line 36-50 in fusion_plating_shopfloor/controllers/plant_kanban.py, find _SORT_PRIORITY. Add the two new entries (use floats — sort_key returns a tuple and float comparison is fine):

_SORT_PRIORITY = {
    'on_hold':            0,
    'no_parts':           1,
    'bake_due':           2,
    'awaiting_signoff':   3,
    'awaiting_cert':      3.5,  # NEW — sit right after awaiting_signoff
    'awaiting_qc':        4,
    'ready_mine':         5,
    'running_mine':       6,
    'ready':              7,
    'running':            8,
    'awaiting_ship':      8.5,  # NEW — after running, before idle
    'idle_warning':       9,
    'predecessor_locked': 10,
    'contract_review':    11,
    'done':               12,
}
  • Step 2: Extend _state_chip for the two new states

Find _state_chip() around line 293. Add the two new branches BEFORE the if card_state == 'done': line:

    if card_state == 'awaiting_cert':
        return {'label': _('🏷️ Awaiting CoC'), 'kind': 'awaiting_cert'}
    if card_state == 'awaiting_ship':
        return {'label': _('📦 Ready to ship'), 'kind': 'awaiting_ship'}
  • Step 3: Add the card_state values to _compute_card_state in fusion_plating_jobs/models/fp_job.py

Find _compute_card_state around line 262 in fusion_plating_jobs/models/fp_job.py. Inside the for job in self: loop, BEFORE the existing # Rule 8 — done block (around line 315), add:

            # Rule 7.5 — awaiting_cert + awaiting_ship (spec 2026-05-25)
            # State drives card_state here regardless of step state.
            if job.state == 'awaiting_cert':
                job.card_state = 'awaiting_cert'
                continue
            if job.state == 'awaiting_ship':
                job.card_state = 'awaiting_ship'
                continue

ALSO update the @api.depends decorator above _compute_card_state (line 252) to include 'state':

    @api.depends(
        'state',
        'active_step_id',
        ...
    )

(The existing decorator already lists 'state' — verify but no change needed if so.)

  • Step 4: Reload + verify

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20 Expected: clean load.

  • Step 5: Commit
git add fusion_plating_shopfloor/controllers/plant_kanban.py fusion_plating_jobs/models/fp_job.py
git commit -m "feat(card_state): awaiting_cert + awaiting_ship chips + sort

New card_state values mirror the new fp.job.state values; chips
read 'Awaiting CoC' / 'Ready to ship'. Sort priorities slot them
right after awaiting_signoff / running.
"

Task 11: Add the SCSS tokens + state modifier classes

Files:

  • Modify: fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss

  • Modify: fusion_plating_shopfloor/static/src/scss/_plant_card.scss

  • Step 1: Add tokens to _plant_tokens.scss (light + dark)

Open fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss. Find the existing $_state-* hex declarations and add light values:

// Spec 2026-05-25 — post-shop states
$_state-awaiting-cert-bg-hex:   #fff3cd;
$_state-awaiting-cert-bdr-hex:  #ff9800;
$_state-awaiting-ship-bg-hex:   #d1f1d4;
$_state-awaiting-ship-bdr-hex:  #2e7d32;

Find the existing @if $o-webclient-color-scheme == dark { ... !global; } block. Add dark overrides INSIDE it:

@if $o-webclient-color-scheme == dark {
    // ...existing dark overrides...
    $_state-awaiting-cert-bg-hex:   #3a2f15 !global;
    $_state-awaiting-cert-bdr-hex:  #ffb74d !global;
    $_state-awaiting-ship-bg-hex:   #1a2d1f !global;
    $_state-awaiting-ship-bdr-hex:  #66bb6a !global;
}

Then expose them as CSS-custom-prop-backed SCSS vars (mirror the existing pattern, search for one to copy):

$plant-state-awaiting-cert-bg:  var(--fp-state-awaiting-cert-bg,  $_state-awaiting-cert-bg-hex);
$plant-state-awaiting-cert-bdr: var(--fp-state-awaiting-cert-bdr, $_state-awaiting-cert-bdr-hex);
$plant-state-awaiting-ship-bg:  var(--fp-state-awaiting-ship-bg,  $_state-awaiting-ship-bg-hex);
$plant-state-awaiting-ship-bdr: var(--fp-state-awaiting-ship-bdr, $_state-awaiting-ship-bdr-hex);
  • Step 2: Add state-modifier classes to _plant_card.scss

Open fusion_plating_shopfloor/static/src/scss/_plant_card.scss. Find the existing .o_fp_plant_card.state-* classes (search for &.state-). Add the two new modifier classes following the established pattern:

&.state-awaiting_cert {
    background-color: $plant-state-awaiting-cert-bg;
    border-left: 4px solid $plant-state-awaiting-cert-bdr;
}
&.state-awaiting_ship {
    background-color: $plant-state-awaiting-ship-bg;
    border-left: 4px solid $plant-state-awaiting-ship-bdr;
}
  • Step 3: Bust the asset cache + reload

Run:

docker exec odoo-modsdev-db psql -U odoo -d modsdev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20

Expected: clean load.

  • Step 4: Commit
git add fusion_plating_shopfloor/static/src/scss/
git commit -m "feat(plant kanban): SCSS tokens + classes for awaiting_cert/ship

Light + dark variants via the existing \$o-webclient-color-scheme
compile-time branch (per Rule 9). amber for awaiting_cert, green for
awaiting_ship. Border-left 4px to match the existing state pattern.
"

Task 12: Add KPI tiles + filter chips + KPI server compute

Files:

  • Modify: fusion_plating_shopfloor/controllers/plant_kanban.py

  • Modify: fusion_plating_shopfloor/static/src/js/plant_kanban.js

  • Step 1: Server-side: extend the kpis dict around line 124

In fusion_plating_shopfloor/controllers/plant_kanban.py, find the kpis = {...} dict. Add two entries:

        kpis = {
            'active_jobs': sum(1 for j in jobs if j.state != 'done'),
            'at_my_station': sum(
                1 for j in jobs
                if j.card_state in ('ready_mine', 'running_mine')
            ),
            'bakes_due_soon': sum(
                1 for j in jobs if j.card_state == 'bake_due'
            ),
            'on_hold': sum(
                1 for j in jobs if j.card_state == 'on_hold'
            ),
            'awaiting_cert': sum(
                1 for j in jobs if j.state == 'awaiting_cert'
            ),
            'awaiting_ship': sum(
                1 for j in jobs if j.state == 'awaiting_ship'
            ),
            'overdue': sum(
                1 for j in jobs
                if j.date_deadline and j.date_deadline.date() < date.today()
                and j.state != 'done'
            ),
        }
  • Step 2: Server-side: extend the filter logic around line 76

Find the filters = filters or {} block. Add two new filter clauses:

        if filters.get('awaiting_cert'):
            domain.append(('state', '=', 'awaiting_cert'))
        if filters.get('awaiting_ship'):
            domain.append(('state', '=', 'awaiting_ship'))
  • Step 3: Client-side: extend KPI tiles in plant_kanban.js

Open fusion_plating_shopfloor/static/src/js/plant_kanban.js. Find the KPI tile array (search for at_my_station or the existing KPI definitions). Add two entries following the existing pattern (likely a _t() label + key + kind):

{ key: 'awaiting_cert', label: _t('Awaiting CoC'),
  count: kpis.awaiting_cert, kind: 'awaiting_cert' },
{ key: 'awaiting_ship', label: _t('Ready to Ship'),
  count: kpis.awaiting_ship, kind: 'awaiting_ship' },

(If the existing structure uses a different shape, mirror it — don't invent new field names.)

  • Step 4: Client-side: extend filter chips

In the same file, find the filter chip definitions (search for running, blocked, overdue, fair chips). Add two:

{ key: 'awaiting_cert', label: _t('Awaiting CoC') },
{ key: 'awaiting_ship', label: _t('Ready to Ship') },
  • Step 5: Bust asset cache + reload

Run:

docker exec odoo-modsdev-db psql -U odoo -d modsdev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_shopfloor --stop-after-init 2>&1 | tail -20

Expected: clean load.

  • Step 6: Commit
git add fusion_plating_shopfloor/
git commit -m "feat(plant kanban): KPI tiles + filter chips for new states

Two new clickable KPI tiles ('Awaiting CoC' / 'Ready to Ship') and
two matching filter chips. Server-side filter clauses honor the
state values. Server-side KPI counts.
"

Task 13: Update _compute_mini_timeline_json for the two new states

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Step 1: Read the current _compute_mini_timeline_json (around line 339-370)

Confirm the current shape: for each of 9 areas, emits {area, state, variant?}.

  • Step 2: Extend the compute to handle the two new states

Replace the body of _compute_mini_timeline_json with this updated version:

    @api.depends(
        'step_ids.state',
        'step_ids.area_kind',
        'active_step_id',
        'card_state',
        'state',
    )
    def _compute_mini_timeline_json(self):
        """9-element JSON array, one per Shop Floor column.

        For awaiting_cert / awaiting_ship (spec 2026-05-25): the
        Final-inspection or Shipping dot renders as 'current' with the
        state-named variant; all earlier dots render 'done'.
        """
        for job in self:
            # Post-shop state override: visually walk the card across
            # the two right-most columns even though the recipe may not
            # have steps with those area_kinds.
            if job.state == 'awaiting_cert':
                timeline = []
                for area in _COLUMN_SEQUENCE:
                    if area == 'inspection':
                        timeline.append({
                            'area': area,
                            'state': 'current',
                            'variant': 'awaiting_cert',
                        })
                    elif area == 'shipping':
                        timeline.append({'area': area, 'state': 'upcoming'})
                    else:
                        timeline.append({'area': area, 'state': 'done'})
                job.mini_timeline_json = json.dumps(timeline)
                continue
            if job.state == 'awaiting_ship':
                timeline = []
                for area in _COLUMN_SEQUENCE:
                    if area == 'shipping':
                        timeline.append({
                            'area': area,
                            'state': 'current',
                            'variant': 'awaiting_ship',
                        })
                    else:
                        timeline.append({'area': area, 'state': 'done'})
                job.mini_timeline_json = json.dumps(timeline)
                continue
            # Standard path — pre-existing logic.
            active_area = (job.active_step_id.area_kind
                           if job.active_step_id else None)
            timeline = []
            for area in _COLUMN_SEQUENCE:
                steps_in_area = job.step_ids.filtered(
                    lambda s: s.area_kind == area,
                )
                if not steps_in_area:
                    timeline.append({'area': area, 'state': 'upcoming'})
                    continue
                if all(s.state in ('done', 'skipped') for s in steps_in_area):
                    timeline.append({'area': area, 'state': 'done'})
                elif area == active_area:
                    timeline.append({
                        'area': area,
                        'state': 'current',
                        'variant': job.card_state or '',
                    })
                else:
                    timeline.append({'area': area, 'state': 'upcoming'})
            job.mini_timeline_json = json.dumps(timeline)
  • Step 3: Reload + verify

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --stop-after-init 2>&1 | tail -20 Expected: clean load.

  • Step 4: Commit
git add fusion_plating_jobs/models/fp_job.py
git commit -m "feat(mini_timeline): walk the dot for awaiting_cert/ship

awaiting_cert → all left dots done, inspection current, shipping upcoming.
awaiting_ship → all left dots done, shipping current.
Variant carries the card_state so the dot tinting matches the chip.
"

Phase 5 — Quality Dashboard "Certificates" tab

Task 14: Add the certificates block to the counts endpoint

Files:

  • Modify: fusion_plating_quality/controllers/fp_quality_dashboard.py

  • Step 1: Extend the endpoint

In fusion_plating_quality/controllers/fp_quality_dashboard.py, find the counts method (line 16). At the top of the method add:

        Cert = env['fp.certificate'] if 'fp.certificate' in env else None

In the returned dict, add certificates as the LAST block:

            'certificates': ({
                'open': Cert.search_count([('state', '=', 'draft')]),
                'overdue': Cert.search_count([
                    ('state', '=', 'draft'),
                    ('create_date', '<', d1),
                ]),
            } if Cert is not None else {'open': 0, 'overdue': 0}),
  • Step 2: Update the docstring

Append to the existing docstring (lines 17-26):

          - Certificate: state='draft' for > 1 day = overdue
  • Step 3: Verify the endpoint responds

Run an HTTP smoke test via odoo-shell:

docker exec -i odoo-modsdev-app bash -c "echo \"
controller = env.registry['fp.quality.dashboard']
\" | odoo shell -c /etc/opp/odoo.conf -d modsdev --no-http"

OR simpler: reload and rely on UI test in Task 15.

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_quality --stop-after-init 2>&1 | tail -10 Expected: clean load.

  • Step 4: Commit
git add fusion_plating_quality/controllers/fp_quality_dashboard.py
git commit -m "feat(quality_dashboard): add certificates block to counts

Open = draft certs. Overdue = draft and create_date > 24h.
Falls back to {open:0, overdue:0} when fp.certificate isn't installed.
"

Task 15: Add the sixth tab to the Quality Dashboard OWL component

Files:

  • Modify: fusion_plating_quality/static/src/js/fp_quality_dashboard.js

  • Modify: fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml

  • Modify: fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss

  • Step 1: Read the existing dashboard to understand its tab pattern

Read fusion_plating_quality/static/src/js/fp_quality_dashboard.js (first 80 lines) and fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml (first 80 lines). Identify how the existing 5 tabs are declared.

  • Step 2: Add the Certificates tab to the JS state

In fp_quality_dashboard.js, find where the tab list / tab state is defined. The existing tabs likely look like ['holds', 'checks', 'ncrs', 'capas', 'rmas']. Add 'certificates' to the end:

const TABS = ['holds', 'checks', 'ncrs', 'capas', 'rmas', 'certificates'];

(Adjust to match the existing pattern — if it's an object keyed by tab name, follow that.)

Also support the URL deep-link ?tab=certificates in setup():

setup() {
    // ...existing setup...
    // Spec 2026-05-25 — honor ?tab=<name> query param from notification deep-links
    const params = this.action?.context?.params || {};
    const initialTab = params.tab || 'holds';
    if (TABS.includes(initialTab)) {
        this.state.activeTab = initialTab;
    }
}
  • Step 3: Add the tab row + content to the XML template

In fp_quality_dashboard.xml, find the existing tab row (search for t-on-click="() => this.setTab('holds')" or similar). Add a sixth <button> mirroring the pattern:

<button class="fp-tab" t-att-class="{ active: state.activeTab === 'certificates' }"
        t-on-click="() => this.setTab('certificates')">
    🏷️ Certificates
    <span class="fp-tab-count" t-esc="counts.certificates?.open or 0"/>
    <span class="fp-tab-overdue" t-if="counts.certificates?.overdue"
          t-esc="counts.certificates.overdue"/>
</button>

Add a sixth content panel below the existing five (search for t-if="state.activeTab === 'rmas'"):

<div t-if="state.activeTab === 'certificates'" class="fp-tab-panel">
    <!-- Embedded kanban view of fp.certificate filtered to draft+issued+voided.
         Reuses Odoo's view registry; the action xmlid points at the
         existing fp.certificate kanban view. -->
    <ViewLoader t-props="{
        type: 'kanban',
        resModel: 'fp.certificate',
        domain: state.certFilter || [],
        context: { group_by: 'state', search_default_state_draft: 1 },
    }"/>
</div>

(If the existing tabs don't use ViewLoader, mirror whatever pattern they use — likely an inline kanban template defined within the OWL component. The point is: this tab shows a kanban of certs grouped by state, draft folded open.)

  • Step 4: Add Certificates tab styling

In fp_quality_dashboard.scss, add a color for the new tab matching the existing pattern:

.fp-tab[data-key="certificates"] {
    --fp-tab-accent: #ff9800;  // amber
}
  • Step 5: Bust asset cache + reload

Run:

docker exec odoo-modsdev-db psql -U odoo -d modsdev -c "DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_quality --stop-after-init 2>&1 | tail -20

Expected: clean load. Open http://localhost:8082/odoo/action-fp_quality_dashboard in browser → confirm 6 tabs visible.

  • Step 6: Commit
git add fusion_plating_quality/static/src/
git commit -m "feat(quality_dashboard): sixth 'Certificates' tab

Tab content: kanban of fp.certificate grouped by state, draft folded
open. ?tab=certificates URL param parsed by setup() so the notification
deep-link lands on the right tab.
"

Task 16: Manifest bump + add fusion_plating_certificates to depends

Files:

  • Modify: fusion_plating_quality/__manifest__.py

  • Step 1: Check the depends list

Open fusion_plating_quality/__manifest__.py. Check whether fusion_plating_certificates is already in 'depends'. Most likely yes — but verify, since we're now reading fp.certificate directly in the controller and grouping a tab on it. If absent, add it.

  • Step 2: Bump version

Bump version to 19.0.6.0.0 per spec.

  • Step 3: Reload + verify

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_quality --stop-after-init 2>&1 | tail -10 Expected: clean load.

  • Step 4: Commit
git add fusion_plating_quality/__manifest__.py
git commit -m "chore(fusion_plating_quality): version 19.0.6.0.0; certs dep"

Phase 6 — Notification + Activity

Task 17: Add the two new trigger_event selection values

Files:

  • Modify: fusion_plating_notifications/models/fp_notification_template.py

  • Step 1: Locate the existing trigger_event Selection

Run: grep -n "trigger_event" K:/Github/Odoo-Modules/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py Identify the Selection definition.

  • Step 2: Add the two new values

If trigger_event is defined in this file directly (not via selection_add), append:

('cert_awaiting_issuance', 'Cert Awaiting Issuance'),
('cert_voided_re_notify',  'Cert Voided — Please Re-Issue'),

If it's already extended elsewhere, follow the extension pattern.

  • Step 3: Reload + verify

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_notifications --stop-after-init 2>&1 | tail -10 Expected: clean load.

  • Step 4: Commit
git add fusion_plating_notifications/models/fp_notification_template.py
git commit -m "feat(notifications): cert_awaiting_issuance + cert_voided events

Per spec 2026-05-25. Used by fp.job auto-transitions to alert QM
authority group.
"

Task 18: Add _fp_resolve_cert_authority_users recipient resolver

Files:

  • Modify: fusion_plating_notifications/models/fp_notification_template.py

  • Step 1: Add the helper near other recipient-resolution helpers in the file

Append (or place near existing resolvers):

    @api.model
    def _fp_resolve_cert_authority_users(self, source_record=None):
        """Return active, non-share users holding QM | Manager | Owner
        (transitive via all_group_ids). Per Rule 13l, direct user_ids on
        the group record only catches DIRECT memberships; Owners reach
        QM authority via the implication chain and would be missed by
        a naive .user_ids walk.

        Spec 2026-05-25.
        """
        gids = []
        for xmlid in (
            'fusion_plating.group_fp_quality_manager',
            'fusion_plating.group_fp_manager',
            'fusion_plating.group_fp_owner',
        ):
            grp = self.env.ref(xmlid, raise_if_not_found=False)
            if grp:
                gids.append(grp.id)
        if not gids:
            return self.env['res.users']
        return self.env['res.users'].sudo().search([
            ('all_group_ids', 'in', gids),
            ('share', '=', False),
            ('active', '=', True),
        ])
  • Step 2: Wire dispatch — find the existing dispatch method and add a branch for the two new events

Search for _dispatch or the method that resolves recipients per trigger_event. Add (or mirror the existing pattern):

        if template.trigger_event in (
            'cert_awaiting_issuance', 'cert_voided_re_notify',
        ):
            return self._fp_resolve_cert_authority_users(source_record)
  • Step 3: Reload + verify

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_notifications --stop-after-init 2>&1 | tail -10 Expected: clean load.

  • Step 4: Commit
git add fusion_plating_notifications/models/fp_notification_template.py
git commit -m "feat(notifications): cert_authority group-membership resolver

Returns every QM/Manager/Owner via all_group_ids (transitive), so
Owners reaching QM authority via implication don't get missed.
Wired to dispatch for cert_awaiting_issuance + cert_voided_re_notify.
"

Task 19: Seed the two notification templates + add activity type

Files:

  • Create: fusion_plating_notifications/data/fp_cert_authority_templates.xml

  • Create: fusion_plating_jobs/data/fp_activity_types_data.xml

  • Modify: fusion_plating_notifications/__manifest__.py

  • Modify: fusion_plating_jobs/__manifest__.py

  • Step 1: Create the activity type data file

Create fusion_plating_jobs/data/fp_activity_types_data.xml:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="1">
        <record id="activity_type_issue_coc" model="mail.activity.type">
            <field name="name">Issue CoC</field>
            <field name="summary">Issue Certificate of Conformance</field>
            <field name="icon">fa-certificate</field>
            <field name="delay_count">1</field>
            <field name="delay_unit">days</field>
            <field name="delay_from">current_date</field>
            <field name="res_model">fp.job</field>
            <field name="default_note">Job has finished the shop floor. Review the inspection prompts captured on the final step, then issue the CoC.</field>
        </record>
    </data>
</odoo>
  • Step 2: Create the notification templates data file

Create fusion_plating_notifications/data/fp_cert_authority_templates.xml:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="1">
        <record id="tpl_cert_awaiting_issuance" model="fp.notification.template">
            <field name="name">Cert Awaiting Issuance</field>
            <field name="trigger_event">cert_awaiting_issuance</field>
            <field name="model_id" ref="fusion_plating.model_fp_job"/>
            <field name="subject">🏷️ Job ${object.display_wo_name} ready for CoC issuance</field>
            <field name="body_html"><![CDATA[
<div>
  <p>Hi,</p>
  <p>Job <strong t-out="object.display_wo_name"/> (<t t-out="object.partner_id.name"/>)
     has finished the shop floor and is awaiting CoC issuance.</p>
  <table>
    <tr><td><strong>Part:</strong></td><td t-out="object.part_catalog_id.part_number or ''"/></tr>
    <tr><td><strong>Quantity:</strong></td><td t-out="object.qty_done or 0"/></tr>
    <tr><td><strong>Recipe:</strong></td><td t-out="object.recipe_id.name or ''"/></tr>
  </table>
  <p>Review the inspection prompts captured by the operator on the Final Inspection step,
     then issue the CoC from the Quality Dashboard.</p>
  <p>
    <a t-attf-href="/odoo/action-fp_quality_dashboard?tab=certificates">
      → Open Quality Dashboard
    </a>
  </p>
</div>
            ]]></field>
        </record>

        <record id="tpl_cert_voided_re_notify" model="fp.notification.template">
            <field name="name">Cert Voided — Please Re-Issue</field>
            <field name="trigger_event">cert_voided_re_notify</field>
            <field name="model_id" ref="fusion_plating.model_fp_job"/>
            <field name="subject">⚠️ Job ${object.display_wo_name} CoC voided — please re-issue</field>
            <field name="body_html"><![CDATA[
<div>
  <p>Hi,</p>
  <p>A previously-issued CoC for job <strong t-out="object.display_wo_name"/>
     (<t t-out="object.partner_id.name"/>) was voided. The job has slid back to
     <em>Awaiting Cert</em> and is waiting for re-issuance.</p>
  <p>
    <a t-attf-href="/odoo/action-fp_quality_dashboard?tab=certificates">
      → Open Quality Dashboard
    </a>
  </p>
</div>
            ]]></field>
        </record>
    </data>
</odoo>

(Field names like body_html, model_id, subject must match fp.notification.template's actual schema — verify by reading the model file. If the framework uses different fields, mirror them.)

  • Step 3: Register both files in their manifests

In fusion_plating_jobs/__manifest__.py, add to the 'data' list:

'data/fp_activity_types_data.xml',

In fusion_plating_notifications/__manifest__.py, add to the 'data' list:

'data/fp_cert_authority_templates.xml',

Also bump fusion_plating_notifications version (look up current and increment minor: e.g. 19.0.3.0.019.0.4.0.0).

  • Step 4: Reload + verify the records load

Run:

docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs,fusion_plating_notifications --stop-after-init 2>&1 | tail -20

Expected: clean load. Verify via odoo-shell:

docker exec -i odoo-modsdev-app bash -c "echo \"
print('Activity type:', env.ref('fusion_plating_jobs.activity_type_issue_coc', False))
print('Template:', env.ref('fusion_plating_notifications.tpl_cert_awaiting_issuance', False))
\" | odoo shell -c /etc/odoo/odoo.conf -d modsdev --no-http"

Expected: both refs print a record, not None.

  • Step 5: Commit
git add fusion_plating_jobs/data/ fusion_plating_jobs/__manifest__.py fusion_plating_notifications/data/ fusion_plating_notifications/__manifest__.py
git commit -m "feat(notifications): seed cert authority templates + activity type

activity_type_issue_coc: mail.activity.type for the belt-and-suspenders
in-app activity assigned to a QM on awaiting_cert.
tpl_cert_awaiting_issuance + tpl_cert_voided_re_notify: seeded
fp.notification.template records. noupdate=1 so admin edits survive -u.
"

Task 20: Add _fp_schedule_cert_activity + _fp_resolve_cert_activities to fp.job

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py

  • Modify: fusion_plating_jobs/tests/test_post_shop_states.py

  • Step 1: Add failing test for the schedule helper

Append to test_post_shop_states.py:

    def test_schedule_cert_activity_helper_exists(self):
        job = self._make_job()
        self.assertTrue(hasattr(job, '_fp_schedule_cert_activity'))

    def test_resolve_cert_activities_helper_exists(self):
        job = self._make_job()
        self.assertTrue(hasattr(job, '_fp_resolve_cert_activities'))
  • Step 2: Run — expect fail

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: AttributeError on the new tests.

  • Step 3: Add both helpers to fusion_plating_jobs/models/fp_job.py

Add right after _fp_check_regress_after_cert_void:

    def _fp_schedule_cert_activity(self):
        """Schedule an Issue CoC mail.activity for one QM. Round-robin
        by oldest login_date (least recently active QM, likely least
        busy). Idempotent — re-firing while an open activity already
        exists is a no-op.

        Spec 2026-05-25 §mail.activity belt + suspenders.
        """
        self.ensure_one()
        activity_type = self.env.ref(
            'fusion_plating_jobs.activity_type_issue_coc',
            raise_if_not_found=False,
        )
        if not activity_type:
            return
        # Idempotency: if an open activity of this type already exists,
        # don't schedule another. Re-firing on cert_voided_re_notify
        # creates a fresh one ONLY if the prior auto-resolved.
        existing = self.activity_ids.filtered(
            lambda a: a.activity_type_id == activity_type
        )
        if existing:
            return
        Template = self.env['fp.notification.template'].sudo()
        if not hasattr(Template, '_fp_resolve_cert_authority_users'):
            return
        qms = Template._fp_resolve_cert_authority_users(self)
        if not qms:
            return
        # Round-robin: pick the QM who logged in least recently (likely
        # least busy). NULL login_date sorts first.
        qm = qms.sorted(
            lambda u: u.login_date or fields.Datetime.from_string(
                '1970-01-01 00:00:00'
            )
        )[:1]
        self.activity_schedule(
            activity_type_id=activity_type.id,
            user_id=qm.id,
            summary=_('Issue CoC for %s') % (
                self.display_wo_name or self.name or 'job'
            ),
        )

    def _fp_resolve_cert_activities(self):
        """Auto-resolve all open Issue-CoC activities on this job.
        Called from _fp_check_advance_after_cert_issue when the job
        transitions awaiting_cert → awaiting_ship.
        """
        self.ensure_one()
        activity_type = self.env.ref(
            'fusion_plating_jobs.activity_type_issue_coc',
            raise_if_not_found=False,
        )
        if not activity_type:
            return
        open_activities = self.activity_ids.filtered(
            lambda a: a.activity_type_id == activity_type
        )
        for act in open_activities:
            act.action_feedback(feedback=_('Cert issued — auto-resolved.'))
  • Step 4: Run — expect pass

Run: docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev --test-tags fusion_plating_jobs/test_post_shop_states --stop-after-init 2>&1 | tail -30 Expected: 2 new tests pass.

  • Step 5: Commit
git add fusion_plating_jobs/models/fp_job.py fusion_plating_jobs/tests/test_post_shop_states.py
git commit -m "feat(fp.job): mail.activity helpers for cert authority

_fp_schedule_cert_activity: one Issue-CoC activity per job assigned
to oldest-login QM. Idempotent on existing open activity.
_fp_resolve_cert_activities: auto-resolves on awaiting_ship.
"

Phase 7 — Migration

Task 21: Write migrations/19.0.11.0.0/post-migrate.py

Files:

  • Create: fusion_plating_jobs/migrations/19.0.11.0.0/__init__.py (if not present — empty file)

  • Create: fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py

  • Modify: fusion_plating_jobs/__manifest__.py

  • Step 1: Verify the migrations directory exists

Run: ls fusion_plating_jobs/migrations/ 2>&1 Expected output lists existing version directories.

  • Step 2: Create the migration directory + script

Create fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backfill new awaiting_cert / awaiting_ship states for mid-flight jobs.

Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Rules:
  - in_progress + all steps terminal + draft cert exists → awaiting_cert
  - in_progress + all steps terminal + no cert required → awaiting_ship
  - done jobs LEFT ALONE — historically completed

Idempotent: re-running on a fresh upgrade is a no-op because no
in_progress job will match the all-terminal predicate after the first
run.
"""
import logging
from odoo import api, SUPERUSER_ID

_logger = logging.getLogger(__name__)


def migrate(cr, version):
    """Post-migrate entrypoint — called by Odoo after the module's
    XML/Python loads on -u of fusion_plating_jobs."""

    # ---- Pass 1: in_progress + all-terminal + draft cert → awaiting_cert
    cr.execute("""
        UPDATE fp_job
           SET state = 'awaiting_cert'
         WHERE id IN (
            SELECT j.id
              FROM fp_job j
              JOIN fp_job_step s ON s.job_id = j.id
             WHERE j.state = 'in_progress'
             GROUP BY j.id
            HAVING count(*) FILTER (
                WHERE s.state NOT IN ('done','skipped','cancelled')
            ) = 0
         )
           AND EXISTS (
            SELECT 1 FROM fp_certificate c
             WHERE c.x_fc_job_id = fp_job.id AND c.state = 'draft'
         );
    """)
    n_cert = cr.rowcount
    _logger.info(
        "post-migrate 19.0.11.0.0: %d jobs migrated to awaiting_cert", n_cert,
    )

    # ---- Pass 2: in_progress + all-terminal + no cert → awaiting_ship
    cr.execute("""
        UPDATE fp_job
           SET state = 'awaiting_ship'
         WHERE id IN (
            SELECT j.id
              FROM fp_job j
              JOIN fp_job_step s ON s.job_id = j.id
             WHERE j.state = 'in_progress'
             GROUP BY j.id
            HAVING count(*) FILTER (
                WHERE s.state NOT IN ('done','skipped','cancelled')
            ) = 0
         )
           AND NOT EXISTS (
            SELECT 1 FROM fp_certificate c
             WHERE c.x_fc_job_id = fp_job.id
               AND c.state IN ('draft', 'issued')
         );
    """)
    n_ship = cr.rowcount
    _logger.info(
        "post-migrate 19.0.11.0.0: %d jobs migrated to awaiting_ship", n_ship,
    )

    # ---- Card_state recompute for affected rows (stored compute) ----
    if n_cert or n_ship:
        env = api.Environment(cr, SUPERUSER_ID, {})
        affected = env['fp.job'].search([
            ('state', 'in', ('awaiting_cert', 'awaiting_ship')),
        ])
        affected.invalidate_recordset(['card_state', 'mini_timeline_json'])
        # Force recompute by reading — triggers @api.depends recompute.
        affected.mapped('card_state')
        affected.mapped('mini_timeline_json')
        _logger.info(
            "post-migrate 19.0.11.0.0: card_state recomputed on %d jobs",
            len(affected),
        )
  • Step 3: Bump fusion_plating_jobs/__manifest__.py version

Open fusion_plating_jobs/__manifest__.py. Find 'version': '19.0.10.31.0', and change to:

'version': '19.0.11.0.0',

(The version bump is what causes Odoo to run the migration on -u.)

  • Step 4: Smoke-test the migration on dev

The dev DB likely has zero matching rows, so the script will be a no-op — but we want it to RUN without error.

Run:

docker exec odoo-modsdev-app odoo -c /etc/odoo/odoo.conf -d modsdev -u fusion_plating_jobs --stop-after-init 2>&1 | grep -E "(post-migrate|ERROR|Error|Traceback)"

Expected: a line post-migrate 19.0.11.0.0: 0 jobs migrated to awaiting_cert (or non-zero if dev has matching rows). NO Tracebacks.

  • Step 5: Commit
git add fusion_plating_jobs/migrations/19.0.11.0.0/ fusion_plating_jobs/__manifest__.py
git commit -m "feat(fp.job): migration 19.0.11.0.0 — backfill new states

Idempotent post-migrate that moves mid-flight in_progress jobs whose
recipe steps are all terminal into the appropriate new state
(awaiting_cert if any draft cert exists, else awaiting_ship). done
jobs left alone — historically completed.
"

Phase 8 — Battle test + deploy

Task 22: Write the entech smoke battle test

Files:

  • Create: fusion_plating_quality/scripts/bt_post_shop_states.py

  • Step 1: Create the script

Create fusion_plating_quality/scripts/bt_post_shop_states.py:

# -*- coding: utf-8 -*-
"""Battle test — post-shop state machine (awaiting_cert + awaiting_ship).

Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
Plan: docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md

Run via:
  ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"
  exec(open(\\\"/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_post_shop_states.py\\\").read())
  \" | su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'"

10-step verification:
  1. Create SO + job with cert-requiring customer
  2. Walk every step to terminal → assert state='awaiting_cert'
  3. Assert card appears in plant_kanban under 'inspection' column
  4. Assert email + activity scheduled on a QM
  5. As a Technician, call cert.action_issue() → assert AccessError
  6. As a QM, call cert.action_issue() → state='issued', job state→'awaiting_ship'
  7. Assert card moves to 'shipping' column, activity auto-resolves
  8. Void the cert → assert state back to 'awaiting_cert', activity re-scheduled
  9. Re-issue → 'awaiting_ship' again
 10. Click button_mark_shipped (as Manager) → state='done', card off board
"""
from odoo.exceptions import AccessError, UserError


def _assert(cond, label):
    if cond:
        print('OK  -', label)
    else:
        print('FAIL -', label)
        raise SystemExit(1)


# Helper to find a cert-requiring customer
partner = env['res.partner'].search([
    ('x_fc_send_coc', '=', True),
], limit=1)
if not partner:
    print('No cert-requiring customer on this DB; aborting.')
    raise SystemExit(0)

product = env['product.product'].search([], limit=1)
qm = env['res.users'].search([
    ('all_group_ids', 'in', env.ref('fusion_plating.group_fp_quality_manager').id),
    ('share', '=', False), ('active', '=', True),
], limit=1)
tech = env['res.users'].search([
    ('all_group_ids', 'in', env.ref('fusion_plating.group_fp_technician').id),
    ('share', '=', False), ('active', '=', True),
], limit=1)
_assert(bool(qm), 'QM user exists')
_assert(bool(tech), 'Technician user exists')

# 1) Create a job in_progress with one (will-be-final) step
job = env['fp.job'].create({
    'partner_id': partner.id,
    'product_id': product.id,
    'qty': 1.0,
    'state': 'in_progress',
})
step = env['fp.job.step'].create({
    'job_id': job.id,
    'name': 'Final Inspection',
    'state': 'in_progress',
    'sequence': 10,
})

# 2) Finish the step → auto-advance
step.button_finish()
job.invalidate_recordset()
_assert(job.state == 'awaiting_cert', f'state→awaiting_cert (got {job.state})')

# 3) Kanban placement
import odoo.addons.fusion_plating_shopfloor.controllers.plant_kanban as pk
area = pk._resolve_card_area(job)
_assert(area == 'inspection', f'card area is "inspection" (got {area})')

# 4) Activity scheduled
acts = job.activity_ids.filtered(
    lambda a: a.activity_type_id == env.ref('fusion_plating_jobs.activity_type_issue_coc')
)
_assert(bool(acts), 'Issue-CoC activity scheduled')

# 5) Tech tries to issue → AccessError
cert = env['fp.certificate'].search([('x_fc_job_id', '=', job.id)], limit=1)
_assert(bool(cert), 'cert exists on job')
try:
    cert.with_user(tech).action_issue()
    _assert(False, 'Technician issue should raise AccessError')
except AccessError:
    print('OK  - Technician issue raised AccessError')

# 6) QM issues
cert.with_user(qm).action_issue()
job.invalidate_recordset()
_assert(cert.state == 'issued', f'cert.state=issued (got {cert.state})')
_assert(job.state == 'awaiting_ship', f'job state→awaiting_ship (got {job.state})')

# 7) Activity auto-resolved
acts = job.activity_ids.filtered(
    lambda a: a.activity_type_id == env.ref('fusion_plating_jobs.activity_type_issue_coc')
)
_assert(not acts, 'Issue-CoC activity auto-resolved')

# 8) Void cert → regress
cert.write({'state': 'voided'})
job.invalidate_recordset()
_assert(job.state == 'awaiting_cert', f'state regressed to awaiting_cert (got {job.state})')

# 9) New cert + re-issue (since the prior is voided)
new_cert = env['fp.certificate'].create({
    'partner_id': partner.id,
    'certificate_type': 'coc',
    'x_fc_job_id': job.id,
    'state': 'draft',
})
# Lazy-fill of required fields handled by action_issue prefill
new_cert.spec_reference = 'TEST'
new_cert.process_description = 'TEST'
new_cert.certified_by_id = qm.id
new_cert.contact_partner_id = partner.id
new_cert.with_user(qm).action_issue()
job.invalidate_recordset()
_assert(job.state == 'awaiting_ship', f'state→awaiting_ship after re-issue (got {job.state})')

# 10) Manager marks shipped
mgr = env['res.users'].search([
    ('all_group_ids', 'in', env.ref('fusion_plating.group_fp_manager').id),
    ('share', '=', False), ('active', '=', True),
], limit=1)
_assert(bool(mgr), 'Manager user exists')
job.with_user(mgr).button_mark_shipped()
_assert(job.state == 'done', f'state→done (got {job.state})')

print('--- bt_post_shop_states: ALL PASS ---')
env.cr.rollback()  # leave DB clean
  • Step 2: Smoke-test locally first

Run on dev:

docker exec -i odoo-modsdev-app bash -c "echo \"
exec(open('/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_post_shop_states.py').read())
\" | odoo shell -c /etc/odoo/odoo.conf -d modsdev --no-http"

Expected: every line prints OK - ..., ends with --- bt_post_shop_states: ALL PASS ---.

  • Step 3: Commit
git add fusion_plating_quality/scripts/bt_post_shop_states.py
git commit -m "test(bt): post-shop state machine end-to-end smoke

10-step battle test covering: auto-advance on last step finish,
kanban placement, QM activity, ACL guard, cert issue advance,
activity auto-resolve, cert void regress, re-issue, manual ship.
"

Task 23: Deploy to entech (final integration verification)

Files: none (deployment task)

  • Step 1: Push all commits to remotes
git push

Expected output: pushes to both GitHub + Gitea (multi-remote).

  • Step 2: Sync files to entech in dependency order

Run from K:/Github/Odoo-Modules/fusion_plating/. For each modified module, sync via the entech file-copy pattern:

# fusion_plating_notifications first (no dependencies on the new code)
for f in models/fp_notification_template.py data/fp_cert_authority_templates.xml __manifest__.py; do
  cat "fusion_plating_notifications/$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_notifications/$f'"
done

# fusion_plating_jobs (state extension + helpers + migration + activity type)
for f in models/fp_job.py models/fp_job_step.py views/fp_job_views.xml data/fp_activity_types_data.xml migrations/19.0.11.0.0/post-migrate.py tests/test_post_shop_states.py __manifest__.py; do
  cat "fusion_plating_jobs/$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'mkdir -p /mnt/extra-addons/custom/fusion_plating_jobs/$(dirname $f) && cat > /mnt/extra-addons/custom/fusion_plating_jobs/$f'"
done

# fusion_plating_certificates (ACL + hooks)
for f in models/fp_certificate.py views/fp_certificate_views.xml __manifest__.py; do
  cat "fusion_plating_certificates/$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_certificates/$f'"
done

# fusion_plating_shopfloor (kanban + SCSS + JS)
for f in controllers/plant_kanban.py static/src/js/plant_kanban.js static/src/scss/_plant_tokens.scss static/src/scss/_plant_card.scss __manifest__.py; do
  cat "fusion_plating_shopfloor/$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_shopfloor/$f'"
done

# fusion_plating_quality (dashboard tab)
for f in controllers/fp_quality_dashboard.py static/src/js/fp_quality_dashboard.js static/src/xml/fp_quality_dashboard.xml static/src/scss/fp_quality_dashboard.scss __manifest__.py; do
  cat "fusion_plating_quality/$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_quality/$f'"
done

Also copy the battle test:

cat fusion_plating_quality/scripts/bt_post_shop_states.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_post_shop_states.py'"
  • Step 3: Run the upgrade
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_notifications,fusion_plating_jobs,fusion_plating_certificates,fusion_plating_shopfloor,fusion_plating_quality --stop-after-init\" && systemctl start odoo'"

Watch the output for: post-migrate 19.0.11.0.0: N jobs migrated to awaiting_cert and awaiting_ship. NO Tracebacks.

  • Step 4: Clear asset cache (SCSS + JS changed)
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\" && systemctl restart odoo'"
  • Step 5: Run the entech battle test
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"
exec(open(\\\"/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_post_shop_states.py\\\").read())
\" | su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'"

Expected: --- bt_post_shop_states: ALL PASS ---

  • Step 6: Manual verification on entech via browser

Open https://enplating.com as admin. Navigate to Plating → Shop Floor → Plant Kanban. Verify:

  • Two new KPI tiles ("Awaiting CoC" / "Ready to Ship") visible
  • Two new filter chips visible
  • If any migrated job exists in awaiting_cert or awaiting_ship, it appears in the correct column with the correct chip + border tint

Then Plating → Quality → Dashboard. Verify:

  • Sixth tab "Certificates" visible
  • Counts in header strip correct

Then find SO-30058's job:

ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"psql -d admin -c \\\"SELECT id, name, state FROM fp_job WHERE sale_order_id IN (SELECT id FROM sale_order WHERE name = 'SO-30058');\\\"\"'"
  • If state was done → unchanged (historical, left alone per spec)

  • If state was in_progress with all steps terminal → now awaiting_cert or awaiting_ship

  • Step 7: Tag the deploy

cd K:/Github/Odoo-Modules/fusion_plating
git tag deploy/entech/2026-05-25-post-shop-cert-shipping
git push --tags

Self-Review (run after writing the plan)

The author of this plan performed the following checks on the assembled plan:

1. Spec coverage — every spec section maps to a task:

  • §State machine + transitions → Tasks 1-5
  • §Plant Kanban changes (controller, card_state, chip, sort, SCSS, KPI, mini-timeline) → Tasks 9-13
  • §Quality Dashboard changes → Tasks 14-16
  • §ACL changes → Tasks 6, 8
  • §Notification changes (events, templates, recipient resolver, activity) → Tasks 17-20
  • §Migration plan → Task 21
  • §Testing strategy (battle test + unit tests) → Tasks 22 (+ unit tests interleaved with tasks 2-5, 20)
  • §Files touched inventory → File Inventory table at top
  • §D12 (gates moved into step.button_finish) → Task 4 Step 4-6 implements this directly

2. Placeholder scan — no TBDs, no "implement later", no "add appropriate error handling". Every code block contains the actual code or the actual exact text to add.

3. Type consistency — all helper names match across tasks:

  • _fp_check_advance_post_shop — Task 2 defines, Task 4 calls
  • _fp_check_advance_after_cert_issue — Task 3 defines, Task 7 calls
  • _fp_check_regress_after_cert_void — Task 3 defines, Task 7 calls
  • _fp_schedule_cert_activity — Task 20 defines, Task 2 calls (forward reference; defined later in the file but Python resolves at call time, not parse time — safe)
  • _fp_resolve_cert_activities — Task 20 defines, Task 3 calls (same forward-reference pattern)
  • _fp_check_finish_gates — Task 4 Step 5 defines, Task 4 Step 4 calls
  • _fp_resolve_cert_authority_users — Task 18 defines, Task 6 references via helper call from _fp_schedule_cert_activity (Task 20)
  • activity_type_issue_coc xmlid — Task 19 creates, Task 20 references
  • tpl_cert_awaiting_issuance / tpl_cert_voided_re_notify xmlids — Task 19 creates, dispatched by notification framework based on trigger_event match (no direct ref by xmlid in calling code)

The forward references in Task 2 (_fp_create_certificates, _fp_fire_notification, _fp_schedule_cert_activity) are safe because Python's method lookup happens at call time. The methods all exist by the time the first transition fires (Task 20 lands before any production traffic via the deploy in Task 23).

4. Migration safety — Task 21's post-migrate is idempotent (no in_progress row matches the all-terminal predicate after the first run). Pass 2 is mutually exclusive with Pass 1 (the cert-existence subqueries are inverses). Card_state recompute happens explicitly after the UPDATEs to keep the kanban consistent. Done jobs are explicitly left alone.

5. Deploy order — Task 23 syncs in the correct dependency order: notifications first (no new dependencies), then jobs (consumes notifications + activity type), then certificates (consumes jobs' new helpers), then shopfloor (consumes jobs' new state values), then quality (consumes certificates' x_fc_age_hours). The single -u command upgrades all five in one transaction; if any fails, Odoo rolls back to pre-deploy state.

No issues found that require rewriting tasks. Plan is ready.


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md.

Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration.

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?