Compare commits

...

6 Commits

Author SHA1 Message Date
gsinghpal
3fe5d5c17c test(fusion_plating_shopfloor): sign-off tests use the authenticated admin + a recipe link
Clone-verify fixes: the HTTP request runs as base.user_admin, so set/read
x_fc_signature_image on that user (not self.env.user / uid 1); give the step a
recipe_node_id so button_finish passes the S21 no-recipe-link gate (also fixes
the pre-existing test_sign_off_finishes_step). 5/5 pass on an entech clone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:37:01 -04:00
gsinghpal
190b394001 feat(fusion_plating_shopfloor): workspace sign-off confirms saved signature, draws only when absent
onFinishStep: if the user has a saved Plating Signature, show FpSignatureConfirm
(one-tap, preview); otherwise open the draw-pad. Factored _openSignaturePad +
_commitSignOff (sends null data URI when using the saved signature).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:22:42 -04:00
gsinghpal
b5a300f439 feat(fusion_plating_shopfloor): FpSignatureConfirm dialog + asset registration
Confirm-with-preview dialog (saved-signature preview + Sign & Finish + Use a
different signature). Registered after the signature_pad assets; version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:21:36 -04:00
gsinghpal
25ef7832f5 feat(fusion_plating_shopfloor): sign_off reuses+persists Plating Signature; load exposes it
/fp/workspace/sign_off: signature_data_uri now optional; a supplied drawing
persists to res.users.x_fc_signature_image (SELF_WRITEABLE) and the wasted
per-step ir.attachment is dropped; no drawing + a saved signature just finishes.
/fp/workspace/load exposes user_has_plating_signature + user_plating_signature.
Merged 3 new tests into the existing TestWorkspaceSignOff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:20:35 -04:00
gsinghpal
600e11fabb docs(fusion_plating_shopfloor): implementation plan - reuse saved Plating Signature
4 tasks: backend (load payload + sign_off persist/drop-attachment + HttpCase
tests) -> FpSignatureConfirm component + manifest -> job_workspace confirm-vs-draw
wiring -> entech clone-verify. Isolated worktree off main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:14:14 -04:00
gsinghpal
5e3e6b5319 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>
2026-06-04 00:06:10 -04:00
9 changed files with 821 additions and 53 deletions

View File

@@ -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 "<file>"` (host).
- XML: `python -c "import xml.etree.ElementTree as ET; ET.parse(r'<file>'); print('XML OK')"`.
- JS (ESM): `node --check` rejects `import` on a `.js`; copy to a temp `.mjs` first: `Copy-Item <file> $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
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.SignatureConfirm">
<Dialog title="props.title or 'Confirm signature'" size="'md'">
<div class="o_fp_sig_confirm">
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
<t t-esc="props.contextLabel"/>
</div>
<div class="o_fp_sig_preview">
<img t-att-src="props.signatureUrl" alt="Your saved signature"/>
</div>
<div class="o_fp_sig_hint">Your saved Plating Signature will be applied.</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-link" t-on-click="onRedraw">Use a different signature</button>
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
<button class="btn btn-primary" t-on-click="onConfirm">Sign &amp; Finish</button>
</t>
</Dialog>
</t>
</templates>
```
- [ ] **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 <clone> -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; `<clone>` 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.

View File

@@ -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).

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.37.1.0', 'version': '19.0.37.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """ 'description': """
@@ -79,6 +79,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss', 'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml', 'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js', 'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
# Confirm-with-preview dialog (reuse saved Plating Signature on sign-off)
'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',
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss', 'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml', 'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js', 'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',

View File

@@ -240,6 +240,11 @@ class FpWorkspaceController(http.Controller):
return { return {
'ok': True, 'ok': True,
'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 ''
),
'job': { 'job': {
'id': job.id, 'id': job.id,
'name': job.name, 'name': job.name,
@@ -448,37 +453,35 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/sign_off — capture signature + finish step atomically # /fp/workspace/sign_off — capture signature + finish step atomically
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user') @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri): def sign_off(self, step_id, signature_data_uri=None):
env = request.env env = request.env
sig = (signature_data_uri or '').strip()
if not sig:
_logger.warning("workspace/sign_off: empty signature for step %s", step_id)
return {
'ok': False,
'error': 'A signature is required to finish this step.',
}
step = env['fp.job.step'].browse(int(step_id)) step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': f'Step {step_id} not found'} return {'ok': False, 'error': f'Step {step_id} not found'}
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it) sig = (signature_data_uri or '').strip()
if ',' in sig and sig.startswith('data:'): user = env.user
sig = sig.split(',', 1)[1] if sig:
# A drawing was supplied (first-time, or "use a different
try: # signature"). Persist it as the user's Plating Signature so
env['ir.attachment'].create({ # every future sign-off + report reuses it. x_fc_signature_image
'name': f'signature_{step.id}.png', # is in SELF_WRITEABLE_FIELDS, so writing one's own is allowed.
'datas': sig, if ',' in sig and sig.startswith('data:'):
'res_model': 'fp.job.step', sig = sig.split(',', 1)[1]
'res_id': step.id, try:
'mimetype': 'image/png', user.write({'x_fc_signature_image': sig})
}) except Exception:
except Exception: _logger.exception(
_logger.exception( "workspace/sign_off: persisting Plating Signature failed for uid %s",
"workspace/sign_off: attachment failed for step %s", step.id, env.uid,
) )
return {'ok': False, 'error': 'Failed to save signature.'} 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: try:
step.button_finish() step.button_finish()
@@ -487,11 +490,7 @@ class FpWorkspaceController(http.Controller):
return {'ok': False, 'error': str(exc)} return {'ok': False, 'error': str(exc)}
_logger.info("Step %s signed off by uid %s", step.id, env.uid) _logger.info("Step %s signed off by uid %s", step.id, env.uid)
return { return {'ok': True, 'step_id': step.id, 'state': step.state}
'ok': True,
'step_id': step.id,
'state': step.state,
}
# ====================================================================== # ======================================================================
# /fp/workspace/advance_milestone — fire next_milestone_action # /fp/workspace/advance_milestone — fire next_milestone_action

View File

@@ -0,0 +1,35 @@
/** @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();
}
}

View File

@@ -25,6 +25,7 @@ import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip"; import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz"; import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad"; import { FpSignaturePad } from "./components/signature_pad";
import { FpSignatureConfirm } from "./components/signature_confirm";
import { FpHoldComposer } from "./components/hold_composer"; import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock"; import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog"; import { FpRackPartsDialog } from "./rack_parts_dialog";
@@ -38,7 +39,7 @@ import { FileModel } from "@web/core/file_viewer/file_model";
export class FpJobWorkspace extends Component { export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace"; static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"]; static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog }; static components = { WorkflowChip, GateViz, FpSignaturePad, FpSignatureConfirm, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
@@ -363,26 +364,20 @@ export class FpJobWorkspace extends Component {
async onFinishStep(step) { async onFinishStep(step) {
if (step.requires_signoff) { if (step.requires_signoff) {
this.dialog.add(FpSignaturePad, { if (this.state.data.user_has_plating_signature) {
title: `Sign to finish ${step.name}`, // One-tap confirm with a preview of the saved Plating Signature.
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, this.dialog.add(FpSignatureConfirm, {
onSubmit: async (dataUri) => { title: `Sign to finish ${step.name}`,
try { contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
const res = await fpRpc("/fp/workspace/sign_off", { signatureUrl: this.state.data.user_plating_signature,
step_id: step.id, onConfirm: () => this._commitSignOff(step, null), // use saved sig
signature_data_uri: dataUri, onRedraw: () => this._openSignaturePad(step), // draw a new one
}); });
if (res && res.ok) { } else {
this.notification.add("Step signed off and finished.", { type: "success" }); // First time — draw once; the backend persists it to the
await this.refresh(); // user's Plating Signature so later sign-offs are one-tap.
} else { this._openSignaturePad(step);
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" }); }
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
},
});
return; return;
} }
// Plain finish — route through /fp/workspace/finish_step which // Plain finish — route through /fp/workspace/finish_step which
@@ -391,6 +386,31 @@ export class FpJobWorkspace extends Component {
await this._callFinishStep(step, /* bypass */ false); 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" });
}
}
async _callFinishStep(step, bypassRequiredInputs) { async _callFinishStep(step, bypassRequiredInputs) {
try { try {
const res = await rpc("/fp/workspace/finish_step", { const res = await rpc("/fp/workspace/finish_step", {

View File

@@ -0,0 +1,29 @@
// 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;
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.SignatureConfirm">
<Dialog title="props.title or 'Confirm signature'" size="'md'">
<div class="o_fp_sig_confirm">
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
<t t-esc="props.contextLabel"/>
</div>
<div class="o_fp_sig_preview">
<img t-att-src="props.signatureUrl" alt="Your saved signature"/>
</div>
<div class="o_fp_sig_hint">Your saved Plating Signature will be applied.</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-link" t-on-click="onRedraw">Use a different signature</button>
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
<button class="btn btn-primary" t-on-click="onConfirm">Sign &amp; Finish</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -110,6 +110,10 @@ class TestWorkspaceSignOff(HttpCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.authenticate("admin", "admin") self.authenticate("admin", "admin")
# The HTTP request runs as the authenticated "admin" (base.user_admin);
# the controller reads/writes THAT user's x_fc_signature_image, so the
# test must set/read it on the same user (NOT self.env.user / uid 1).
self.admin = self.env.ref('base.user_admin')
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'}) self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
self.product = self.env['product.product'].create({'name': 'Sig Prod'}) self.product = self.env['product.product'].create({'name': 'Sig Prod'})
self.job = self.env['fp.job'].create({ self.job = self.env['fp.job'].create({
@@ -118,14 +122,24 @@ class TestWorkspaceSignOff(HttpCase):
'product_id': self.product.id, 'product_id': self.product.id,
'qty': 1, 'qty': 1,
}) })
# button_finish requires a recipe link (S21 gate). A minimal step node
# (no inputs, no sign-off) makes the gates pass so the step can finish.
kind = self.env['fp.step.kind'].search([], limit=1)
node_vals = {'name': 'ENP Plate', 'node_type': 'step'}
if kind:
node_vals['kind_id'] = kind.id
self.node = self.env['fusion.plating.process.node'].create(node_vals)
self.step = self.env['fp.job.step'].create({ self.step = self.env['fp.job.step'].create({
'job_id': self.job.id, 'job_id': self.job.id,
'name': 'ENP Plate', 'name': 'ENP Plate',
'sequence': 50, 'sequence': 50,
'state': 'in_progress', 'state': 'in_progress',
'recipe_node_id': self.node.id,
}) })
def test_sign_off_rejects_empty_signature(self): def test_sign_off_rejects_empty_signature(self):
# Empty drawing AND no saved Plating Signature -> reject.
self.admin.x_fc_signature_image = False
res = _rpc( res = _rpc(
self, '/fp/workspace/sign_off', self, '/fp/workspace/sign_off',
step_id=self.step.id, signature_data_uri='', step_id=self.step.id, signature_data_uri='',
@@ -142,6 +156,46 @@ class TestWorkspaceSignOff(HttpCase):
self.step.invalidate_recordset(['state']) self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done') self.assertEqual(self.step.state, 'done')
def test_load_exposes_plating_signature_flags(self):
self.admin.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.admin.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_with_drawing_persists_signature_and_drops_attachment(self):
# First-time draw: persists to the admin's Plating Signature, finishes
# the (in_progress) step, and creates NO per-step signature attachment.
self.admin.x_fc_signature_image = False
data_uri = 'data:image/png;base64,' + _TINY_PNG_B64
res = _rpc(
self, '/fp/workspace/sign_off',
step_id=self.step.id, signature_data_uri=data_uri,
)
self.assertTrue(res['ok'])
self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done')
self.admin.invalidate_recordset(['x_fc_signature_image'])
self.assertTrue(
self.admin.x_fc_signature_image,
'drawing persisted to the Plating Signature')
n = self.env['ir.attachment'].search_count([
('res_model', '=', 'fp.job.step'), ('res_id', '=', self.step.id)])
self.assertEqual(n, 0, 'no per-step signature attachment is created')
def test_sign_off_uses_saved_signature_without_drawing(self):
# Admin already has a saved signature -> finishing without a drawing
# still works (no signature_data_uri sent).
self.admin.x_fc_signature_image = _TINY_PNG_B64
res = _rpc(self, '/fp/workspace/sign_off', step_id=self.step.id)
self.assertTrue(res['ok'])
self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done')
@tagged('-at_install', 'post_install', 'fp_shopfloor') @tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceAdvanceMilestone(HttpCase): class TestWorkspaceAdvanceMilestone(HttpCase):