docs(fusion_plating_shopfloor): spec - reuse saved Plating Signature on sign-off
Shop-floor sign-off currently makes operators redraw a signature every time, and the drawing is discarded (reports read x_fc_signature_image). Spec: use the saved Plating Signature (one-tap confirm-with-preview); draw once when absent and persist it to x_fc_signature_image so future sign-offs + reports reuse it. Tablet-workspace scope; no model/migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<uid>/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 (`<img t-att-src="props.signatureUrl"/>`),
|
||||
- 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).
|
||||
Reference in New Issue
Block a user