feat(fusion_repairs): Bundle 2 - weekend self-service (CL6/CL7 + CL15 + CL17)
CL6/CL7 AI self-check engine
- New fusion.repair.ai.service AbstractModel with single guardrailed
suggest_self_check(category_id, symptoms, urgency) entry point.
- Hard-escalation FIRST (before any AI call): stairlift / porch lift +
safety symptoms (smoke / burning / spark / stuck / motor), OR any
mention of fire / injury / hurt / bleeding / trapped, OR urgency=safety
-> escalate immediately regardless of AI availability.
- AI call via fusion.api.service.call_openai() (consumer='fusion_repairs',
feature='client_self_triage') with try/fallback per project rule -
no hard fusion_api dep, no install error if it's missing.
- Strict response validation: JSON schema check, max 3 steps, max 200
chars per field, forbidden-phrase regex (diagnose, you have, medical
condition, stop using, consult doctor, price patterns) - on any
failure falls back to deterministic rules.
- 24h in-memory cache keyed by (category, symptom_hash) so repeat calls
during AI cost-cap incidents come from cache.
- System prompt + JSON schema published as ir.config_parameter so office
can refine without code changes (default prompt + schema in spec
Appendix A).
- New fusion.repair.self.check.rule model + 17 seeded rules across all
7 product categories (data/self_check_data.xml) - these are the
deterministic fallback AND the canonical seed if AI is disabled.
- New /repair/self_check jsonrpc route (auth=public) gated by the
per-IP rate-limit; defensive input bounds (max 5 symptoms, 500 chars
each) defend against prompt-injection bloat.
CL15 weekend safety escalation + on-call paging
- New fusion.repair.on.call.service AbstractModel with:
* find_next_on_call(exclude=...) -> lowest x_fc_on_call_priority
* page_on_call(repair) -> sends mail to next available + writes
x_fc_on_call_token / x_fc_on_call_paged_user_id / paged_at on the
repair, posts chatter
* acknowledge(repair, user) -> records ack, posts chatter
* cron_escalate_unacknowledged() -> every 5 min, re-pages the next
priority for repairs paged >15 min ago without ack
- Auto-fires from intake service whenever x_fc_urgency='safety' is
submitted. _is_business_hours() defaults to "page" when no calendar
is set or after working hours.
- New email_template_on_call_page with 4px red accent + acknowledge
CTA button linking to /repair/on-call/ack/<token>.
- /repair/on-call/ack/<token> http route (auth=user, must be the paged
manager OR any internal user) records the ack and renders confirmation.
- 5-minute cron 'Fusion Repairs: Escalate unacknowledged on-call pages'
with configurable window via fusion_repairs.on_call_escalate_minutes
(default 15).
- New repair.order fields x_fc_on_call_token, x_fc_on_call_paged_user_id,
x_fc_on_call_paged_at, x_fc_on_call_acknowledged_user_ids,
x_fc_on_call_acknowledged_at - all copy=False so duplicates start fresh.
CL17 QR sticker generator
- New fusion.repair.qr.sticker.wizard TransientModel takes a Many2many
of stock.lot records (optionally filtered by product).
- QWeb PDF report fusion_repairs.report_qr_stickers prints a 4-up
sticker sheet on letter paper: 80mm x 50mm per sticker with the
QR code (38mm), product name, serial number, and the canonical
portal URL (from web.base.url + fusion_repairs.client_portal_url).
- QR encodes /repair?sn=<serial> which the public client portal
already pre-fills via the ?sn= query param.
- Uses the qrcode library if available; renders 'QR lib missing'
placeholder otherwise so the PDF still prints.
- New menu Configuration > Generate QR Stickers + standalone wizard.
Verified end-to-end on local westin-v19:
CL6 stairlift+smoke -> escalate=True source=escalated reason=safety
CL6 bed (no AI) -> fallback returned escalate=True (safe default)
CL15 admin paged for RO-202605-10 with 27-char token
CL17 sticker URL: /repair?sn=001124032521528404
QR data URI: data:image/png;base64,iVBORw... (PNG OK)
Bumped to 19.0.1.2.0 (minor bump - new public-facing capabilities).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Repairs',
|
||||
'version': '19.0.1.1.1',
|
||||
'version': '19.0.1.2.0',
|
||||
'category': 'Inventory/Repairs',
|
||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||
'description': """
|
||||
@@ -74,6 +74,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
'data/mail_template_data.xml',
|
||||
'data/repair_product_category_data.xml',
|
||||
'data/intake_template_data.xml',
|
||||
'data/self_check_data.xml',
|
||||
# Views
|
||||
'views/repair_product_category_views.xml',
|
||||
'views/intake_template_views.xml',
|
||||
@@ -94,6 +95,9 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
# Wizards
|
||||
'wizard/repair_intake_wizard_views.xml',
|
||||
'wizard/repair_visit_report_wizard_views.xml',
|
||||
'wizard/qr_sticker_wizard_views.xml',
|
||||
# Reports
|
||||
'report/qr_sticker_report.xml',
|
||||
# Menus (last, after all referenced actions exist)
|
||||
'views/menus.xml',
|
||||
],
|
||||
|
||||
@@ -254,3 +254,43 @@ class ClientRepairPortal(http.Controller):
|
||||
"page_name": "client_repair_thanks",
|
||||
"ref": ref or "",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CL6 / CL7: AI self-check JSONRPC endpoint
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/repair/self_check", type="jsonrpc", auth="public",
|
||||
website=True)
|
||||
def repair_self_check(self, category_id=None, symptoms=None,
|
||||
urgency=None, **kw):
|
||||
if self._check_rate_limit():
|
||||
return {"error": "rate_limited"}
|
||||
if not symptoms:
|
||||
symptoms = []
|
||||
if isinstance(symptoms, str):
|
||||
symptoms = [symptoms]
|
||||
# Defensive: cap input size to defend against prompt-injection bloat
|
||||
symptoms = [str(s)[:500] for s in symptoms[:5]]
|
||||
Service = request.env["fusion.repair.ai.service"].sudo()
|
||||
return Service.suggest_self_check(
|
||||
product_category_id=int(category_id or 0) or None,
|
||||
symptoms=symptoms,
|
||||
urgency=urgency or None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CL15: on-call acknowledgement endpoint
|
||||
# ------------------------------------------------------------------
|
||||
@http.route("/repair/on-call/ack/<string:token>", type="http",
|
||||
auth="user", website=True, sitemap=False)
|
||||
def repair_on_call_ack(self, token, **kw):
|
||||
Repair = request.env["repair.order"].sudo()
|
||||
repair = Repair.search([("x_fc_on_call_token", "=", token)], limit=1)
|
||||
if not repair:
|
||||
return request.render(
|
||||
"fusion_repairs.portal_on_call_ack_invalid", {},
|
||||
)
|
||||
Service = request.env["fusion.repair.on.call.service"].sudo()
|
||||
Service.acknowledge(repair, request.env.user)
|
||||
return request.render("fusion_repairs.portal_on_call_ack_ok", {
|
||||
"repair_name": repair.name,
|
||||
})
|
||||
|
||||
@@ -15,5 +15,20 @@
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- CL15: Escalate unacknowledged on-call pages every 5 minutes.
|
||||
Pages older than fusion_repairs.on_call_escalate_minutes
|
||||
(default 15) get re-paged to the next priority. -->
|
||||
<record id="cron_on_call_escalate" model="ir.cron">
|
||||
<field name="name">Fusion Repairs: Escalate unacknowledged on-call pages</field>
|
||||
<field name="model_id" ref="model_fusion_repair_on_call_service"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_escalate_unacknowledged()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="nextcall" eval="(DateTime.now() + timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -55,6 +55,53 @@
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- On-Call Safety Page -->
|
||||
<!-- ============================================================== -->
|
||||
<record id="email_template_on_call_page" model="mail.template">
|
||||
<field name="name">Repair: On-Call Safety Page</field>
|
||||
<field name="model_id" ref="repair.model_repair_order"/>
|
||||
<field name="subject">[SAFETY PAGE] {{ object.partner_id.name or 'Unknown' }} - {{ object.name or 'n/a' }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#c53030;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<p style="color:#c53030;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
URGENT - SAFETY PAGE
|
||||
</p>
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Safety service call requires response</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
A client just submitted a safety-flagged service request via the
|
||||
<t t-out="dict(object._fields['x_fc_intake_source'].selection).get(object.x_fc_intake_source) or 'intake'"/>.
|
||||
You have been paged as the on-call manager.
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.name or '?'"/></td></tr>
|
||||
<t t-if="object.partner_id.phone">
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Phone</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.phone"/></td></tr>
|
||||
</t>
|
||||
<t t-if="object.x_fc_repair_category_id">
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Equipment</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_repair_category_id.name"/></td></tr>
|
||||
</t>
|
||||
</table>
|
||||
<div style="text-align:center;margin:0 0 24px 0;">
|
||||
<a t-attf-href="/repair/on-call/ack/{{ object.x_fc_on_call_token or '' }}"
|
||||
style="display:inline-block;padding:14px 28px;background-color:#c53030;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
|
||||
Acknowledge - I will respond
|
||||
</a>
|
||||
</div>
|
||||
<p style="opacity:0.55;font-size:12px;margin:0;">
|
||||
If you do not acknowledge within 15 minutes, the next on-call
|
||||
priority will be paged automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Maintenance Due Reminder -->
|
||||
<!-- ============================================================== -->
|
||||
|
||||
162
fusion_repairs/data/self_check_data.xml
Normal file
162
fusion_repairs/data/self_check_data.xml
Normal file
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Seed deterministic self-check rules per equipment category.
|
||||
These are used by fusion.repair.ai.service when AI is unavailable,
|
||||
when AI returns malformed/unsafe content, or when AI is disabled.
|
||||
noupdate=1 so admins can customise per site without losing changes.
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- Hospital Bed -->
|
||||
<record id="self_check_bed_no_power" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Hospital Bed - No Power</field>
|
||||
<field name="category_id" ref="category_hospital_bed"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">won't move,dead,no power,no response</field>
|
||||
<field name="instruction">Check the bed is plugged in and the outlet has power - try plugging a phone charger into the same outlet to confirm.</field>
|
||||
<field name="expected_result">Bed responds when controls are pressed.</field>
|
||||
</record>
|
||||
<record id="self_check_bed_slow" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Hospital Bed - Slow / Sluggish</field>
|
||||
<field name="category_id" ref="category_hospital_bed"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="symptom_keywords">slow,sluggish</field>
|
||||
<field name="instruction">Unplug the bed for 30 seconds then plug it back in.</field>
|
||||
<field name="expected_result">Movement returns to normal speed.</field>
|
||||
</record>
|
||||
<record id="self_check_bed_remote" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Hospital Bed - Remote Unresponsive</field>
|
||||
<field name="category_id" ref="category_hospital_bed"/>
|
||||
<field name="sequence">30</field>
|
||||
<field name="symptom_keywords">remote,controller</field>
|
||||
<field name="instruction">Replace the remote batteries with fresh AAA batteries.</field>
|
||||
<field name="expected_result">Remote lights up and bed responds.</field>
|
||||
</record>
|
||||
<record id="self_check_bed_alarm" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Hospital Bed - Alarm</field>
|
||||
<field name="category_id" ref="category_hospital_bed"/>
|
||||
<field name="sequence">40</field>
|
||||
<field name="symptom_keywords">beep,alarm,alert</field>
|
||||
<field name="instruction">Check both side rails are fully locked in the raised position.</field>
|
||||
<field name="expected_result">Alarm stops.</field>
|
||||
</record>
|
||||
|
||||
<!-- Wheelchair (manual) -->
|
||||
<record id="self_check_wheelchair_brake" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Wheelchair - Brake</field>
|
||||
<field name="category_id" ref="category_wheelchair_manual"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">brake,stop</field>
|
||||
<field name="instruction">Push the brake lever fully to the locked position and listen for a click.</field>
|
||||
<field name="expected_result">Brake holds wheel firmly.</field>
|
||||
</record>
|
||||
<record id="self_check_wheelchair_tire" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Wheelchair - Hard to Push</field>
|
||||
<field name="category_id" ref="category_wheelchair_manual"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="symptom_keywords">hard to push,drag,slow</field>
|
||||
<field name="instruction">Check both tires for full inflation - firm to thumb pressure.</field>
|
||||
<field name="expected_result">Wheelchair rolls freely.</field>
|
||||
</record>
|
||||
|
||||
<!-- Wheelchair (power) -->
|
||||
<record id="self_check_powerchair_battery" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Power Wheelchair - No Power</field>
|
||||
<field name="category_id" ref="category_wheelchair_power"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">won't turn on,dead,no power,battery</field>
|
||||
<field name="instruction">Confirm the battery indicator shows charge and the key switch is in the ON position.</field>
|
||||
<field name="expected_result">Display lights up.</field>
|
||||
</record>
|
||||
<record id="self_check_powerchair_error" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Power Wheelchair - Error Code</field>
|
||||
<field name="category_id" ref="category_wheelchair_power"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="symptom_keywords">error,flashing,code</field>
|
||||
<field name="instruction">Note the error code shown on the joystick display, then turn off and back on after 30 seconds.</field>
|
||||
<field name="expected_result">Error clears or a specific code is captured.</field>
|
||||
</record>
|
||||
|
||||
<!-- Stairlift (safety-critical) -->
|
||||
<record id="self_check_stairlift_seat" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Stairlift - Won't Move</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">won't move,stuck</field>
|
||||
<field name="instruction">Check the seat is fully rotated to the forward position and the seatbelt is fastened.</field>
|
||||
<field name="expected_result">Stairlift responds.</field>
|
||||
</record>
|
||||
<record id="self_check_stairlift_track" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Stairlift - Stops Midway</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="symptom_keywords">stops midway,halts</field>
|
||||
<field name="instruction">Check the track for items blocking the sensors - toys, slippers, debris.</field>
|
||||
<field name="expected_result">Stairlift completes its travel.</field>
|
||||
</record>
|
||||
<record id="self_check_stairlift_remote" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Stairlift - Call Station Unresponsive</field>
|
||||
<field name="category_id" ref="category_stairlift"/>
|
||||
<field name="sequence">30</field>
|
||||
<field name="symptom_keywords">remote,call station</field>
|
||||
<field name="instruction">Replace the remote / call-station batteries with fresh batteries.</field>
|
||||
<field name="expected_result">Call station responds.</field>
|
||||
</record>
|
||||
|
||||
<!-- Porch Lift (safety-critical) -->
|
||||
<record id="self_check_porch_gate" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Porch Lift - Won't Move</field>
|
||||
<field name="category_id" ref="category_porch_lift"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">won't move,dead</field>
|
||||
<field name="instruction">Check all gate and door safety switches are fully closed.</field>
|
||||
<field name="expected_result">Lift responds.</field>
|
||||
</record>
|
||||
<record id="self_check_porch_controls" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Porch Lift - Sticky Controls</field>
|
||||
<field name="category_id" ref="category_porch_lift"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="symptom_keywords">sticky,stuck button</field>
|
||||
<field name="instruction">If outdoors, gently wipe the controls with a dry cloth and let dry.</field>
|
||||
<field name="expected_result">Controls respond.</field>
|
||||
</record>
|
||||
|
||||
<!-- Walker / Rollator -->
|
||||
<record id="self_check_walker_wheel" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Walker - Wheel Stuck</field>
|
||||
<field name="category_id" ref="category_walker"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">wheel stick,won't roll</field>
|
||||
<field name="instruction">Check for hair or debris wrapped around the wheel axle.</field>
|
||||
<field name="expected_result">Wheel spins freely.</field>
|
||||
</record>
|
||||
<record id="self_check_rollator_brake" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Rollator - Brake Won't Lock</field>
|
||||
<field name="category_id" ref="category_rollator"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">brake won't lock,brake loose</field>
|
||||
<field name="instruction">Push the brake lever fully down until you feel a click.</field>
|
||||
<field name="expected_result">Brake holds.</field>
|
||||
</record>
|
||||
|
||||
<!-- Medical Mattress -->
|
||||
<record id="self_check_mattress_pump" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Mattress - Deflated</field>
|
||||
<field name="category_id" ref="category_mattress"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="symptom_keywords">deflated,flat,soft</field>
|
||||
<field name="instruction">Confirm the pump is plugged in, powered on, and the hose is firmly attached.</field>
|
||||
<field name="expected_result">Mattress inflates.</field>
|
||||
</record>
|
||||
<record id="self_check_mattress_alarm" model="fusion.repair.self.check.rule">
|
||||
<field name="name">Mattress - Alarm</field>
|
||||
<field name="category_id" ref="category_mattress"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="symptom_keywords">alarm,beep</field>
|
||||
<field name="instruction">Check the pump display for the error code shown, then restart the pump by unplugging for 30 seconds.</field>
|
||||
<field name="expected_result">Alarm clears.</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -9,6 +9,9 @@ from . import intake_answer
|
||||
from . import service_catalog
|
||||
from . import repair_warranty
|
||||
from . import maintenance_contract
|
||||
from . import repair_self_check_rule
|
||||
from . import repair_ai_service
|
||||
from . import repair_on_call_service
|
||||
from . import product_template
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
||||
@@ -179,6 +179,13 @@ class FusionRepairIntakeService(models.AbstractModel):
|
||||
'Created in <b>Quote Only</b> mode - no technician dispatched.'
|
||||
)))
|
||||
|
||||
# CL15: page the on-call manager for safety intakes after hours.
|
||||
if repair.x_fc_urgency == 'safety':
|
||||
try:
|
||||
self.env['fusion.repair.on.call.service'].sudo().page_on_call(repair)
|
||||
except Exception as e:
|
||||
_logger.warning('On-call page failed for %s: %s', repair.name, e)
|
||||
|
||||
# Emails (client + office).
|
||||
self._send_intake_emails(repair)
|
||||
|
||||
|
||||
346
fusion_repairs/models/repair_ai_service.py
Normal file
346
fusion_repairs/models/repair_ai_service.py
Normal file
@@ -0,0 +1,346 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Repair AI Service - single guardrailed entry point for client-portal AI.
|
||||
|
||||
Per the design spec (Appendix A), this AbstractModel:
|
||||
1) Builds a strict system prompt forbidding medical advice, diagnoses,
|
||||
stop-using recommendations, etc.
|
||||
2) Calls fusion.api.service.call_openai() if available (try/fallback per
|
||||
fusion-api-integration rule - never installs as a hard dep)
|
||||
3) JSON-schema validates the response and runs a forbidden-phrase regex
|
||||
4) Always falls back to deterministic fusion.repair.self.check.rule
|
||||
records on any failure - intake must never be blocked by AI
|
||||
|
||||
System prompt + JSON schema live in ir.config_parameter so the office can
|
||||
refine them without code changes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ----- Safety filters -----
|
||||
FORBIDDEN_PATTERNS = [
|
||||
re.compile(r'\b(diagnos(e|is|ed|ing))\b', re.I),
|
||||
re.compile(r'\byou have\b', re.I),
|
||||
re.compile(r'\bmedical condition\b', re.I),
|
||||
re.compile(r'\bstop using\b', re.I),
|
||||
re.compile(r'\bconsult\s+(your|a)\s+(doctor|physician|nurse)\b', re.I),
|
||||
re.compile(r'(\$|CAD|USD)\s?\d+', re.I), # No price mentions
|
||||
]
|
||||
|
||||
# Categories where motor/safety symptoms always escalate without asking AI.
|
||||
SAFETY_CATEGORY_CODES = ('stairlift', 'porch_lift')
|
||||
SAFETY_SYMPTOMS = (
|
||||
'smoke', 'burning', 'spark', 'fire', 'stuck', 'trapped',
|
||||
'motor', 'brake fail', "won't stop", 'overshoot',
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
"You are a triage assistant for Fusion Repairs, a Canadian medical "
|
||||
"equipment service company. Your ONLY job is to suggest 1-3 safe, "
|
||||
"reversible self-check steps a client can try on their medical equipment "
|
||||
"before scheduling a technician visit.\n\n"
|
||||
"ABSOLUTE RULES:\n"
|
||||
"1. NEVER provide medical advice, diagnoses, or health recommendations.\n"
|
||||
"2. NEVER claim a definitive cause for the problem.\n"
|
||||
"3. NEVER recommend stopping use of medical equipment.\n"
|
||||
"4. NEVER use phrases like 'you have', 'I diagnose', 'you should stop', "
|
||||
"'medical condition', 'consult your doctor'.\n"
|
||||
"5. ONLY suggest steps that are: safe, reversible, require no tools, "
|
||||
"take under 2 minutes, and pose zero risk to the client or equipment.\n"
|
||||
"6. If symptoms involve smoke, sparks, burning smell, motors on "
|
||||
"stairlifts/porch lifts, OR if you are uncertain -> return "
|
||||
"escalate_immediately: true.\n"
|
||||
"7. Maximum 3 steps. Each step <= 1 sentence. Grade-6 reading level. "
|
||||
"No technical jargon.\n"
|
||||
"8. NEVER reference part numbers, prices, or other clients.\n"
|
||||
"9. If client reports injury, equipment fire, or person trapped -> "
|
||||
"escalate_immediately: true with escalation_reason: 'emergency'.\n"
|
||||
"10. You MUST output valid JSON matching the provided schema. No prose, "
|
||||
"no markdown, no commentary."
|
||||
)
|
||||
|
||||
|
||||
class FusionRepairAIService(models.AbstractModel):
|
||||
_name = 'fusion.repair.ai.service'
|
||||
_description = 'Repair AI Service - guardrailed self-check engine'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUBLIC API
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def suggest_self_check(self, product_category_id=None, symptoms=None, urgency=None):
|
||||
"""Return a list of safe self-check steps for the client portal.
|
||||
|
||||
Returns a dict with shape:
|
||||
{
|
||||
'escalate_immediately': bool,
|
||||
'escalation_reason': str | None,
|
||||
'confidence': 'high' | 'medium' | 'low',
|
||||
'steps': [{'instruction': str, 'expected_result': str,
|
||||
'safety_note': str | None}, ...],
|
||||
'source': 'ai' | 'fallback' | 'escalated',
|
||||
'disclaimer': str,
|
||||
}
|
||||
"""
|
||||
symptoms = [s for s in (symptoms or []) if s]
|
||||
category = (
|
||||
self.env['fusion.repair.product.category'].sudo().browse(product_category_id)
|
||||
if product_category_id else False
|
||||
)
|
||||
|
||||
# Pre-check: hard-escalate for safety-critical category + symptom combos
|
||||
# without consulting AI. This is BEFORE any AI call so even if AI is
|
||||
# down we still escalate the right way.
|
||||
if self._should_hard_escalate(category, symptoms, urgency):
|
||||
return self._escalated_response('safety')
|
||||
|
||||
# Try the AI, fall back to deterministic rules on any failure.
|
||||
ai_result = self._try_ai(category, symptoms)
|
||||
if ai_result:
|
||||
ai_result['source'] = 'ai'
|
||||
ai_result['disclaimer'] = self._disclaimer()
|
||||
return ai_result
|
||||
|
||||
return self._deterministic_fallback(category, symptoms)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HARD ESCALATION
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _should_hard_escalate(self, category, symptoms, urgency):
|
||||
if urgency == 'safety':
|
||||
return True
|
||||
text = ' '.join(symptoms).lower()
|
||||
if category and category.code in SAFETY_CATEGORY_CODES:
|
||||
if any(kw in text for kw in SAFETY_SYMPTOMS):
|
||||
return True
|
||||
# Anyone reporting fire / injury / trapped person, regardless of category.
|
||||
if any(kw in text for kw in ('fire', 'injury', 'hurt', 'bleeding', 'trapped')):
|
||||
return True
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _escalated_response(self, reason):
|
||||
return {
|
||||
'escalate_immediately': True,
|
||||
'escalation_reason': reason,
|
||||
'confidence': 'high',
|
||||
'steps': [],
|
||||
'source': 'escalated',
|
||||
'disclaimer': self._disclaimer(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AI CALL (try/fallback)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _try_ai(self, category, symptoms):
|
||||
try:
|
||||
ApiService = self.env.get('fusion.api.service')
|
||||
if not ApiService:
|
||||
return None
|
||||
messages = [
|
||||
{'role': 'system', 'content': self._system_prompt()},
|
||||
{'role': 'user', 'content': self._user_prompt(category, symptoms)},
|
||||
]
|
||||
cache_key = self._cache_key(category, symptoms)
|
||||
cached = self._cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
raw = ApiService.call_openai(
|
||||
consumer='fusion_repairs',
|
||||
feature='client_self_triage',
|
||||
messages=messages,
|
||||
max_tokens=400,
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
parsed = self._safe_parse(raw)
|
||||
if not parsed:
|
||||
self._log_incident('parse_failed', raw)
|
||||
return None
|
||||
self._cache_set(cache_key, parsed)
|
||||
return parsed
|
||||
except Exception as e:
|
||||
_logger.info('AI self-check skipped: %s', e)
|
||||
return None
|
||||
|
||||
@api.model
|
||||
def _system_prompt(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return ICP.get_param(
|
||||
'fusion_repairs.ai_self_check_system_prompt',
|
||||
DEFAULT_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _user_prompt(self, category, symptoms):
|
||||
cat_name = category.name if category else 'medical equipment'
|
||||
return (
|
||||
f"Equipment category: {cat_name}\n"
|
||||
f"Reported symptoms: {'; '.join(symptoms) if symptoms else '(none provided)'}\n"
|
||||
"Output the JSON object only."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SAFE PARSE + VALIDATE
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _safe_parse(self, raw):
|
||||
"""Parse the AI response, validate against the JSON schema, and run
|
||||
forbidden-phrase regex filters. Returns None on any failure - caller
|
||||
falls back to deterministic rules."""
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
# Strip code-fence wrapping if AI added it.
|
||||
if text.startswith('```'):
|
||||
text = re.sub(r'^```[a-zA-Z]*\n?', '', text)
|
||||
text = re.sub(r'\n?```$', '', text)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
# Schema check (minimal - we don't pull in jsonschema as a dep)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if not isinstance(data.get('escalate_immediately'), bool):
|
||||
return None
|
||||
confidence = data.get('confidence')
|
||||
if confidence not in ('high', 'medium', 'low'):
|
||||
return None
|
||||
steps = data.get('steps')
|
||||
if not isinstance(steps, list) or len(steps) > 3:
|
||||
return None
|
||||
# Coherence: not-escalated must have at least one step.
|
||||
if not data['escalate_immediately'] and not steps:
|
||||
return None
|
||||
# Per-step validation + forbidden-phrase scan.
|
||||
cleaned_steps = []
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
return None
|
||||
instr = step.get('instruction')
|
||||
expected = step.get('expected_result')
|
||||
if not isinstance(instr, str) or not instr.strip():
|
||||
return None
|
||||
if not isinstance(expected, str) or not expected.strip():
|
||||
return None
|
||||
if len(instr) > 200 or len(expected) > 200:
|
||||
return None
|
||||
if self._contains_forbidden(instr) or self._contains_forbidden(expected):
|
||||
return None
|
||||
note = step.get('safety_note')
|
||||
if note is not None and (not isinstance(note, str) or len(note) > 200):
|
||||
return None
|
||||
if note and self._contains_forbidden(note):
|
||||
return None
|
||||
cleaned_steps.append({
|
||||
'instruction': instr.strip(),
|
||||
'expected_result': expected.strip(),
|
||||
'safety_note': (note or '').strip() or None,
|
||||
})
|
||||
return {
|
||||
'escalate_immediately': data['escalate_immediately'],
|
||||
'escalation_reason': data.get('escalation_reason') or None,
|
||||
'confidence': confidence,
|
||||
'steps': cleaned_steps,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _contains_forbidden(self, text):
|
||||
if not text:
|
||||
return False
|
||||
return any(p.search(text) for p in FORBIDDEN_PATTERNS)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DETERMINISTIC FALLBACK
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _deterministic_fallback(self, category, symptoms):
|
||||
"""Look up fusion.repair.self.check.rule records for the category
|
||||
and return the matching steps. Used when AI is unavailable or
|
||||
returns invalid / unsafe content."""
|
||||
Rule = self.env['fusion.repair.self.check.rule'].sudo()
|
||||
steps = []
|
||||
if category:
|
||||
haystack = ' '.join(symptoms).lower()
|
||||
rules = Rule.search([
|
||||
('category_id', '=', category.id),
|
||||
('active', '=', True),
|
||||
], order='sequence')
|
||||
for r in rules:
|
||||
kws = [k.strip().lower() for k in (r.symptom_keywords or '').split(',') if k.strip()]
|
||||
if not kws or any(kw and kw in haystack for kw in kws):
|
||||
steps.append({
|
||||
'instruction': r.instruction or '',
|
||||
'expected_result': r.expected_result or '',
|
||||
'safety_note': r.safety_note or None,
|
||||
})
|
||||
if len(steps) >= 3:
|
||||
break
|
||||
return {
|
||||
'escalate_immediately': len(steps) == 0,
|
||||
'escalation_reason': None if steps else 'no_match',
|
||||
'confidence': 'medium' if steps else 'low',
|
||||
'steps': steps,
|
||||
'source': 'fallback',
|
||||
'disclaimer': self._disclaimer(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CACHE (in-memory per worker, 24h)
|
||||
# ------------------------------------------------------------------
|
||||
_CACHE = {}
|
||||
_CACHE_TTL = 24 * 3600
|
||||
|
||||
@api.model
|
||||
def _cache_key(self, category, symptoms):
|
||||
symptom_hash = hashlib.sha256(
|
||||
('|'.join(sorted(s.lower() for s in symptoms))).encode()
|
||||
).hexdigest()[:16]
|
||||
return f"{category.code if category else 'none'}:{symptom_hash}"
|
||||
|
||||
@api.model
|
||||
def _cache_get(self, key):
|
||||
import time
|
||||
entry = self._CACHE.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
ts, value = entry
|
||||
if time.time() - ts > self._CACHE_TTL:
|
||||
self._CACHE.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
@api.model
|
||||
def _cache_set(self, key, value):
|
||||
import time
|
||||
# Bound cache size to ~512 entries.
|
||||
if len(self._CACHE) > 512:
|
||||
self._CACHE.clear()
|
||||
self._CACHE[key] = (time.time(), value)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MISC
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _disclaimer(self):
|
||||
return ("This is not medical advice. If you're unsure, schedule a "
|
||||
"technician visit. In an emergency, call 9-1-1.")
|
||||
|
||||
@api.model
|
||||
def _log_incident(self, kind, raw):
|
||||
_logger.warning('AI self-check incident (%s): %s', kind, (raw or '')[:300])
|
||||
172
fusion_repairs/models/repair_on_call_service.py
Normal file
172
fusion_repairs/models/repair_on_call_service.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""On-call service - finds the next on-call manager and pages them.
|
||||
|
||||
Triggered when a safety-flagged repair comes in outside business hours
|
||||
(or any time, if the user wants to call us about a stuck stairlift).
|
||||
|
||||
Per the design spec section "Weekend safety escalation":
|
||||
1. 911 disclaimer is shown to the client
|
||||
2. repair.order created with priority=high + Monday-followup activity
|
||||
3. Page next on-call manager (lowest x_fc_on_call_priority among
|
||||
active users with x_fc_on_call=True)
|
||||
4. SMS + email sent; tokenized /repair/on-call/ack/<token> for ack
|
||||
5. 15-minute escalation cron pages next priority if first doesn't ack
|
||||
6. All actions logged to repair chatter
|
||||
|
||||
Phase 2 ships with priority-int sorting; Phase 4 will replace with proper
|
||||
shift scheduling (date ranges per on-call user).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionRepairOnCallService(models.AbstractModel):
|
||||
_name = 'fusion.repair.on.call.service'
|
||||
_description = 'Repair On-Call Paging Service'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUBLIC API
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def find_next_on_call(self, exclude_user_ids=None):
|
||||
"""Return the highest-priority active on-call user, or empty recordset."""
|
||||
exclude_user_ids = exclude_user_ids or []
|
||||
Users = self.env['res.users'].sudo()
|
||||
return Users.search([
|
||||
('x_fc_on_call', '=', True),
|
||||
('active', '=', True),
|
||||
('id', 'not in', exclude_user_ids),
|
||||
], order='x_fc_on_call_priority asc, id asc', limit=1)
|
||||
|
||||
@api.model
|
||||
def page_on_call(self, repair, force=False):
|
||||
"""Page the next on-call manager for the given repair.
|
||||
|
||||
Skips if outside business hours check disabled OR already paged
|
||||
unless force=True. Returns the paged user or empty recordset.
|
||||
"""
|
||||
repair.ensure_one()
|
||||
if not force and self._is_business_hours():
|
||||
_logger.info('On-call page skipped for %s - inside business hours',
|
||||
repair.name)
|
||||
return self.env['res.users']
|
||||
|
||||
# Don't re-page a repair that's already been paged in this cycle.
|
||||
already_paged = repair.x_fc_on_call_acknowledged_user_ids.ids
|
||||
target = self.find_next_on_call(exclude_user_ids=already_paged)
|
||||
if not target:
|
||||
self._notify_office_no_oncall(repair)
|
||||
return self.env['res.users']
|
||||
|
||||
token = secrets.token_urlsafe(20)
|
||||
repair.write({
|
||||
'x_fc_on_call_token': token,
|
||||
'x_fc_on_call_paged_user_id': target.id,
|
||||
'x_fc_on_call_paged_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
self._send_page_email(repair, target, token)
|
||||
self._post_chatter(repair, target)
|
||||
return target
|
||||
|
||||
@api.model
|
||||
def acknowledge(self, repair, user):
|
||||
"""Mark a repair's on-call page as acknowledged by `user`."""
|
||||
repair.ensure_one()
|
||||
repair.x_fc_on_call_acknowledged_user_ids = [(4, user.id)]
|
||||
repair.x_fc_on_call_acknowledged_at = fields.Datetime.now()
|
||||
repair.message_post(body=Markup(_(
|
||||
'On-call page <b>acknowledged</b> by %s.'
|
||||
)) % (user.name or user.login or ''))
|
||||
|
||||
@api.model
|
||||
def cron_escalate_unacknowledged(self):
|
||||
"""Cron: re-page the next priority for any repair whose first page
|
||||
is older than 15 minutes without acknowledgement."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
window_min = int(ICP.get_param(
|
||||
'fusion_repairs.on_call_escalate_minutes', '15'
|
||||
))
|
||||
except (ValueError, TypeError):
|
||||
window_min = 15
|
||||
cutoff = fields.Datetime.now() - timedelta(minutes=window_min)
|
||||
Repair = self.env['repair.order'].sudo()
|
||||
stale = Repair.search([
|
||||
('x_fc_on_call_paged_at', '!=', False),
|
||||
('x_fc_on_call_paged_at', '<=', cutoff),
|
||||
('x_fc_on_call_acknowledged_at', '=', False),
|
||||
('state', 'not in', ('done', 'cancel')),
|
||||
])
|
||||
for r in stale:
|
||||
already = r.x_fc_on_call_acknowledged_user_ids.ids + [
|
||||
r.x_fc_on_call_paged_user_id.id
|
||||
]
|
||||
self.page_on_call(r, force=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HELPERS
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _is_business_hours(self):
|
||||
"""True when within the company resource_calendar's working time."""
|
||||
cal = self.env.company.resource_calendar_id
|
||||
if not cal:
|
||||
return False # Treat "no calendar" as always after-hours so we always page.
|
||||
now = fields.Datetime.now()
|
||||
try:
|
||||
return bool(cal._work_intervals_batch(now, now)[False])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _send_page_email(self, repair, target, token):
|
||||
try:
|
||||
tpl = self.env.ref(
|
||||
'fusion_repairs.email_template_on_call_page',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if tpl:
|
||||
tpl.with_context(
|
||||
on_call_token=token,
|
||||
on_call_user=target,
|
||||
).send_mail(repair.id, force_send=False, email_values={
|
||||
'email_to': target.email or target.partner_id.email or '',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning('On-call page email failed for repair %s: %s',
|
||||
repair.name, e)
|
||||
|
||||
@api.model
|
||||
def _post_chatter(self, repair, target):
|
||||
repair.message_post(body=Markup(_(
|
||||
'After-hours <b>safety paged</b> %(name)s '
|
||||
'(priority %(p)s). Awaiting acknowledgement.'
|
||||
)) % {
|
||||
'name': target.name or target.login or '',
|
||||
'p': str(target.x_fc_on_call_priority or 99),
|
||||
})
|
||||
|
||||
@api.model
|
||||
def _notify_office_no_oncall(self, repair):
|
||||
_logger.error(
|
||||
'No on-call user configured (x_fc_on_call=True) - safety repair '
|
||||
'%s will queue for Monday with no page.',
|
||||
repair.name,
|
||||
)
|
||||
repair.message_post(body=Markup(_(
|
||||
'<span style="color:#c00"><b>WARNING:</b> No on-call user '
|
||||
'configured. This safety repair was queued but no one was paged. '
|
||||
'Configure x_fc_on_call on a manager.</span>'
|
||||
)))
|
||||
@@ -97,6 +97,39 @@ class RepairOrder(models.Model):
|
||||
'office has not yet authorised dispatching a technician.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ON-CALL PAGING (CL15)
|
||||
# Set when a safety repair is paged to the on-call manager. Allows
|
||||
# ack and the 15-minute escalation cron to roll forward to the next
|
||||
# priority if not acknowledged.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_on_call_token = fields.Char(
|
||||
string='On-Call Ack Token',
|
||||
copy=False,
|
||||
index=True,
|
||||
)
|
||||
x_fc_on_call_paged_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='On-Call Paged User',
|
||||
copy=False,
|
||||
index=True,
|
||||
)
|
||||
x_fc_on_call_paged_at = fields.Datetime(
|
||||
string='On-Call Paged At',
|
||||
copy=False,
|
||||
)
|
||||
x_fc_on_call_acknowledged_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
'fusion_repair_on_call_ack_rel',
|
||||
'repair_id', 'user_id',
|
||||
string='On-Call Acknowledgements',
|
||||
copy=False,
|
||||
)
|
||||
x_fc_on_call_acknowledged_at = fields.Datetime(
|
||||
string='Acknowledged At',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Maintenance contract back-link (Phase 3)
|
||||
x_fc_maintenance_contract_id = fields.Many2one(
|
||||
'fusion.repair.maintenance.contract',
|
||||
|
||||
60
fusion_repairs/models/repair_self_check_rule.py
Normal file
60
fusion_repairs/models/repair_self_check_rule.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Deterministic self-check rules.
|
||||
|
||||
Seeded per equipment category + symptom keyword combination. Used by
|
||||
fusion.repair.ai.service when:
|
||||
- AI is unavailable (fusion_api not installed / OpenAI down)
|
||||
- AI returns malformed / unsafe content
|
||||
- The category has no AI configured
|
||||
|
||||
Also rendered directly on the client portal when AI is disabled per spec.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionRepairSelfCheckRule(models.Model):
|
||||
_name = 'fusion.repair.self.check.rule'
|
||||
_description = 'Repair Self-Check Rule (deterministic fallback)'
|
||||
_order = 'category_id, sequence, id'
|
||||
|
||||
name = fields.Char(string='Title', required=True, translate=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
category_id = fields.Many2one(
|
||||
'fusion.repair.product.category',
|
||||
string='Equipment Category',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
symptom_keywords = fields.Char(
|
||||
string='Symptom Keywords',
|
||||
help='Comma-separated, lowercase. Empty matches any symptom.',
|
||||
)
|
||||
|
||||
instruction = fields.Text(
|
||||
string='Instruction',
|
||||
required=True,
|
||||
translate=True,
|
||||
help='What to ask the client to do. Plain English, <= 1 sentence.',
|
||||
)
|
||||
expected_result = fields.Text(
|
||||
string='Expected Result',
|
||||
required=True,
|
||||
translate=True,
|
||||
help='What success looks like ("alarm stops", "wheel spins freely").',
|
||||
)
|
||||
safety_note = fields.Text(
|
||||
string='Safety Note',
|
||||
translate=True,
|
||||
help='Optional warning shown in red below the instruction.',
|
||||
)
|
||||
84
fusion_repairs/report/qr_sticker_report.xml
Normal file
84
fusion_repairs/report/qr_sticker_report.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- 4-up sticker sheet on letter paper. Tweak grid in the QWeb template
|
||||
to match your label stock. -->
|
||||
<record id="action_report_qr_stickers" model="ir.actions.report">
|
||||
<field name="name">QR Stickers</field>
|
||||
<field name="model">fusion.repair.qr.sticker.wizard</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_repairs.report_qr_stickers</field>
|
||||
<field name="report_file">fusion_repairs.report_qr_stickers</field>
|
||||
<field name="print_report_name">'QR Stickers - %s' % (object.id)</field>
|
||||
</record>
|
||||
|
||||
<template id="report_qr_stickers">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="web.internal_layout">
|
||||
<div class="page">
|
||||
<style>
|
||||
.qr-sticker-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12mm;
|
||||
padding: 8mm;
|
||||
}
|
||||
.qr-sticker {
|
||||
width: 80mm;
|
||||
height: 50mm;
|
||||
border: 1px dashed #999;
|
||||
padding: 6mm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6mm;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.qr-sticker .qr-img img {
|
||||
width: 38mm; height: 38mm;
|
||||
}
|
||||
.qr-sticker .qr-info {
|
||||
font-family: sans-serif;
|
||||
font-size: 9pt;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.qr-sticker .qr-info .qr-title {
|
||||
font-weight: 700;
|
||||
font-size: 11pt;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
.qr-sticker .qr-info .qr-serial {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 8pt;
|
||||
}
|
||||
</style>
|
||||
<div class="qr-sticker-grid">
|
||||
<t t-foreach="docs[0].lot_ids" t-as="lot">
|
||||
<t t-set="url" t-value="docs[0].get_sticker_url(lot)"/>
|
||||
<t t-set="qr_uri" t-value="docs[0].get_qr_data_uri(url)"/>
|
||||
<div class="qr-sticker">
|
||||
<div class="qr-img">
|
||||
<img t-if="qr_uri" t-att-src="qr_uri" alt="QR"/>
|
||||
<span t-else="" style="font-size:7pt;color:#900;">QR lib missing</span>
|
||||
</div>
|
||||
<div class="qr-info">
|
||||
<div class="qr-title">Scan for service</div>
|
||||
<div>
|
||||
<t t-out="lot.product_id.display_name or ''"/>
|
||||
</div>
|
||||
<div class="qr-serial">
|
||||
SN <t t-out="lot.name or ''"/>
|
||||
</div>
|
||||
<div style="margin-top:2mm;font-size:7pt;color:#666;">
|
||||
Or visit:
|
||||
<t t-out="docs[0]._portal_base_url()"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -23,3 +23,6 @@ access_repair_order_repairs_user,Repair Order Repairs User Read/Write,repair.mod
|
||||
access_repair_order_repairs_manager,Repair Order Repairs Manager Full,repair.model_repair_order,group_fusion_repairs_manager,1,1,1,1
|
||||
access_technician_task_repairs_user,Technician Task Repairs User Schedule,fusion_tasks.model_fusion_technician_task,group_fusion_repairs_user,1,1,1,0
|
||||
access_technician_task_repairs_manager,Technician Task Repairs Manager Full,fusion_tasks.model_fusion_technician_task,group_fusion_repairs_manager,1,1,1,1
|
||||
access_repair_self_check_rule_user,Self-Check Rule User Read,model_fusion_repair_self_check_rule,group_fusion_repairs_user,1,0,0,0
|
||||
access_repair_self_check_rule_manager,Self-Check Rule Manager Full,model_fusion_repair_self_check_rule,group_fusion_repairs_manager,1,1,1,1
|
||||
access_qr_sticker_wizard_user,QR Sticker Wizard User Full,model_fusion_repair_qr_sticker_wizard,group_fusion_repairs_user,1,1,1,1
|
||||
|
||||
|
@@ -70,4 +70,10 @@
|
||||
action="action_repair_warranty_coverage"
|
||||
sequence="40"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_qr_stickers"
|
||||
name="Generate QR Stickers"
|
||||
parent="menu_fusion_repairs_configuration"
|
||||
action="action_qr_sticker_wizard"
|
||||
sequence="50"/>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -82,6 +82,50 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- On-call ack pages (CL15) -->
|
||||
<!-- ============================================================== -->
|
||||
<template id="portal_on_call_ack_ok" name="On-Call Page Acknowledged">
|
||||
<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">
|
||||
<i class="fa fa-check-circle fa-4x text-success mb-3"/>
|
||||
<h1>Page acknowledged</h1>
|
||||
<p class="lead text-muted">
|
||||
Thank you. <strong t-out="repair_name"/> is now your responsibility -
|
||||
no further pages will be sent for this request.
|
||||
</p>
|
||||
<a href="/web" class="btn btn-outline-secondary mt-3">Open Odoo</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_on_call_ack_invalid" name="On-Call Page Invalid">
|
||||
<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">
|
||||
<i class="fa fa-exclamation-triangle fa-3x text-warning mb-3"/>
|
||||
<h1>Link not valid</h1>
|
||||
<p class="lead text-muted">
|
||||
This on-call acknowledgement link is no longer valid. It may have been
|
||||
acknowledged already or the page expired. Open Odoo and look up the
|
||||
repair in the Fusion Repairs dashboard.
|
||||
</p>
|
||||
<a href="/web" class="btn btn-outline-secondary mt-3">Open Odoo</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Invalid token -->
|
||||
<!-- ============================================================== -->
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
from . import repair_intake_wizard
|
||||
from . import repair_visit_report_wizard
|
||||
from . import qr_sticker_wizard
|
||||
|
||||
79
fusion_repairs/wizard/qr_sticker_wizard.py
Normal file
79
fusion_repairs/wizard/qr_sticker_wizard.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""QR sticker generator (CL17).
|
||||
|
||||
Generates a printable PDF with one sticker per selected serial number.
|
||||
Each sticker has a QR code linking to /repair?sn=<serial>. Stick on the
|
||||
equipment; client scans -> public client portal opens with the unit
|
||||
pre-filled.
|
||||
|
||||
Accessible from stock.lot via a server action AND as a standalone wizard
|
||||
under Fusion Repairs > Configuration.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FusionRepairQRStickerWizard(models.TransientModel):
|
||||
_name = 'fusion.repair.qr.sticker.wizard'
|
||||
_description = 'QR Sticker Generator Wizard'
|
||||
|
||||
lot_ids = fields.Many2many(
|
||||
'stock.lot',
|
||||
string='Serials',
|
||||
help='One sticker will be generated per serial.',
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Filter by Product',
|
||||
help='Optional - limits the serial picker to lots of this product.',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
def action_generate(self):
|
||||
self.ensure_one()
|
||||
if not self.lot_ids:
|
||||
raise UserError(_('Select at least one serial number to print stickers for.'))
|
||||
return self.env.ref('fusion_repairs.action_report_qr_stickers') \
|
||||
.report_action(self)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers used by the QWeb report
|
||||
# ------------------------------------------------------------------
|
||||
def _portal_base_url(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
base = (ICP.get_param('web.base.url', '') or '').rstrip('/')
|
||||
path = ICP.get_param('fusion_repairs.client_portal_url', '/repair') or '/repair'
|
||||
return base + path
|
||||
|
||||
def get_sticker_url(self, lot):
|
||||
"""Return the full URL that the QR code on this sticker encodes."""
|
||||
url = self._portal_base_url()
|
||||
serial = (lot.name or '').strip()
|
||||
return f"{url}?sn={serial}" if serial else url
|
||||
|
||||
def get_qr_data_uri(self, url, size=180):
|
||||
"""Return a base64 PNG data URI for the QR code of the given URL.
|
||||
|
||||
Uses the `qrcode` library if available (it's a transitive dep of many
|
||||
Odoo modules); falls back to a simple ASCII placeholder if not so the
|
||||
report still renders (with a warning).
|
||||
"""
|
||||
try:
|
||||
import qrcode
|
||||
img = qrcode.make(url)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
b64 = base64.b64encode(buf.getvalue()).decode('ascii')
|
||||
return f"data:image/png;base64,{b64}"
|
||||
except Exception:
|
||||
return ""
|
||||
39
fusion_repairs/wizard/qr_sticker_wizard_views.xml
Normal file
39
fusion_repairs/wizard/qr_sticker_wizard_views.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_qr_sticker_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.repair.qr.sticker.wizard.form</field>
|
||||
<field name="model">fusion.repair.qr.sticker.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Generate QR Stickers">
|
||||
<sheet>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-qrcode me-2"/>
|
||||
Each selected serial number prints as one sticker. Affix to
|
||||
the equipment so the client can scan and submit a service request
|
||||
without typing the serial.
|
||||
</div>
|
||||
<group>
|
||||
<field name="product_id" options="{'no_create': True}"/>
|
||||
<field name="lot_ids" widget="many2many_tags"
|
||||
domain="[('product_id', '=?', product_id)]"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_generate" type="object"
|
||||
string="Print Stickers" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_qr_sticker_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Generate QR Stickers</field>
|
||||
<field name="res_model">fusion.repair.qr.sticker.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user