Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-06-04-shopfloor-signoff-reuse-plating-signature.md
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

21 KiB

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:

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

/** @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 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.
// 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:
            '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:

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