diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index 681a0cfb..801b76f8 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -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', ], diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py index 9ce7cbe9..f52cf17e 100644 --- a/fusion_repairs/controllers/portal_client_repair.py +++ b/fusion_repairs/controllers/portal_client_repair.py @@ -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/", 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, + }) diff --git a/fusion_repairs/data/ir_cron_data.xml b/fusion_repairs/data/ir_cron_data.xml index 4dde0416..c73b7009 100644 --- a/fusion_repairs/data/ir_cron_data.xml +++ b/fusion_repairs/data/ir_cron_data.xml @@ -15,5 +15,20 @@ + + + Fusion Repairs: Escalate unacknowledged on-call pages + + code + model.cron_escalate_unacknowledged() + + 5 + minutes + + + + diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index afdfe693..12ad7d93 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -55,6 +55,53 @@ + + + + + Repair: On-Call Safety Page + + [SAFETY PAGE] {{ object.partner_id.name or 'Unknown' }} - {{ object.name or 'n/a' }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + +
+
+
+

+ URGENT - SAFETY PAGE +

+

Safety service call requires response

+

+ A client just submitted a safety-flagged service request via the + . + You have been paged as the on-call manager. +

+ + + + + + + + + +
Reference
Client
Phone
Equipment
+ +

+ If you do not acknowledge within 15 minutes, the next on-call + priority will be paged automatically. +

+
+
+
+ +
+ diff --git a/fusion_repairs/data/self_check_data.xml b/fusion_repairs/data/self_check_data.xml new file mode 100644 index 00000000..b74123dd --- /dev/null +++ b/fusion_repairs/data/self_check_data.xml @@ -0,0 +1,162 @@ + + + + + + + + Hospital Bed - No Power + + 10 + won't move,dead,no power,no response + Check the bed is plugged in and the outlet has power - try plugging a phone charger into the same outlet to confirm. + Bed responds when controls are pressed. + + + Hospital Bed - Slow / Sluggish + + 20 + slow,sluggish + Unplug the bed for 30 seconds then plug it back in. + Movement returns to normal speed. + + + Hospital Bed - Remote Unresponsive + + 30 + remote,controller + Replace the remote batteries with fresh AAA batteries. + Remote lights up and bed responds. + + + Hospital Bed - Alarm + + 40 + beep,alarm,alert + Check both side rails are fully locked in the raised position. + Alarm stops. + + + + + Wheelchair - Brake + + 10 + brake,stop + Push the brake lever fully to the locked position and listen for a click. + Brake holds wheel firmly. + + + Wheelchair - Hard to Push + + 20 + hard to push,drag,slow + Check both tires for full inflation - firm to thumb pressure. + Wheelchair rolls freely. + + + + + Power Wheelchair - No Power + + 10 + won't turn on,dead,no power,battery + Confirm the battery indicator shows charge and the key switch is in the ON position. + Display lights up. + + + Power Wheelchair - Error Code + + 20 + error,flashing,code + Note the error code shown on the joystick display, then turn off and back on after 30 seconds. + Error clears or a specific code is captured. + + + + + Stairlift - Won't Move + + 10 + won't move,stuck + Check the seat is fully rotated to the forward position and the seatbelt is fastened. + Stairlift responds. + + + Stairlift - Stops Midway + + 20 + stops midway,halts + Check the track for items blocking the sensors - toys, slippers, debris. + Stairlift completes its travel. + + + Stairlift - Call Station Unresponsive + + 30 + remote,call station + Replace the remote / call-station batteries with fresh batteries. + Call station responds. + + + + + Porch Lift - Won't Move + + 10 + won't move,dead + Check all gate and door safety switches are fully closed. + Lift responds. + + + Porch Lift - Sticky Controls + + 20 + sticky,stuck button + If outdoors, gently wipe the controls with a dry cloth and let dry. + Controls respond. + + + + + Walker - Wheel Stuck + + 10 + wheel stick,won't roll + Check for hair or debris wrapped around the wheel axle. + Wheel spins freely. + + + Rollator - Brake Won't Lock + + 10 + brake won't lock,brake loose + Push the brake lever fully down until you feel a click. + Brake holds. + + + + + Mattress - Deflated + + 10 + deflated,flat,soft + Confirm the pump is plugged in, powered on, and the hose is firmly attached. + Mattress inflates. + + + Mattress - Alarm + + 20 + alarm,beep + Check the pump display for the error code shown, then restart the pump by unplugging for 30 seconds. + Alarm clears. + + + + diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index a166e8b2..9733b7dd 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -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 diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py index 00feedc4..324580a7 100644 --- a/fusion_repairs/models/intake_service.py +++ b/fusion_repairs/models/intake_service.py @@ -179,6 +179,13 @@ class FusionRepairIntakeService(models.AbstractModel): 'Created in Quote Only 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) diff --git a/fusion_repairs/models/repair_ai_service.py b/fusion_repairs/models/repair_ai_service.py new file mode 100644 index 00000000..76bd5362 --- /dev/null +++ b/fusion_repairs/models/repair_ai_service.py @@ -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]) diff --git a/fusion_repairs/models/repair_on_call_service.py b/fusion_repairs/models/repair_on_call_service.py new file mode 100644 index 00000000..d6f3d620 --- /dev/null +++ b/fusion_repairs/models/repair_on_call_service.py @@ -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/ 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 acknowledged 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 safety paged %(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(_( + 'WARNING: No on-call user ' + 'configured. This safety repair was queued but no one was paged. ' + 'Configure x_fc_on_call on a manager.' + ))) diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index 4ceecae3..19f3ac51 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -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', diff --git a/fusion_repairs/models/repair_self_check_rule.py b/fusion_repairs/models/repair_self_check_rule.py new file mode 100644 index 00000000..0a5bc089 --- /dev/null +++ b/fusion_repairs/models/repair_self_check_rule.py @@ -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.', + ) diff --git a/fusion_repairs/report/qr_sticker_report.xml b/fusion_repairs/report/qr_sticker_report.xml new file mode 100644 index 00000000..9f73f1fd --- /dev/null +++ b/fusion_repairs/report/qr_sticker_report.xml @@ -0,0 +1,84 @@ + + + + + + QR Stickers + fusion.repair.qr.sticker.wizard + qweb-pdf + fusion_repairs.report_qr_stickers + fusion_repairs.report_qr_stickers + 'QR Stickers - %s' % (object.id) + + + + + diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 10da9d95..15c9727b 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -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 diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index 0d370453..4f671813 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -70,4 +70,10 @@ action="action_repair_warranty_coverage" sequence="40"/> + + diff --git a/fusion_repairs/views/portal_maintenance_templates.xml b/fusion_repairs/views/portal_maintenance_templates.xml index 4870e380..3b174e12 100644 --- a/fusion_repairs/views/portal_maintenance_templates.xml +++ b/fusion_repairs/views/portal_maintenance_templates.xml @@ -82,6 +82,50 @@ + + + + + + + diff --git a/fusion_repairs/wizard/__init__.py b/fusion_repairs/wizard/__init__.py index e2dedaa5..822ed47b 100644 --- a/fusion_repairs/wizard/__init__.py +++ b/fusion_repairs/wizard/__init__.py @@ -4,3 +4,4 @@ from . import repair_intake_wizard from . import repair_visit_report_wizard +from . import qr_sticker_wizard diff --git a/fusion_repairs/wizard/qr_sticker_wizard.py b/fusion_repairs/wizard/qr_sticker_wizard.py new file mode 100644 index 00000000..7c0f4930 --- /dev/null +++ b/fusion_repairs/wizard/qr_sticker_wizard.py @@ -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=. 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 "" diff --git a/fusion_repairs/wizard/qr_sticker_wizard_views.xml b/fusion_repairs/wizard/qr_sticker_wizard_views.xml new file mode 100644 index 00000000..8af12f6b --- /dev/null +++ b/fusion_repairs/wizard/qr_sticker_wizard_views.xml @@ -0,0 +1,39 @@ + + + + + fusion.repair.qr.sticker.wizard.form + fusion.repair.qr.sticker.wizard + +
+ + + + + + + +
+
+ +
+
+ + + Generate QR Stickers + fusion.repair.qr.sticker.wizard + form + new + + +