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',
'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'],

View File

@@ -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)

View File

@@ -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:

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>
<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>

View File

@@ -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."""