fix(shopfloor,reports): make QR scan actually navigate after decode

Two bugs were colluding to make iPhone scans look like "nothing
happens":

1. The in-app scanner was calling action.doAction({res_model: 'fp.job',
   res_id: <decoded-id>}). Old physical stickers (still on every box)
   encode /fp/wo/<mrp.production.id> — that id space doesn't match
   fp.job, so the form opened on a non-existent record and silently
   showed nothing. New /fp/job/<id> stickers happened to work because
   the IDs lined up by coincidence.

2. The /fp/wo/<id> controller redirected to mrp.production / mrp.workorder
   forms, both of which still exist as legacy records but aren't the
   canonical source of truth post-migration.

Fix:
- qr_scanner._handleCode now navigates via window.location.href instead
  of action.doAction. It hands /fp/job/<n> and /fp/wo/<n> URLs straight
  to the existing server-side controllers, which know how to resolve
  the right record. Bare numeric ids pasted manually -> /fp/job/<n>.
  Anything else surfaces the decoded text as an error so the operator
  can see decode worked but the value isn't a sticker.

- Modal now shows "Detected: <value>" the moment a code is decoded
  (before navigation), so even on slow phones the operator sees
  immediate feedback that the camera read the QR.

- wo_scan.py now resolves in this order:
    1. fp.job by legacy_mrp_production_id (migration-aware — old
       stickers route to the new model)
    2. mrp.production direct browse
    3. mrp.workorder direct browse
    4. fall back to /odoo/plating-jobs (or work-orders list)

Versions: shopfloor 19.0.17.0.0 -> 19.0.18.0.0,
          reports   19.0.7.15.0 -> 19.0.7.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 13:14:06 -04:00
parent ecac43eef4
commit b93633d728
6 changed files with 99 additions and 31 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.7.15.0',
'version': '19.0.7.16.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -22,25 +22,42 @@ class FpWoScanController(http.Controller):
def wo_scan_redirect(self, wo_id, **kwargs):
"""Redirect a scanned sticker to the right backend form.
Stickers are printed from two sources — mrp.workorder (WO) and
mrp.production (MO) — and both embed their own numeric id in
the QR. Try the MO table first (operators live on the MO
form — customer, SO, all WOs visible) and fall back to WO.
Resolution order:
1. fp.job mapped from this MO id via legacy_mrp_production_id
(post-migration: physical stickers still encode the old MO
id, but the canonical record is now an fp.job)
2. mrp.production with this id (pre-migration callers, or if
the legacy mapping wasn't run)
3. mrp.workorder with this id (older stickers that encoded
the WO id rather than the MO id)
4. fall back to the jobs list so staff can search manually.
"""
MO = request.env['mrp.production'].sudo()
WO = request.env['mrp.workorder'].sudo()
env = request.env
mo = MO.browse(wo_id).exists()
# 1) New native model — preferred when migration has run.
if 'fp.job' in env and 'legacy_mrp_production_id' in env['fp.job']._fields:
job = env['fp.job'].sudo().search(
[('legacy_mrp_production_id', '=', wo_id)], limit=1)
if job:
return request.redirect(
'/odoo/action-fusion_plating.action_fp_job/%d' % job.id
)
# 2) Legacy MO form (pre-migration or non-migrated records).
mo = env['mrp.production'].sudo().browse(wo_id).exists()
if mo:
return request.redirect(
'/odoo/action-mrp.mrp_production_action/%d' % mo.id
)
wo = WO.browse(wo_id).exists()
# 3) Legacy WO form.
wo = env['mrp.workorder'].sudo().browse(wo_id).exists()
if wo:
return request.redirect(
'/odoo/action-mrp.action_mrp_workorder/%d' % wo.id
)
# Neither resolved — land on the WO list so staff can search manually.
# 4) Fall back: native jobs list if it exists, otherwise WO list.
if 'fp.job' in env:
return request.redirect('/odoo/plating-jobs')
return request.redirect('/odoo/manufacturing/work-orders')

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.17.0.0',
'version': '19.0.18.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -42,7 +42,6 @@ export class QrScanner extends Component {
};
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.videoRef = useRef("video");
const hasNative = typeof BarcodeDetector !== "undefined";
@@ -51,6 +50,7 @@ export class QrScanner extends Component {
open: false,
error: null,
manualUrl: "",
detected: "", // last decoded value (for user feedback)
// True whenever ANY decoder (native or jsQR) is available.
// Drives the template: when true we show the camera <video>;
// when false we fall through to the manual paste UI only.
@@ -213,31 +213,65 @@ export class QrScanner extends Component {
}
/**
* Parse a decoded value (URL or raw id-bearing string) and route to
* the matching fp.job form. Accepts /fp/job/<id> and /fp/wo/<id>
* (legacy alias from older mrp.workorder stickers — both now point
* at fp.job).
* Route a decoded value to the right backend page.
*
* Stickers encode either /fp/job/<fp.job.id> (new) or
* /fp/wo/<mrp.production.id|mrp.workorder.id> (legacy — still on
* physical boxes from before the migration). Both URLs are
* handled by server-side controllers (job_scan.py / wo_scan.py)
* that resolve the correct record and redirect to its form.
*
* Rather than guessing ID spaces in the browser, we just navigate
* to the URL and let the controllers do the routing. This means:
* - new stickers (/fp/job/<id>) -> fp.job form
* - old stickers (/fp/wo/<id>) -> fp.job (via legacy_mrp_production_id)
* or mrp.production fallback
* - plain ids pasted manually -> assumed to be fp.job
* - anything else -> show the decoded text as an
* error so the operator knows
* decode worked but the value
* isn't a sticker URL.
*/
_handleCode(rawValue) {
const m = (rawValue || "").match(/\/fp\/(?:job|wo)\/(\d+)/);
if (!m) {
const value = (rawValue || "").trim();
this.state.detected = value.slice(0, 120);
// Path or full URL containing /fp/job/<n> or /fp/wo/<n>.
const pathMatch = value.match(/\/fp\/(?:job|wo)\/\d+/);
let target = null;
if (pathMatch) {
// If the decoded value is a full URL, keep its origin so we
// don't break links that point at a different host.
// Otherwise navigate to the path on the current origin.
try {
const u = new URL(value);
target = u.origin + pathMatch[0];
} catch (e) {
target = pathMatch[0];
}
} else if (/^\d+$/.test(value)) {
// Bare numeric id pasted manually -> treat as fp.job id.
target = "/fp/job/" + value;
} else if (/^https?:\/\//i.test(value)) {
// Some other URL on (presumably) this Odoo. Let the user
// see what was decoded; don't blindly navigate to arbitrary
// off-host URLs.
this.state.error =
"QR doesn't look like a job sticker. Got: " +
(rawValue || "").slice(0, 80);
"Decoded URL doesn't look like a sticker: " + value.slice(0, 80);
return;
} else {
this.state.error =
"QR doesn't look like a job sticker. Got: " + value.slice(0, 80);
this.state.manualUrl = "";
return;
}
const jobId = parseInt(m[1], 10);
this._stopCamera();
this.state.open = false;
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job",
res_id: jobId,
view_mode: "form",
views: [[false, "form"]],
target: "current",
});
this.notification.add("Opened job " + jobId, { type: "success" });
this.notification.add("Opening " + target, { type: "success" });
// Full navigation — the server-side controller resolves the id
// to the right record (works for both new fp.job stickers and
// legacy mrp.production / mrp.workorder stickers).
window.location.href = target;
}
}

View File

@@ -65,18 +65,30 @@
}
.o_fp_qr_error,
.o_fp_qr_warn {
.o_fp_qr_warn,
.o_fp_qr_detected {
padding: $fp-space-2 $fp-space-3;
border-radius: $fp-radius-sm;
background: $fp-card-soft;
color: $fp-ink-soft;
font-size: $fp-text-sm;
word-break: break-all;
}
.o_fp_qr_error {
border-left: 3px solid $fp-bad;
}
.o_fp_qr_detected {
border-left: 3px solid $fp-ok;
color: $fp-ink;
.o_fp_qr_detected_val {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 600;
}
}
.o_fp_qr_manual {
border-top: 1px solid $fp-border;
padding-top: $fp-space-3;

View File

@@ -32,6 +32,11 @@
</div>
<video t-if="state.canScan" t-ref="video"
class="o_fp_qr_video" muted="true" playsinline="true"/>
<div t-if="state.detected" class="o_fp_qr_detected">
<i class="fa fa-check-circle me-1"/>
<span>Detected: </span>
<span class="o_fp_qr_detected_val" t-esc="state.detected"/>
</div>
<div t-if="state.error" class="o_fp_qr_error">
<i class="fa fa-exclamation-triangle me-1"/>
<span t-esc="state.error"/>