fix(fusion_repairs): persona-driven workflow audit - 6 real bugs
Full end-to-end walk acting as customer, CS rep, dispatcher, technician,
and manager surfaced 6 real bugs (1 critical state-machine, 4 missing UX
wires, 1 docstring). Server endpoints existed for everything but several
were not wired into the templates.
B1 (HIGH) - Visit-report wizard never closed the repair
Tech submitted visit -> state stayed 'draft' -> x_fc_done_at never
stamped -> NPS cron never fired -> the whole post-visit flow died
silently. Customers never got their NPS email.
Fix: action_confirm() now drives the Odoo native state machine
draft -> action_validate (with _action_repair_confirm fallback) ->
action_repair_start -> action_repair_end. Each step guarded by the
current state and exception-logged. Leaves the repair open if:
- requires_requote=True (variance flag - office must re-quote)
- no_show=True (office reschedules)
- x_fc_is_quote_only (still a quote)
- found_another_issue spawned a stub
Posts a clear chatter line on success or failure.
Verified: e2e walk now shows state=done + x_fc_done_at stamped +
NPS cron fires + flags x_fc_nps_email_sent=True.
B2 (HIGH) - /repair/new form never called /repair/self_check
The AI self-check engine was the headline weekend feature but it was
invisible to the client. The endpoint worked server-side, just had
no frontend.
Fix: new portal_client_repair.js (Interaction class, registered on
registry.category('public.interactions')). 'Try 1-3 safe self-check
steps first' button POSTs to /repair/self_check, renders steps via
createElement + textContent (no innerHTML - all server output is
treated as untrusted text). Shows the AI's safety disclaimer on
every result. On escalate_immediately, shows a clear 'submit the
form, we'll come to you' message instead of the steps.
Verified: HTTP POST returns full JSON with instruction +
expected_result + disclaimer; new button + result panel appear in
rendered HTML.
B3 (HIGH) - No phone-lookup UI for returning clients
Same problem - endpoint existed but no UI. Returning clients had to
retype everything from scratch.
Fix:
- lookup_phone now returns a 'partners' array (id, name, email,
street, city) - cap of 3 results, rate-limited, every match logged
at INFO level for audit. Privacy compromise: a phone holder
deserves to see their own pre-fill; rate limit caps harvesting.
- JS lookup widget at the top of the form posts to /repair/lookup_phone
and pre-fills the 5 contact fields + writes the partner_id to a
hidden #fr_known_partner_id input.
- controller /repair/submit now trusts known_partner_id if present
(skips the phone re-match) so we don't create duplicate partners
when the lookup widget already identified the right one.
Verified: HTTP POST returns the 2 partner records we have for
+19055551234 with full id/name/email/street/city.
B4 (MEDIUM) - /repair?sn=<serial> from QR sticker did nothing
Spec: 'Client scans QR sticker - portal pre-fills the unit info.'
Reality: the form had no serial field; ?sn= was ignored.
Fix: new _resolve_serial_info(serial) on the controller resolves
the lot via stock.lot.search([('name','=',sn)]) and returns
{serial, lot_id, product_id, product_name, category_id}. Both
/repair (landing) and /repair/new pass it as serial_info template
context. Templates show 'Recognized X (Serial: Y)' + auto-select
the matching category in the dropdown. Hidden #fr_serial_number
carries it through to /repair/submit, which attaches the lot_id +
uses the QR category as fallback if user didn't pick one.
Verified: ?sn=stella23-20040164 produces 'Pre-filled from QR scan:'
banner + hidden input populated.
B5 (MEDIUM) - No upsell after submit
Spec required an upsell - 'reduce future calls'. Page was a bare
'Got it'.
Fix: /repair/thanks now shows a 2-card layout:
- 'Want to avoid this next time?' with 4 bullets (priority booking,
free inspection cert, discounted parts, annual reminder) +
'See our maintenance plans' CTA to /shop?category=maintenance
- 'What happens next' 4-step bulleted explanation
Verified: both cards render.
B6 (LOW) - SyntaxWarning '\-->' in repair_service_plan.py
Made the module docstring a raw string (r''') so the ASCII flowchart
arrows don't trigger Python's invalid-escape-sequence warning.
Bumped to 19.0.1.8.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
173
fusion_repairs/static/src/js/portal_client_repair.js
Normal file
173
fusion_repairs/static/src/js/portal_client_repair.js
Normal file
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user