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:
@@ -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'],
|
||||
|
||||
@@ -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=<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",
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -13,9 +13,18 @@
|
||||
<h1 class="display-5 fw-bold">Need a repair?</h1>
|
||||
<p class="lead text-muted mb-4">
|
||||
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>
|
||||
<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
|
||||
</a>
|
||||
<div class="text-muted mt-4 small">
|
||||
@@ -26,6 +35,9 @@
|
||||
<strong>Is anyone hurt right now?</strong>
|
||||
If you have a medical emergency, please hang up and dial <strong>9-1-1</strong>.
|
||||
</div>
|
||||
<div class="text-muted mt-4 small">
|
||||
Already a customer? Have your phone number handy - we'll recognize your account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -61,9 +73,16 @@
|
||||
</t>
|
||||
|
||||
<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"
|
||||
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. -->
|
||||
<div style="position:absolute;left:-9999px;top:-9999px;" aria-hidden="true">
|
||||
<label>Company name</label>
|
||||
@@ -72,37 +91,62 @@
|
||||
|
||||
<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>
|
||||
<div class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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>
|
||||
|
||||
<hr/>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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"/>
|
||||
</option>
|
||||
</t>
|
||||
@@ -120,7 +164,7 @@
|
||||
<h5>3. What's wrong?</h5>
|
||||
<div class="mb-3">
|
||||
<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 class="mb-3">
|
||||
<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"/>
|
||||
</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/>
|
||||
|
||||
<h5>4. How urgent is it?</h5>
|
||||
@@ -166,15 +225,58 @@
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="o_fusion_repairs_client">
|
||||
<section class="container py-5">
|
||||
<div class="row justify-content-center text-center">
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7 text-center">
|
||||
<i class="fa fa-check-circle fa-4x text-success mb-3"/>
|
||||
<h1 class="mb-3">Got it!</h1>
|
||||
<p class="lead text-muted">
|
||||
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.
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -228,6 +228,25 @@ class RepairVisitReportWizard(models.TransientModel):
|
||||
if not repair.x_fc_is_quote_only:
|
||||
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.
|
||||
# Otherwise, if a certificate was issued, jump to it so the tech can print.
|
||||
if stub:
|
||||
@@ -287,6 +306,42 @@ class RepairVisitReportWizard(models.TransientModel):
|
||||
'Replaced part serials captured:<br/><pre>%s</pre>'
|
||||
)) % 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):
|
||||
"""M5: deduct one visit from the most-recently-active service plan
|
||||
covering this repair. Quietly no-ops if the client has no plan."""
|
||||
|
||||
Reference in New Issue
Block a user