diff --git a/fusion_plating/docs/superpowers/specs/2026-06-04-shopfloor-signoff-reuse-plating-signature-design.md b/fusion_plating/docs/superpowers/specs/2026-06-04-shopfloor-signoff-reuse-plating-signature-design.md new file mode 100644 index 00000000..c6fbc96d --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-06-04-shopfloor-signoff-reuse-plating-signature-design.md @@ -0,0 +1,192 @@ +# Shop-Floor Sign-Off: Reuse the Saved Plating Signature + +**Date:** 2026-06-04 +**Module(s):** `fusion_plating_shopfloor` (frontend + controller), reads `res.users.x_fc_signature_image` (defined in `fusion_plating_jobs`) +**Author:** Gurpreet (Nexa Systems Inc.) +**Status:** Draft — pending user review of this spec + +## Summary + +On the shop-floor Job Workspace, finishing any recipe step with +`requires_signoff=True` pops a draw-pad and makes the operator **draw a +signature from scratch every time**. Worse, that per-step drawing is +saved as an `ir.attachment` on the step and then **never used** — the WO +Detail / CoC reports render the signer's **Plating Signature** +(`res.users.x_fc_signature_image`, per CLAUDE.md rule 14b), not the step +attachment. + +This change makes sign-off reuse the operator's saved **Plating +Signature**: if they have one, finishing is a one-tap confirm (preview + +"Sign & Finish"); if they don't, they draw once and it is **persisted to +their Plating Signature**, so every later sign-off — and every report — +uses it without redrawing. + +## Current behaviour (the bug) + +- `onFinishStep` ([job_workspace.js:364](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js)) — when `step.requires_signoff`, always opens `FpSignaturePad`; on submit POSTs the drawing to `/fp/workspace/sign_off`. +- `/fp/workspace/sign_off` ([workspace_controller.py:451](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)) — requires a non-empty `signature_data_uri`, creates a per-step `ir.attachment` from it, then calls `step.button_finish()` (which sets `signoff_user_id` via `_fp_autosign_if_required`). +- Reports read `signer_user.x_fc_signature_image`, **not** the step attachment → the drawing is wasted. +- `x_fc_signature_image` = `fields.Binary(string='Plating Signature', attachment=True)` on `res.users` (defined in `fusion_plating_jobs/models/res_users.py`), already in `SELF_READABLE_FIELDS` **and** `SELF_WRITEABLE_FIELDS` (fusion_plating/models/res_users.py) — so a tablet tech can read and write **their own** signature with no sudo. + +## Locked decisions (from brainstorming, 2026-06-04) + +| Q | Decision | +|---|----------| +| Finish UX when the user HAS a saved signature | **Quick confirm with preview** — small dialog showing their saved signature + "Sign & Finish", plus a "Use a different signature" link. One tap, no drawing. | +| Finish UX when the user has NO saved signature | Existing draw-pad → on submit, **persist the drawing to their Plating Signature** + finish. | +| "Use a different signature" | Opens the draw-pad; the new drawing **replaces** their saved Plating Signature (it is their signature) and signs this step. | +| Per-step signature `ir.attachment` | **Dropped** — redundant (reports never read it). Audit of *who signed when* stays on `signoff_user_id` + the finish timestamp. | +| Scope | **Tablet Job Workspace only.** The backend job-form `action_signoff` already works off `x_fc_signature_image` implicitly (no draw UI) — unchanged. | + +## Goals / non-goals + +**Goals** +- A user with a saved Plating Signature never redraws — one-tap confirm. +- A user without one draws exactly once; it persists to their Plating Signature. +- The signature shown on certs/WO reports is the same saved Plating Signature (already true; this guarantees it exists). + +**Non-goals** +- Changing the backend `action_signoff` / job-form flow. +- Per-signoff historical signature snapshots (reports already read the *live* `x_fc_signature_image`; not changing that). +- Touching the signoff gate logic (`requires_signoff`, `_fp_autosign_if_required`, `_fp_check_signoff_complete`) — unchanged. +- QC-checklist or any non-workspace signature surface (none use `FpSignaturePad`). + +## Architecture + +### 1. Workspace load payload — expose the saved signature + +In the `/fp/workspace/load` payload builder (`workspace_controller.py`), +add two keys derived from the current user (`request.env.user`, already +the per-tech session): + +```python +user = request.env.user +sig = user.x_fc_signature_image # base64 or False (SELF_READABLE) +payload['user_has_plating_signature'] = bool(sig) +payload['user_plating_signature'] = ( + ('data:image/png;base64,%s' % sig.decode()) if sig else '' +) +``` + +(`x_fc_signature_image` is a small PNG; one data URI per load is fine. If +it ever grows, switch to a `/web/image/res.users//x_fc_signature_image` +URL — deferred.) + +### 2. Frontend — confirm-vs-draw in `onFinishStep` + +`job_workspace.js`, `onFinishStep(step)` — replace the unconditional +`FpSignaturePad` branch with: + +```js +if (step.requires_signoff) { + if (this.state.data.user_has_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), // no drawing -> use saved + onRedraw: () => this._openSignaturePad(step), // draw -> replaces saved + }); + } else { + this._openSignaturePad(step); // first time -> draw + persist + } + return; +} +await this._callFinishStep(step, false); // plain finish (unchanged) +``` + +New helpers: +- `_openSignaturePad(step)` — opens the existing `FpSignaturePad`; its `onSubmit(dataUri)` calls `this._commitSignOff(step, dataUri)`. +- `_commitSignOff(step, dataUri)` — POSTs `{ step_id, signature_data_uri: dataUri /* may be null */ }` to `/fp/workspace/sign_off`, handles ok/error notifications + `refresh()` (the existing logic, factored out of the current inline `onSubmit`). + +### 3. New OWL component — `FpSignatureConfirm` + +`fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` +(+ `signature_confirm.xml`, reuse `_signature_pad.scss` tokens or add a +small `_signature_confirm.scss`). A `Dialog` showing: +- the saved signature image (``), +- the context label, +- **Sign & Finish** → `props.onConfirm(); props.close();` +- **Use a different signature** → `props.onRedraw(); props.close();` +- **Cancel** → `props.close();` + +Props: `close, title?, contextLabel?, signatureUrl, onConfirm, onRedraw`. +Mirrors `FpSignaturePad`'s shape. Register it in `JobWorkspace.components` +and the manifest assets. + +### 4. Backend — `/fp/workspace/sign_off` persists, drops the attachment + +`workspace_controller.py`, `sign_off(self, step_id, signature_data_uri=None)`: + +```python +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"). + if ',' in sig and sig.startswith('data:'): + sig = sig.split(',', 1)[1] + try: + user.write({'x_fc_signature_image': sig}) # SELF_WRITEABLE; own record + except Exception: + _logger.exception("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() # sets signoff_user_id + gates +except Exception as exc: + _logger.exception("sign_off: button_finish failed") + return {'ok': False, 'error': str(exc)} + +return {'ok': True, 'step_id': step.id, 'state': step.state} +``` + +- `signature_data_uri` is now **optional** (defaults `None`). +- No `ir.attachment` is created (the dropped per-step artifact). +- The signature persists to the user's own `x_fc_signature_image` (direct write — the field is in `SELF_WRITEABLE_FIELDS`). + +## Files touched + +| # | File | Change | +|---|------|--------| +| 1 | `fusion_plating_shopfloor/controllers/workspace_controller.py` | `sign_off`: optional `signature_data_uri`, persist to `x_fc_signature_image`, drop attachment; add `user_has_plating_signature` + `user_plating_signature` to the load payload. | +| 2 | `fusion_plating_shopfloor/static/src/js/components/signature_confirm.js` | NEW confirm dialog. | +| 3 | `fusion_plating_shopfloor/static/src/xml/components/signature_confirm.xml` | NEW template. | +| 4 | `fusion_plating_shopfloor/static/src/scss/components/_signature_confirm.scss` | NEW (small). | +| 5 | `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `onFinishStep` branch; `_openSignaturePad` + `_commitSignOff` helpers; register `FpSignatureConfirm`. | +| 6 | `fusion_plating_shopfloor/__manifest__.py` | add the 3 new asset files + version bump. | + +No model, view, ACL, or migration changes. `res.users.x_fc_signature_image` already exists with the right SELF_* access. + +## Edge cases + +| Case | Behaviour | +|------|-----------| +| Has saved sig → "Sign & Finish" | No drawing sent; `button_finish()` only; report uses saved sig. | +| No saved sig → draw | Drawing persists to `x_fc_signature_image`; future steps are one-tap. | +| Has saved sig → "Use a different signature" → draw | New drawing **replaces** saved sig + signs. | +| Empty draw | `FpSignaturePad.onSubmit` already no-ops without ink; backend also rejects empty+no-saved. | +| `button_finish` raises a gate error (required inputs, predecessor, etc.) | Returned as `{ok:false, error}` and shown as a notification — the signature has already persisted (harmless; it's their signature either way). | +| Manager/Owner with no saved sig | Same flow — draws once, persists. | + +## Testing + +`fusion_plating_shopfloor` can't install on local Community; verify on an +entech clone (`-u` + odoo-shell), like the WO-grouping deploy. + +- **Unit (controller logic, runnable where the module installs):** `sign_off` with a data URI writes `env.user.x_fc_signature_image` and finishes; `sign_off` with no URI + an existing saved sig finishes without writing; `sign_off` with no URI + no saved sig returns the "signature required" error; no `ir.attachment` is created in any path. +- **Payload:** `/fp/workspace/load` returns `user_has_plating_signature=False` + empty `user_plating_signature` for a user with no sig, and `True` + a `data:image/png;base64,…` URI once set. +- **Live smoke (entech clone):** a tech with no Plating Signature draws on a sign-off step → their `x_fc_signature_image` is populated; the next sign-off shows the confirm-preview (no pad); the WO Detail report renders the saved signature. + +## Static-check note + +`node --check` rejects ESM `import` on a `.js`; copy the OWL files to +`/tmp/x.mjs` for a syntax check, and lxml/ET-parse the `.xml` template +(per the project's static-check conventions).