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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user