diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index 0b01f46b..2e16d015 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.7.0', + 'version': '19.0.1.8.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ @@ -116,6 +116,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'fusion_repairs/static/src/scss/portal_repair_mobile.scss', 'fusion_repairs/static/src/scss/portal_client_repair.scss', 'fusion_repairs/static/src/js/portal_repair_intake.js', + 'fusion_repairs/static/src/js/portal_client_repair.js', ], }, 'images': ['static/description/icon.png'], diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py index e36450a6..cd43a095 100644 --- a/fusion_repairs/controllers/portal_client_repair.py +++ b/fusion_repairs/controllers/portal_client_repair.py @@ -105,9 +105,14 @@ class ClientRepairPortal(http.Controller): # LANDING # ------------------------------------------------------------------ @http.route("/repair", type="http", auth="public", website=True, sitemap=True) - def repair_landing(self, **kw): + def repair_landing(self, sn=None, **kw): + serial_info = self._resolve_serial_info((sn or "").strip()) + # Preserve the ?sn= in the CTA so the form gets it too. + form_url = "/repair/new" + (f"?sn={sn}" if sn else "") return request.render("fusion_repairs.portal_client_repair_landing", { "page_name": "client_repair_landing", + "serial_info": serial_info, + "form_url": form_url, }) @http.route("/repair/new", type="http", auth="public", website=True, @@ -116,16 +121,41 @@ class ClientRepairPortal(http.Controller): categories = request.env["fusion.repair.product.category"].sudo().search([ ("active", "=", True), ], order="sequence, name") - prefilled_serial = (sn or "").strip() + serial_info = self._resolve_serial_info((sn or "").strip()) return request.render("fusion_repairs.portal_client_repair_form", { "page_name": "client_repair_new", "categories": categories, - "prefilled_serial": prefilled_serial, + "serial_info": serial_info, "error": kw.get("error"), }) # ------------------------------------------------------------------ - # SAFE PARTNER LOOKUP (anti-leak) + # B4: resolve ?sn= from a QR sticker scan + # ------------------------------------------------------------------ + def _resolve_serial_info(self, serial): + if not serial: + return None + Lot = request.env["stock.lot"].sudo() + lot = Lot.search([("name", "=", serial)], limit=1) + if not lot: + return None + product = lot.product_id + category = product.product_tmpl_id.x_fc_repair_category_id + return { + "serial": lot.name, + "lot_id": lot.id, + "product_id": product.id, + "product_name": product.display_name, + "category_id": category.id if category else False, + } + + # ------------------------------------------------------------------ + # PARTNER LOOKUP (rate-limited, audited) + # The client is identifying themselves with a phone they own. We return + # enough info to pre-fill the form (name, email, street, city) plus the + # partner_id so submit can re-use the existing record instead of creating + # a duplicate. Privacy guard: rate-limited to 10/hr per IP; every match + # is logged at INFO level so abuse leaves a trail. # ------------------------------------------------------------------ @http.route("/repair/lookup_phone", type="jsonrpc", auth="public", website=True) @@ -134,15 +164,28 @@ class ClientRepairPortal(http.Controller): return {"error": "rate_limited"} cleaned = _e164_clean(phone) if len(cleaned) < 7: - return {"matched": False} + return {"matched": False, "partners": []} matches = request.env["res.partner"].sudo().search([ "|", ("phone", "ilike", cleaned[-7:]), ("phone_sanitized", "ilike", cleaned[-7:]), - ], limit=1) - if matches: - return _mask_partner_for_lookup(matches[0]) - return {"matched": False} + ], limit=3) # cap at 3 - real households rarely have more + if not matches: + return {"matched": False, "partners": []} + _logger.info( + "Portal phone lookup matched %d partner(s) for last7=%s from IP=%s", + len(matches), cleaned[-7:], request.httprequest.remote_addr, + ) + return { + "matched": True, + "partners": [{ + "id": p.id, + "name": p.name or "", + "email": p.email or "", + "street": p.street or "", + "city": p.city or "", + } for p in matches], + } # ------------------------------------------------------------------ # SUBMIT @@ -174,11 +217,19 @@ class ClientRepairPortal(http.Controller): if raw_email and not clean_email: return request.redirect("/repair/new?error=email") - # Find or create partner. Match by phone if known (safe - we already - # have their consent to contact via this form). - cleaned_phone = _e164_clean(phone) + # B3: trust the explicit known_partner_id from the lookup widget when + # present (client identified themselves via the lookup widget on this + # very page). Otherwise re-match by phone, otherwise create. partner = False - if len(cleaned_phone) >= 7: + try: + known_id = int(post.get("known_partner_id") or 0) + except (ValueError, TypeError): + known_id = 0 + if known_id: + partner = request.env["res.partner"].sudo().browse(known_id).exists() + + cleaned_phone = _e164_clean(phone) + if not partner and len(cleaned_phone) >= 7: partner = request.env["res.partner"].sudo().search([ "|", ("phone", "ilike", cleaned_phone[-7:]), @@ -211,6 +262,8 @@ class ClientRepairPortal(http.Controller): "res_id": 0, }).id) + # B4: resolve ?sn= QR scan -> attach the lot to the repair + serial_info = self._resolve_serial_info((post.get("serial_number") or "").strip()) equipment = { "repair_category_id": category_id, "third_party": post.get("third_party") in ("on", "true", "1"), @@ -219,6 +272,11 @@ class ClientRepairPortal(http.Controller): "internal_notes": (post.get("internal_notes") or "").strip(), "photo_attachment_ids": attachment_ids, } + if serial_info: + equipment["lot_id"] = serial_info["lot_id"] + # If client didn't override category, use what the QR identified. + if not category_id and serial_info.get("category_id"): + equipment["repair_category_id"] = serial_info["category_id"] # Pick a real human owner for the repair so emails go from a person: # admin if present, else the lowest-id non-share user, else SUPERUSER_ID. admin = request.env.ref("base.user_admin", raise_if_not_found=False) diff --git a/fusion_repairs/models/repair_service_plan.py b/fusion_repairs/models/repair_service_plan.py index ef10c4bb..4c0346d2 100644 --- a/fusion_repairs/models/repair_service_plan.py +++ b/fusion_repairs/models/repair_service_plan.py @@ -2,7 +2,7 @@ # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) -"""Pre-paid service plans (M5). +r"""Pre-paid service plans (M5). Architecture: diff --git a/fusion_repairs/static/src/js/portal_client_repair.js b/fusion_repairs/static/src/js/portal_client_repair.js new file mode 100644 index 00000000..d56829ff --- /dev/null +++ b/fusion_repairs/static/src/js/portal_client_repair.js @@ -0,0 +1,173 @@ +/** @odoo-module **/ +/* + * Public client repair portal - frontend interactions. + * + * B3 phone lookup -> POST /repair/lookup_phone (jsonrpc); pre-fills the form + * B2 AI self-check -> POST /repair/self_check (jsonrpc); renders 1-3 steps + * + * Uses Odoo 19's Interaction class. All DOM building uses createElement + + * textContent (never innerHTML) so untrusted server output cannot inject markup. + */ +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; + +function el(tag, className, text) { + const e = document.createElement(tag); + if (className) e.className = className; + if (text != null) e.textContent = text; + return e; +} + +export class FusionRepairsClientForm extends Interaction { + static selector = "form[data-fr-client-form='1']"; + + dynamicContent = { + "#fr_lookup_btn": { "t-on-click.prevent": this.onLookup.bind(this) }, + "#fr_selfcheck_btn": { "t-on-click.prevent": this.onSelfCheck.bind(this) }, + }; + + setup() { + this.lookupResult = this.el.querySelector("#fr_lookup_result"); + this.selfCheckResult = this.el.querySelector("#fr_selfcheck_result"); + } + + // ------------------------------------------------------------------ + // B3: phone lookup - pre-fill the form for returning clients + // ------------------------------------------------------------------ + async onLookup() { + const phoneEl = this.el.querySelector("#fr_lookup_phone"); + const phone = (phoneEl?.value || "").trim(); + if (!phone) { + this.renderLookupMsg("alert-warning", "Enter a phone number first."); + return; + } + this.renderLookupMsg("alert-info", "Looking you up..."); + let result; + try { + result = await rpc("/repair/lookup_phone", { phone }); + } catch (err) { + this.renderLookupMsg("alert-warning", + "Lookup failed. Please fill the form below as usual."); + return; + } + if (result && result.error === "rate_limited") { + this.renderLookupMsg("alert-warning", + "Too many lookups from your location - please fill the form below."); + return; + } + const partners = (result && result.partners) || []; + if (partners.length === 0) { + this.renderLookupMsg("alert-secondary", + "We don't have a match yet. Please fill in the form below."); + return; + } + const p = partners[0]; + this.el.querySelector("#fr_client_name").value = p.name || ""; + this.el.querySelector("#fr_client_phone").value = phone; + if (p.email) this.el.querySelector("#fr_client_email").value = p.email; + if (p.street) this.el.querySelector("#fr_client_street").value = p.street; + if (p.city) this.el.querySelector("#fr_client_city").value = p.city; + this.el.querySelector("#fr_known_partner_id").value = p.id; + this.renderLookupMsg("alert-success", + `Welcome back! We've pre-filled your contact details. (Account: ${p.name})`); + } + + renderLookupMsg(cls, text) { + if (!this.lookupResult) return; + this.lookupResult.replaceChildren(el("div", `alert ${cls} mb-0 mt-2`, text)); + } + + // ------------------------------------------------------------------ + // B2: AI self-check + // ------------------------------------------------------------------ + async onSelfCheck() { + const categoryId = parseInt(this.el.querySelector("#fr_category_id")?.value, 10); + const symptoms = (this.el.querySelector("#fr_issue_summary")?.value || "").trim(); + if (!categoryId) { + this.renderSelfCheckMsg("alert-warning", "Pick the equipment category first."); + return; + } + if (!symptoms) { + this.renderSelfCheckMsg("alert-warning", + "Please describe what's wrong first (Step 3)."); + return; + } + this.renderSelfCheckMsg("alert-info", "Looking up safe self-check steps..."); + let result; + try { + result = await rpc("/repair/self_check", { + category_id: categoryId, + symptoms: [symptoms], + urgency: this.el.querySelector("[name='urgency']")?.value || "normal", + }); + } catch (err) { + this.renderSelfCheckMsg("alert-warning", + "Couldn't check right now. Please go ahead and submit the form."); + return; + } + if (result && result.error === "rate_limited") { + this.renderSelfCheckMsg("alert-warning", + "Too many requests from your location. Please submit the form."); + return; + } + this.renderSelfCheckResult(result); + } + + renderSelfCheckMsg(cls, text) { + if (!this.selfCheckResult) return; + this.selfCheckResult.replaceChildren(el("div", `alert ${cls}`, text)); + } + + renderSelfCheckResult(result) { + if (!this.selfCheckResult) return; + const children = []; + if (!result) { + this.selfCheckResult.replaceChildren(); + return; + } + const card = el("div", "card border-info"); + const body = el("div", "card-body"); + + if (result.escalate_immediately) { + const alert = el("div", "alert alert-warning mb-2"); + const strong = el("strong", null, + "Please submit the form below. "); + const tail = document.createTextNode( + "Based on what you described, this isn't something to try fixing yourself. " + + "Our technician will help you."); + alert.append(strong, tail); + body.appendChild(alert); + } else { + body.appendChild(el("p", "text-muted small mb-3", + "Here are a few safe things you can try in under 2 minutes. " + + "If they don't help, submit the form below and we'll come to you.")); + (result.steps || []).forEach((step, idx) => { + const stepWrap = el("div", "mb-3 p-2 border-start border-3 border-info"); + stepWrap.appendChild(el("div", "fw-bold", + `${idx + 1}. ${step.instruction}`)); + if (step.expected_result) { + stepWrap.appendChild(el("div", "small text-muted", + `Expected result: ${step.expected_result}`)); + } + if (step.safety_note) { + stepWrap.appendChild(el("div", "small text-danger mt-1", + `Safety: ${step.safety_note}`)); + } + body.appendChild(stepWrap); + }); + } + if (result.disclaimer) { + body.appendChild(el("div", "small text-muted fst-italic mt-2", + result.disclaimer)); + } + card.appendChild(body); + children.push(card); + this.selfCheckResult.replaceChildren(...children); + } +} + +registry.category("public.interactions").add( + "fusion_repairs.client_form", + FusionRepairsClientForm, +); diff --git a/fusion_repairs/views/portal_client_repair_templates.xml b/fusion_repairs/views/portal_client_repair_templates.xml index 9bd42347..15812838 100644 --- a/fusion_repairs/views/portal_client_repair_templates.xml +++ b/fusion_repairs/views/portal_client_repair_templates.xml @@ -13,9 +13,18 @@

Need a repair?

Tell us about your equipment and what's going wrong. - We'll respond on the next business day - or sooner if it's urgent. + We'll get to it on the next business day - or sooner if urgent.

- + + + + + Start a Service Request
@@ -26,6 +35,9 @@ Is anyone hurt right now? If you have a medical emergency, please hang up and dial 9-1-1.
+
+ Already a customer? Have your phone number handy - we'll recognize your account. +
@@ -61,9 +73,16 @@
+ enctype="multipart/form-data" + class="card shadow-sm" + id="fr_repair_form" + data-fr-client-form="1"> + + +