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

@@ -4,7 +4,7 @@
{ {
'name': 'Fusion Repairs', 'name': 'Fusion Repairs',
'version': '19.0.1.7.0', 'version': '19.0.1.8.0',
'category': 'Inventory/Repairs', 'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """ '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_repair_mobile.scss',
'fusion_repairs/static/src/scss/portal_client_repair.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_repair_intake.js',
'fusion_repairs/static/src/js/portal_client_repair.js',
], ],
}, },
'images': ['static/description/icon.png'], 'images': ['static/description/icon.png'],

View File

@@ -105,9 +105,14 @@ class ClientRepairPortal(http.Controller):
# LANDING # LANDING
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route("/repair", type="http", auth="public", website=True, sitemap=True) @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", { return request.render("fusion_repairs.portal_client_repair_landing", {
"page_name": "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, @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([ categories = request.env["fusion.repair.product.category"].sudo().search([
("active", "=", True), ("active", "=", True),
], order="sequence, name") ], 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", { return request.render("fusion_repairs.portal_client_repair_form", {
"page_name": "client_repair_new", "page_name": "client_repair_new",
"categories": categories, "categories": categories,
"prefilled_serial": prefilled_serial, "serial_info": serial_info,
"error": kw.get("error"), "error": kw.get("error"),
}) })
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# SAFE PARTNER LOOKUP (anti-leak) # B4: resolve ?sn=<serial> 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", @http.route("/repair/lookup_phone", type="jsonrpc", auth="public",
website=True) website=True)
@@ -134,15 +164,28 @@ class ClientRepairPortal(http.Controller):
return {"error": "rate_limited"} return {"error": "rate_limited"}
cleaned = _e164_clean(phone) cleaned = _e164_clean(phone)
if len(cleaned) < 7: if len(cleaned) < 7:
return {"matched": False} return {"matched": False, "partners": []}
matches = request.env["res.partner"].sudo().search([ matches = request.env["res.partner"].sudo().search([
"|", "|",
("phone", "ilike", cleaned[-7:]), ("phone", "ilike", cleaned[-7:]),
("phone_sanitized", "ilike", cleaned[-7:]), ("phone_sanitized", "ilike", cleaned[-7:]),
], limit=1) ], limit=3) # cap at 3 - real households rarely have more
if matches: if not matches:
return _mask_partner_for_lookup(matches[0]) return {"matched": False, "partners": []}
return {"matched": False} _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 # SUBMIT
@@ -174,11 +217,19 @@ class ClientRepairPortal(http.Controller):
if raw_email and not clean_email: if raw_email and not clean_email:
return request.redirect("/repair/new?error=email") return request.redirect("/repair/new?error=email")
# Find or create partner. Match by phone if known (safe - we already # B3: trust the explicit known_partner_id from the lookup widget when
# have their consent to contact via this form). # present (client identified themselves via the lookup widget on this
cleaned_phone = _e164_clean(phone) # very page). Otherwise re-match by phone, otherwise create.
partner = False 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([ partner = request.env["res.partner"].sudo().search([
"|", "|",
("phone", "ilike", cleaned_phone[-7:]), ("phone", "ilike", cleaned_phone[-7:]),
@@ -211,6 +262,8 @@ class ClientRepairPortal(http.Controller):
"res_id": 0, "res_id": 0,
}).id) }).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 = { equipment = {
"repair_category_id": category_id, "repair_category_id": category_id,
"third_party": post.get("third_party") in ("on", "true", "1"), "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(), "internal_notes": (post.get("internal_notes") or "").strip(),
"photo_attachment_ids": attachment_ids, "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: # 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 if present, else the lowest-id non-share user, else SUPERUSER_ID.
admin = request.env.ref("base.user_admin", raise_if_not_found=False) admin = request.env.ref("base.user_admin", raise_if_not_found=False)

View File

@@ -2,7 +2,7 @@
# Copyright 2024-2026 Nexa Systems Inc. # Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
"""Pre-paid service plans (M5). r"""Pre-paid service plans (M5).
Architecture: Architecture:

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

View File

@@ -13,9 +13,18 @@
<h1 class="display-5 fw-bold">Need a repair?</h1> <h1 class="display-5 fw-bold">Need a repair?</h1>
<p class="lead text-muted mb-4"> <p class="lead text-muted mb-4">
Tell us about your equipment and what's going wrong. 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.
</p> </p>
<a href="/repair/new" class="btn btn-primary btn-lg px-5 py-3"> <!-- B4: surface the QR ?sn= context so the user knows we recognized their device -->
<t t-if="serial_info">
<div class="alert alert-success text-start mb-4" role="alert">
<i class="fa fa-qrcode me-1"/>
Recognized <strong t-out="serial_info.get('product_name')"/>
(Serial: <code t-out="serial_info.get('serial')"/>).
We'll pre-fill your service request.
</div>
</t>
<a t-att-href="form_url" class="btn btn-primary btn-lg px-5 py-3">
Start a Service Request Start a Service Request
</a> </a>
<div class="text-muted mt-4 small"> <div class="text-muted mt-4 small">
@@ -26,6 +35,9 @@
<strong>Is anyone hurt right now?</strong> <strong>Is anyone hurt right now?</strong>
If you have a medical emergency, please hang up and dial <strong>9-1-1</strong>. If you have a medical emergency, please hang up and dial <strong>9-1-1</strong>.
</div> </div>
<div class="text-muted mt-4 small">
Already a customer? Have your phone number handy - we'll recognize your account.
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -61,9 +73,16 @@
</t> </t>
<form action="/repair/submit" method="POST" <form action="/repair/submit" method="POST"
enctype="multipart/form-data" class="card shadow-sm"> enctype="multipart/form-data"
class="card shadow-sm"
id="fr_repair_form"
data-fr-client-form="1">
<input type="hidden" name="csrf_token" <input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/> t-att-value="request.csrf_token()"/>
<!-- B3: phone lookup pre-fills + ?sn= QR pre-fills -->
<input type="hidden" name="known_partner_id" id="fr_known_partner_id" value=""/>
<input type="hidden" name="serial_number" id="fr_serial_number"
t-att-value="serial_info and serial_info.get('serial') or ''"/>
<!-- Honeypot. Real users never see this. --> <!-- Honeypot. Real users never see this. -->
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true"> <div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
<label>Company name</label> <label>Company name</label>
@@ -72,37 +91,62 @@
<div class="card-body p-4"> <div class="card-body p-4">
<!-- B3: phone-first lookup for returning clients -->
<div class="alert alert-info mb-3" id="fr_lookup_panel">
<h6 class="mb-2"><i class="fa fa-search me-1"/>Already a client?</h6>
<div class="input-group">
<input type="tel" id="fr_lookup_phone"
class="form-control"
placeholder="Enter phone (e.g. 905-555-1234)"/>
<button class="btn btn-outline-primary" type="button"
id="fr_lookup_btn">Look me up</button>
</div>
<small class="text-muted">
We'll pre-fill your contact info so you don't have to retype it.
</small>
<div id="fr_lookup_result" class="mt-2"></div>
</div>
<h5>1. Your contact details</h5> <h5>1. Your contact details</h5>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Your name <span class="text-danger">*</span></label> <label class="form-label">Your name <span class="text-danger">*</span></label>
<input type="text" name="client_name" class="form-control form-control-lg" required="required"/> <input type="text" name="client_name" id="fr_client_name" class="form-control form-control-lg" required="required"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Phone number <span class="text-danger">*</span></label> <label class="form-label">Phone number <span class="text-danger">*</span></label>
<input type="tel" name="client_phone" class="form-control form-control-lg" required="required" placeholder="(519) 555-1234"/> <input type="tel" name="client_phone" id="fr_client_phone" class="form-control form-control-lg" required="required" placeholder="(519) 555-1234"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Email (so we can send a confirmation)</label> <label class="form-label">Email (so we can send a confirmation)</label>
<input type="email" name="client_email" class="form-control"/> <input type="email" name="client_email" id="fr_client_email" class="form-control"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Street address</label> <label class="form-label">Street address</label>
<input type="text" name="client_street" class="form-control"/> <input type="text" name="client_street" id="fr_client_street" class="form-control"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">City</label> <label class="form-label">City</label>
<input type="text" name="client_city" class="form-control"/> <input type="text" name="client_city" id="fr_client_city" class="form-control"/>
</div> </div>
<hr/> <hr/>
<h5>2. What equipment needs service?</h5> <h5>2. What equipment needs service?</h5>
<!-- B4: surface ?sn= context so user knows we identified their device -->
<t t-if="serial_info">
<div class="alert alert-success mb-3">
<i class="fa fa-qrcode me-1"/>
Pre-filled from QR scan: <strong t-out="serial_info.get('product_name')"/>
(Serial <code t-out="serial_info.get('serial')"/>)
</div>
</t>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Equipment category <span class="text-danger">*</span></label> <label class="form-label">Equipment category <span class="text-danger">*</span></label>
<select name="category_id" class="form-select form-select-lg" required="required"> <select name="category_id" id="fr_category_id" class="form-select form-select-lg" required="required">
<option value="">Choose one...</option> <option value="">Choose one...</option>
<t t-foreach="categories" t-as="cat"> <t t-foreach="categories" t-as="cat">
<option t-att-value="cat.id"> <option t-att-value="cat.id"
t-att-selected="serial_info and serial_info.get('category_id') == cat.id">
<t t-out="cat.name"/> <t t-out="cat.name"/>
</option> </option>
</t> </t>
@@ -120,7 +164,7 @@
<h5>3. What's wrong?</h5> <h5>3. What's wrong?</h5>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Short description <span class="text-danger">*</span></label> <label class="form-label">Short description <span class="text-danger">*</span></label>
<input type="text" name="issue_summary" class="form-control form-control-lg" required="required" placeholder="e.g. 'stairlift beeps and won't move'"/> <input type="text" name="issue_summary" id="fr_issue_summary" class="form-control form-control-lg" required="required" placeholder="e.g. 'stairlift beeps and won't move'"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Anything else we should know?</label> <label class="form-label">Anything else we should know?</label>
@@ -131,6 +175,21 @@
<input type="file" name="photos" class="form-control" accept="image/*,video/*" multiple="multiple" capture="environment"/> <input type="file" name="photos" class="form-control" accept="image/*,video/*" multiple="multiple" capture="environment"/>
</div> </div>
<!-- B2: AI self-check button. Triggered manually so we don't
spam the AI / fallback on every keystroke. -->
<div class="mb-3">
<button type="button" class="btn btn-outline-info"
id="fr_selfcheck_btn">
<i class="fa fa-magic me-1"/>
Try 1-3 safe self-check steps first (optional)
</button>
<small class="text-muted d-block mt-1">
We'll suggest a couple of things you can safely try in under 2 minutes.
If they don't help, just submit and we'll come to you.
</small>
<div id="fr_selfcheck_result" class="mt-3"></div>
</div>
<hr/> <hr/>
<h5>4. How urgent is it?</h5> <h5>4. How urgent is it?</h5>
@@ -166,15 +225,58 @@
<t t-call="website.layout"> <t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client"> <div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5"> <section class="container py-5">
<div class="row justify-content-center text-center"> <div class="row justify-content-center">
<div class="col-12 col-lg-7"> <div class="col-12 col-lg-7 text-center">
<i class="fa fa-check-circle fa-4x text-success mb-3"/> <i class="fa fa-check-circle fa-4x text-success mb-3"/>
<h1 class="mb-3">Got it!</h1> <h1 class="mb-3">Got it!</h1>
<p class="lead text-muted"> <p class="lead text-muted">
Your service request <strong t-if="ref"><t t-out="ref"/></strong> was received. Your service request <strong t-if="ref"><t t-out="ref"/></strong> was received.
We'll get back to you on the next business day or sooner if you marked it urgent. We'll get back to you on the next business day or sooner if you marked it urgent.
</p> </p>
<a href="/repair" class="btn btn-outline-secondary mt-3">Back to home</a> </div>
<!-- B5: post-submit upsell - maintenance plan + next steps -->
<div class="col-12 col-lg-7 mt-4">
<div class="card border-info">
<div class="card-body">
<h5 class="card-title">
<i class="fa fa-lightbulb-o me-1 text-warning"/>
Want to avoid this next time?
</h5>
<p class="card-text">
Most of our regular clients enrol in an <strong>annual
maintenance plan</strong> - we visit twice a year, catch wear
before it becomes a breakdown, and you pay a lot less for
peace of mind than for an emergency call-out.
</p>
<ul class="small text-muted">
<li>Priority booking - your calls jump the queue</li>
<li>Free safety inspection certificate (stairlifts, porch lifts)</li>
<li>Discounted parts</li>
<li>Annual reminder so you never forget</li>
</ul>
<a href="/shop?category=maintenance" class="btn btn-info">
See our maintenance plans
</a>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title mb-2">
<i class="fa fa-info-circle me-1"/>
What happens next
</h6>
<ol class="small mb-0">
<li>You will get a confirmation email within a few minutes.</li>
<li>Our office reviews your request the next business day.</li>
<li>We call you to confirm a technician visit time.</li>
<li>You will get a reminder the day before the visit.</li>
</ol>
</div>
</div>
<div class="text-center mt-4">
<a href="/repair" class="btn btn-outline-secondary">Back to home</a>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -228,6 +228,25 @@ class RepairVisitReportWizard(models.TransientModel):
if not repair.x_fc_is_quote_only: if not repair.x_fc_is_quote_only:
self._burn_service_plan_visit(repair) self._burn_service_plan_visit(repair)
# BUG-B1 fix: actually close the repair so the whole downstream chain
# (NPS cron, dashboard "done this month" stats, customer survey) fires.
# Leave open if requote needed - the office will re-quote and the tech
# will revisit. No-show or quote-only also stays open.
if (not self.requires_requote
and not self.no_show
and not repair.x_fc_is_quote_only
and not stub):
self._close_repair(repair)
elif self.no_show:
# No-show: drop back to draft for re-scheduling.
repair.message_post(body=Markup(_(
'Repair kept <b>open</b> due to no-show. Office to reschedule.'
)))
elif self.requires_requote:
repair.message_post(body=Markup(_(
'Repair kept <b>open</b> pending re-quote (variance flag).'
)))
# If a stub was spawned, open it directly so the tech can fill in details. # If a stub was spawned, open it directly so the tech can fill in details.
# Otherwise, if a certificate was issued, jump to it so the tech can print. # Otherwise, if a certificate was issued, jump to it so the tech can print.
if stub: if stub:
@@ -287,6 +306,42 @@ class RepairVisitReportWizard(models.TransientModel):
'Replaced part serials captured:<br/><pre>%s</pre>' 'Replaced part serials captured:<br/><pre>%s</pre>'
)) % self.parts_serial_capture.strip()) )) % self.parts_serial_capture.strip())
def _close_repair(self, repair):
"""Drive the Odoo native state machine from draft -> done.
Odoo 19 sequence: draft -> action_validate (confirmed/under_repair)
-> action_repair_start (under_repair) -> action_repair_end (done).
Calls are guarded - silently re-runs only the missing steps.
"""
try:
if repair.state == 'draft':
# action_validate is the standard entry path; if the product is
# storable it expects reservations etc., so fall back to the
# simpler _action_repair_confirm() helper if validate refuses.
try:
repair.action_validate()
except Exception as e:
_logger.info(
'action_validate skipped for %s: %s; using internal confirm.',
repair.name, e,
)
repair._action_repair_confirm()
if repair.state == 'confirmed':
repair.action_repair_start()
if repair.state == 'under_repair':
repair.action_repair_end()
repair.message_post(body=Markup(_(
'Visit report submitted - repair closed by <b>%s</b>.'
)) % (self.technician_id.name or self.env.user.name))
except Exception as e:
_logger.exception(
'Visit report could not close repair %s automatically: %s',
repair.name, e,
)
repair.message_post(body=Markup(_(
'<b>Could not auto-close repair</b>: %s. Office must close manually.'
)) % str(e))
def _burn_service_plan_visit(self, repair): def _burn_service_plan_visit(self, repair):
"""M5: deduct one visit from the most-recently-active service plan """M5: deduct one visit from the most-recently-active service plan
covering this repair. Quietly no-ops if the client has no plan.""" covering this repair. Quietly no-ops if the client has no plan."""