# 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_cert` → `awaiting_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](../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.0` → `19.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`](../../fusion_plating/models/fp_job.py) 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`](../../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: ```python # ===== 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** ```bash 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`](../../fusion_plating_jobs/tests/test_post_shop_states.py): ```python # -*- 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: ```python # ================================================================== # 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** ```bash 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`: ```python 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: ```python 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** ```bash 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** ```python 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): ```python 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: ```python 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: ```python # 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** ```bash 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`: ```python 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`: ```python 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`](../../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): ```xml ``` Add a sixth content panel below the existing five (search for `t-if="state.activeTab === 'rmas'"`): ```xml
``` (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: ```scss .fp-tab[data-key="certificates"] { --fp-tab-accent: #ff9800; // amber } ``` - [ ] **Step 5: Bust asset cache + reload** Run: ```bash 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** ```bash 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`](../../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** ```bash 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: ```python ('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** ```bash 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): ```python @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): ```python 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** ```bash 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`](../../fusion_plating_jobs/data/fp_activity_types_data.xml): ```xml Issue CoC Issue Certificate of Conformance fa-certificate 1 days current_date fp.job Job has finished the shop floor. Review the inspection prompts captured on the final step, then issue the CoC. ``` - [ ] **Step 2: Create the notification templates data file** Create [`fusion_plating_notifications/data/fp_cert_authority_templates.xml`](../../fusion_plating_notifications/data/fp_cert_authority_templates.xml): ```xml Cert Awaiting Issuance cert_awaiting_issuance 🏷️ Job ${object.display_wo_name} ready for CoC issuance

Hi,

Job () has finished the shop floor and is awaiting CoC issuance.

Part:
Quantity:
Recipe:

Review the inspection prompts captured by the operator on the Final Inspection step, then issue the CoC from the Quality Dashboard.

→ Open Quality Dashboard

]]>
Cert Voided — Please Re-Issue cert_voided_re_notify ⚠️ Job ${object.display_wo_name} CoC voided — please re-issue

Hi,

A previously-issued CoC for job () was voided. The job has slid back to Awaiting Cert and is waiting for re-issuance.

→ Open Quality Dashboard

]]>
``` (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: ```python 'data/fp_activity_types_data.xml', ``` In `fusion_plating_notifications/__manifest__.py`, add to the `'data'` list: ```python 'data/fp_cert_authority_templates.xml', ``` Also bump `fusion_plating_notifications` version (look up current and increment minor: e.g. `19.0.3.0.0` → `19.0.4.0.0`). - [ ] **Step 4: Reload + verify the records load** Run: ```bash 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: ```bash 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** ```bash 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`: ```python 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`: ```python 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** ```bash 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`](../../fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py): ```python # -*- 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`](../../fusion_plating_jobs/__manifest__.py). Find `'version': '19.0.10.31.0',` and change to: ```python '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: ```bash 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** ```bash 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`](../../fusion_plating_quality/scripts/bt_post_shop_states.py): ```python # -*- 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: ```bash 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** ```bash 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** ```bash 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: ```bash # 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: ```bash 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** ```bash 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)** ```bash 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** ```bash 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: ```sql 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** ```bash 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?