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:
gsinghpal
2026-05-21 01:06:12 -04:00
parent b4b59cc3c9
commit 4f1b7c2df6
6 changed files with 418 additions and 29 deletions

View 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,
);