diff --git a/fusion_plating/docs/superpowers/plans/2026-06-04-shopfloor-signoff-reuse-plating-signature.md b/fusion_plating/docs/superpowers/plans/2026-06-04-shopfloor-signoff-reuse-plating-signature.md new file mode 100644 index 00000000..3753d4eb --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-06-04-shopfloor-signoff-reuse-plating-signature.md @@ -0,0 +1,412 @@ +# Shop-Floor Sign-Off: Reuse Saved Plating Signature — 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:** Make shop-floor step sign-off reuse the operator's saved Plating Signature (one-tap confirm) instead of redrawing every time; capture-and-persist it the first time. + +**Architecture:** The `/fp/workspace/load` payload exposes whether the user has a Plating Signature + the image; `job_workspace.js` shows a confirm-with-preview dialog when they do (new `FpSignatureConfirm`) and the existing `FpSignaturePad` when they don't; `/fp/workspace/sign_off` persists any drawing to `res.users.x_fc_signature_image` and drops the wasted per-step attachment. + +**Tech Stack:** Odoo 19 (`fusion_plating_shopfloor`), OWL components, JSON-RPC controller, `HttpCase` tests. + +--- + +## Working location (IMPORTANT — isolated worktree) + +All work happens in the worktree **`K:\Github\Odoo-Modules-signoff-wt`** on branch **`feat/shopfloor-signoff-reuse-signature`** (off `main`). Use absolute paths under that dir for Read/Edit; for git use `git -C "K:\Github\Odoo-Modules-signoff-wt" ...` (tracked prefix `fusion_plating/`). The main checkout is in use by another session — do not touch it. + +## Testing model + +`fusion_plating_shopfloor` can't install on the local Community box — the `HttpCase` tests run on an Enterprise env (entech clone), like the WO-grouping deploy. Local per-task gate: +- Python: `python -m pyflakes ""` (host). +- XML: `python -c "import xml.etree.ElementTree as ET; ET.parse(r''); print('XML OK')"`. +- JS (ESM): `node --check` rejects `import` on a `.js`; copy to a temp `.mjs` first: `Copy-Item $env:TEMP\x.mjs; node --check $env:TEMP\x.mjs` (skip if `node` absent — the asset-bundle compile during the clone-verify `-u` is the real gate). +- SCSS: no local check; Odoo compiles it on `-u` (clone-verify catches errors). + +## File structure + +| File | Module | Responsibility | +|------|--------|----------------| +| `fusion_plating_shopfloor/controllers/workspace_controller.py` | shopfloor | `load` payload keys; `sign_off` persist + drop attachment. | +| `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | shopfloor | NEW confirm dialog component. | +| `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | shopfloor | NEW template. | +| `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | shopfloor | NEW styling. | +| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | shopfloor | confirm-vs-draw wiring. | +| `fusion_plating_shopfloor/__manifest__.py` | shopfloor | register 3 assets + version bump. | +| `fusion_plating_shopfloor/tests/test_workspace_controller.py` | shopfloor | new HttpCase tests. | + +**Build order:** backend (payload + sign_off + tests) → new component + manifest → workspace wiring → version bump + static checks → clone-verify. + +--- + +### Task 1: Backend — load payload + sign_off rewrite + tests + +**Files:** +- Modify: `fusion_plating_shopfloor/controllers/workspace_controller.py` (load return dict ~line 241; `sign_off` ~line 450-494) +- Test: `fusion_plating_shopfloor/tests/test_workspace_controller.py` + +- [ ] **Step 1: Add the load payload keys.** In `workspace_controller.py`, the `load` method's `return {` dict starts with `'ok': True,` (around line 241-242). Insert these two keys immediately after the `'ok': True,` line, at the same indentation: + +```python + 'user_has_plating_signature': bool(env.user.x_fc_signature_image), + 'user_plating_signature': ( + ('data:image/png;base64,%s' % env.user.x_fc_signature_image.decode()) + if env.user.x_fc_signature_image else '' + ), +``` + +(`env` is already bound at the top of `load`. `x_fc_signature_image` is in `SELF_READABLE_FIELDS`, so reading `env.user`'s own value is allowed.) + +- [ ] **Step 2: Rewrite `sign_off`.** Replace the entire `sign_off` method (the `@http.route('/fp/workspace/sign_off', ...)` decorator + method, lines ~450-494) with: + +```python + @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user') + def sign_off(self, step_id, signature_data_uri=None): + env = request.env + step = env['fp.job.step'].browse(int(step_id)) + if not step.exists(): + return {'ok': False, 'error': f'Step {step_id} not found'} + + sig = (signature_data_uri or '').strip() + user = env.user + if sig: + # A drawing was supplied (first-time, or "use a different + # signature"). Persist it as the user's Plating Signature so + # every future sign-off + report reuses it. x_fc_signature_image + # is in SELF_WRITEABLE_FIELDS, so writing one's own is allowed. + if ',' in sig and sig.startswith('data:'): + sig = sig.split(',', 1)[1] + try: + user.write({'x_fc_signature_image': sig}) + except Exception: + _logger.exception( + "workspace/sign_off: persisting Plating Signature failed for uid %s", + env.uid, + ) + return {'ok': False, 'error': 'Failed to save your signature.'} + elif not user.x_fc_signature_image: + # No drawing AND no saved signature — nothing to sign with. + return { + 'ok': False, + 'error': 'A signature is required. Draw one to continue.', + } + + try: + step.button_finish() + except Exception as exc: + _logger.exception("workspace/sign_off: button_finish failed") + return {'ok': False, 'error': str(exc)} + + _logger.info("Step %s signed off by uid %s", step.id, env.uid) + return {'ok': True, 'step_id': step.id, 'state': step.state} +``` + +(Note: `signature_data_uri` is now optional; the per-step `ir.attachment` create is gone.) + +- [ ] **Step 3: Write the tests.** Append to `fusion_plating_shopfloor/tests/test_workspace_controller.py` (the file already defines `_rpc`, `_TINY_PNG_B64`, and the `@tagged` decorator at the top — reuse them): + +```python +@tagged('-at_install', 'post_install', 'fp_shopfloor') +class TestWorkspaceSignOff(HttpCase): + + def setUp(self): + super().setUp() + self.authenticate("admin", "admin") + self.partner = self.env['res.partner'].create({'name': 'Sig Cust'}) + self.product = self.env['product.product'].create({'name': 'Sig Prod'}) + self.job = self.env['fp.job'].create({ + 'name': 'WH/JOB/SIG001', + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 3, + }) + + def test_load_exposes_plating_signature_flags(self): + self.env.user.x_fc_signature_image = False + res = _rpc(self, '/fp/workspace/load', job_id=self.job.id) + self.assertFalse(res['user_has_plating_signature']) + self.assertEqual(res['user_plating_signature'], '') + self.env.user.x_fc_signature_image = _TINY_PNG_B64 + res2 = _rpc(self, '/fp/workspace/load', job_id=self.job.id) + self.assertTrue(res2['user_has_plating_signature']) + self.assertTrue( + res2['user_plating_signature'].startswith('data:image/png;base64,')) + + def test_sign_off_without_signature_and_no_saved_errors(self): + self.env.user.x_fc_signature_image = False + step = self.env['fp.job.step'].create({ + 'job_id': self.job.id, 'name': 'Final', 'sequence': 10}) + res = _rpc(self, '/fp/workspace/sign_off', step_id=step.id) + self.assertFalse(res['ok']) + self.assertIn('signature', res['error'].lower()) + + def test_sign_off_with_drawing_persists_signature_and_no_attachment(self): + self.env.user.x_fc_signature_image = False + step = self.env['fp.job.step'].create({ + 'job_id': self.job.id, 'name': 'Final', 'sequence': 10}) + data_uri = 'data:image/png;base64,' + _TINY_PNG_B64 + # button_finish may fail on this un-started step; we assert the + # signature-persist + no-attachment side effects, which happen first. + _rpc(self, '/fp/workspace/sign_off', + step_id=step.id, signature_data_uri=data_uri) + self.env.user.invalidate_recordset(['x_fc_signature_image']) + self.assertTrue( + self.env.user.x_fc_signature_image, + 'drawing persisted to the Plating Signature') + n = self.env['ir.attachment'].search_count([ + ('res_model', '=', 'fp.job.step'), ('res_id', '=', step.id)]) + self.assertEqual(n, 0, 'no per-step signature attachment is created') +``` + +- [ ] **Step 4: Static check.** Run: +``` +python -m pyflakes "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\controllers\workspace_controller.py" "K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\tests\test_workspace_controller.py" +``` +Expected: clean (ignore pre-existing warnings on lines you didn't touch). + +- [ ] **Step 5: Commit.** +``` +git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py fusion_plating/fusion_plating_shopfloor/tests/test_workspace_controller.py +git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): sign_off reuses+persists Plating Signature; load exposes it" +``` + +--- + +### Task 2: New `FpSignatureConfirm` component + manifest registration + +**Files:** +- Create: `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` +- Create: `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` +- Create: `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` +- Modify: `fusion_plating_shopfloor/__manifest__.py` (assets list, after the `signature_pad.*` block ~line 81; version) + +- [ ] **Step 1: Create the JS component.** + +```js +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — SignatureConfirm +// +// Confirm dialog shown when the operator already has a saved Plating +// Signature: previews it + "Sign & Finish" (props.onConfirm) or "Use a +// different signature" (props.onRedraw, opens the draw-pad). No drawing here. +// ============================================================================= +import { Component } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; + +export class FpSignatureConfirm extends Component { + static template = "fusion_plating_shopfloor.SignatureConfirm"; + static components = { Dialog }; + static props = { + close: Function, // dialog service injects + title: { type: String, optional: true }, + contextLabel: { type: String, optional: true }, + signatureUrl: { type: String }, // data: URI of saved sig + onConfirm: { type: Function }, // () => commit (no drawing) + onRedraw: { type: Function }, // () => open draw-pad + }; + + onConfirm() { + this.props.onConfirm(); + this.props.close(); + } + onRedraw() { + this.props.onRedraw(); + this.props.close(); + } + onCancel() { + this.props.close(); + } +} +``` + +- [ ] **Step 2: Create the XML template.** + +```xml + + + + + +
+
+ +
+
+ Your saved signature +
+
Your saved Plating Signature will be applied.
+
+ + + + + +
+
+ +
+``` + +- [ ] **Step 3: Create the SCSS.** + +```scss +// Confirm-with-preview dialog for shop-floor sign-off. Explicit hex per the +// project card-styling rule (don't rely on var(--bs-border-color)). +.o_fp_sig_confirm { + .o_fp_sig_ctx { + font-size: 0.85rem; + color: #555; + margin-bottom: 8px; + } + .o_fp_sig_preview { + display: flex; + justify-content: center; + align-items: center; + min-height: 120px; + padding: 8px; + background-color: #ffffff; + border: 1px solid #d8dadd; + border-radius: 4px; + img { + max-width: 100%; + max-height: 160px; + } + } + .o_fp_sig_hint { + text-align: center; + margin-top: 6px; + font-size: 0.85rem; + color: #555; + } +} +``` + +- [ ] **Step 4: Register assets + bump version** in `__manifest__.py`. Immediately after the three `signature_pad.*` lines (the `.scss`, `.xml`, `.js` block ending ~line 81), insert: + +```python + 'fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss', + 'fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml', + 'fusion_plating_shopfloor/static/src/js/components/signature_confirm.js', +``` + +And change `'version': '19.0.37.1.0',` → `'version': '19.0.37.2.0',`. + +- [ ] **Step 5: Static checks.** +``` +python -c "import xml.etree.ElementTree as ET; ET.parse(r'K:\Github\Odoo-Modules-signoff-wt\fusion_plating\fusion_plating_shopfloor\static\src\xml\components\signature_confirm.xml'); print('XML OK')" +``` +Expected: `XML OK`. (Optional JS check: copy `signature_confirm.js` to `$env:TEMP\x.mjs` and `node --check` it if `node` is present.) + +- [ ] **Step 6: Commit.** +``` +git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_confirm.js fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss fusion_plating/fusion_plating_shopfloor/__manifest__.py +git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): FpSignatureConfirm dialog + asset registration" +``` + +--- + +### Task 3: Wire confirm-vs-draw into `job_workspace.js` + +**Files:** +- Modify: `fusion_plating_shopfloor/static/src/js/job_workspace.js` (import ~line 27; `static components` ~line 41; `onFinishStep` ~line 364-392) + +- [ ] **Step 1: Import the new component.** After the existing `import { FpSignaturePad } from "./components/signature_pad";` (line 27), add: + +```js +import { FpSignatureConfirm } from "./components/signature_confirm"; +``` + +- [ ] **Step 2: Register it in `static components`.** In the `static components = { ... };` line (~41), add `FpSignatureConfirm` to the set (e.g. right after `FpSignaturePad`): + +```js + static components = { WorkflowChip, GateViz, FpSignaturePad, FpSignatureConfirm, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog }; +``` + +- [ ] **Step 3: Replace `onFinishStep` and add two helpers.** Replace the whole `onFinishStep(step)` method (currently lines ~364-392, the `if (step.requires_signoff) { this.dialog.add(FpSignaturePad, {...}); return; } await this._callFinishStep(step, false);`) with: + +```js + async onFinishStep(step) { + if (step.requires_signoff) { + if (this.state.data.user_has_plating_signature) { + // One-tap confirm with preview of the saved Plating Signature. + this.dialog.add(FpSignatureConfirm, { + title: `Sign to finish ${step.name}`, + contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, + signatureUrl: this.state.data.user_plating_signature, + onConfirm: () => this._commitSignOff(step, null), // use saved + onRedraw: () => this._openSignaturePad(step), // draw a new one + }); + } else { + // First time — draw once; the backend persists it. + this._openSignaturePad(step); + } + return; + } + // Plain finish — routes through /fp/workspace/finish_step which + // returns structured errors so we can show the FpFinishBlockDialog. + await this._callFinishStep(step, /* bypass */ false); + } + + _openSignaturePad(step) { + this.dialog.add(FpSignaturePad, { + title: `Sign to finish ${step.name}`, + contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, + onSubmit: (dataUri) => this._commitSignOff(step, dataUri), + }); + } + + async _commitSignOff(step, dataUri) { + try { + const res = await fpRpc("/fp/workspace/sign_off", { + step_id: step.id, + signature_data_uri: dataUri, // null -> backend uses the saved signature + }); + if (res && res.ok) { + this.notification.add("Step signed off and finished.", { type: "success" }); + await this.refresh(); + } else { + this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" }); + } + } catch (err) { + this.notification.add(err.message, { type: "danger" }); + } + } +``` + +(`fpRpc`, `this.dialog`, `this.notification`, `this.refresh`, `this._callFinishStep` all already exist in this component — verify the imports/usages are unchanged.) + +- [ ] **Step 4: Static check (optional JS).** Copy `job_workspace.js` to `$env:TEMP\x.mjs` and `node --check $env:TEMP\x.mjs` if `node` is present; otherwise rely on the clone-verify asset compile. + +- [ ] **Step 5: Commit.** +``` +git -C "K:\Github\Odoo-Modules-signoff-wt" add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js +git -C "K:\Github\Odoo-Modules-signoff-wt" commit -m "feat(fusion_plating_shopfloor): workspace sign-off confirms saved signature, draws only when absent" +``` + +--- + +### Task 4: Verify on an entech clone + +**Files:** none (verification only). Mirror the WO-grouping clone-verify recipe. + +- [ ] **Step 1: Clone + upgrade + tests.** On entech: clone `admin` → throwaway UTF-8 DB (`createdb -O odoo -E UTF8 -T template0 --lc-collate=C --lc-ctype=C`, then `pg_dump admin | psql`), stage this branch's `fusion_plating_shopfloor` files into `/mnt/extra-addons/custom/fusion_plating_shopfloor`, then: +``` +odoo -c /etc/odoo/odoo.conf -d -u fusion_plating_shopfloor --test-enable \ + --test-tags /fusion_plating_shopfloor:TestWorkspaceSignOff --stop-after-init \ + --workers=0 --http-port=0 --gevent-port=0 --log-level=test +``` +Expected: exit 0; the 3 new tests pass. (Run the full `/fusion_plating_shopfloor` suite + a baseline diff if any failures appear, to confirm they're pre-existing — same technique as the WO-grouping deploy.) + +- [ ] **Step 2: Asset compile sanity.** Confirm the `-u` compiled the backend bundle without SCSS/XML errors (no `CRITICAL`/`Failed to load` for `signature_confirm`). + +- [ ] **Step 3: Browser smoke (clone or post-deploy).** As a tech with **no** Plating Signature: finish a `requires_signoff` step → draw-pad appears → draw → their `x_fc_signature_image` is set (query DB). Finish another sign-off step → the **confirm-with-preview** dialog appears (no pad) → Sign & Finish works. Render that job's WO Detail → the saved signature shows. + +- [ ] **Step 4: Mark complete.** Suite green + smoke confirmed → ready to deploy `fusion_plating_shopfloor` to entech (standard recipe: backup, stage, `-u`, cache-bust, restart, gated on exit 0). + +--- + +## Self-review (by plan author) + +- **Spec coverage:** load payload keys (Task 1) ✓; sign_off optional URI + persist + drop attachment (Task 1) ✓; `FpSignatureConfirm` (Task 2) ✓; workspace confirm-vs-draw + "use a different signature" replaces saved (Task 3) ✓; manifest assets + version (Task 2) ✓; tablet-only scope, no model/migration ✓. +- **Placeholder scan:** no TBD/TODO; every code step has complete code; `` in Task 4 is an explicit env parameter. +- **Type/name consistency:** `signature_data_uri` (optional, default None) consistent across controller + JS; payload keys `user_has_plating_signature` / `user_plating_signature` consistent between controller (Task 1), workspace `this.state.data.*` (Task 3); `FpSignatureConfirm` props (`signatureUrl`, `onConfirm`, `onRedraw`) consistent between the component (Task 2) and its caller (Task 3); `_commitSignOff` / `_openSignaturePad` defined and used in Task 3.