diff --git a/fusion_plating/docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md new file mode 100644 index 00000000..a2057f3a --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md @@ -0,0 +1,2273 @@ +# 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?