diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index 801b76f8..a8edb017 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.1.2.0', + 'version': '19.0.1.2.2', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ diff --git a/fusion_repairs/controllers/portal_client_repair.py b/fusion_repairs/controllers/portal_client_repair.py index f52cf17e..e36450a6 100644 --- a/fusion_repairs/controllers/portal_client_repair.py +++ b/fusion_repairs/controllers/portal_client_repair.py @@ -70,17 +70,19 @@ def _e164_clean(phone): class ClientRepairPortal(http.Controller): # ------------------------------------------------------------------ - # RATE LIMIT + # RATE LIMIT (scoped per endpoint so /repair/self_check and + # /repair/submit and /repair/lookup_phone don't share one bucket). # ------------------------------------------------------------------ - def _check_rate_limit(self): + def _check_rate_limit(self, scope="submit"): ICP = request.env["ir.config_parameter"].sudo() + # Scope-specific cap if configured, falls back to the global. try: limit = int(ICP.get_param( - "fusion_repairs.client_portal_rate_limit_per_hour", "10" + f"fusion_repairs.client_portal_rate_limit_per_hour_{scope}", + ICP.get_param("fusion_repairs.client_portal_rate_limit_per_hour", "10"), )) except (ValueError, TypeError): limit = 10 - # Use remote_addr from the proxy header if present. ip = ( request.httprequest.headers.get("X-Forwarded-For") or request.httprequest.remote_addr @@ -88,12 +90,12 @@ class ClientRepairPortal(http.Controller): ) ip = ip.split(",")[0].strip() bucket = _now_hour_bucket() - key = f"{ip}:{bucket}" - # Prune old buckets (cheap - dict is small). + key = f"{scope}:{ip}:{bucket}" + # Prune old buckets across all scopes (cheap - dict is small). + suffix = f":{bucket}" for k in list(_RATE_LIMIT_BUCKET.keys()): - if not k.endswith(f":{bucket}"): + if not k.endswith(suffix): _RATE_LIMIT_BUCKET.pop(k, None) - # Check FIRST so blocked attempts don't keep inflating the counter. if _RATE_LIMIT_BUCKET.get(key, 0) >= limit: return True # blocked _RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1 @@ -128,7 +130,7 @@ class ClientRepairPortal(http.Controller): @http.route("/repair/lookup_phone", type="jsonrpc", auth="public", website=True) def repair_lookup_phone(self, phone=None, **kw): - if self._check_rate_limit(): + if self._check_rate_limit(scope="lookup"): return {"error": "rate_limited"} cleaned = _e164_clean(phone) if len(cleaned) < 7: @@ -154,7 +156,7 @@ class ClientRepairPortal(http.Controller): request.httprequest.remote_addr) return request.redirect("/repair/new?error=spam") - if self._check_rate_limit(): + if self._check_rate_limit(scope="submit"): return request.redirect("/repair/new?error=rate_limited") # Required fields. @@ -262,7 +264,7 @@ class ClientRepairPortal(http.Controller): website=True) def repair_self_check(self, category_id=None, symptoms=None, urgency=None, **kw): - if self._check_rate_limit(): + if self._check_rate_limit(scope="self_check"): return {"error": "rate_limited"} if not symptoms: symptoms = [] @@ -279,6 +281,9 @@ class ClientRepairPortal(http.Controller): # ------------------------------------------------------------------ # CL15: on-call acknowledgement endpoint + # Only the paged user OR a Repairs Manager can ack - prevents arbitrary + # internal users (or someone with a forwarded mail) from acknowledging + # a page they were never paged for. # ------------------------------------------------------------------ @http.route("/repair/on-call/ack/", type="http", auth="user", website=True, sitemap=False) @@ -289,8 +294,21 @@ class ClientRepairPortal(http.Controller): return request.render( "fusion_repairs.portal_on_call_ack_invalid", {}, ) + user = request.env.user + is_paged_user = user == repair.x_fc_on_call_paged_user_id + is_manager = user.has_group("fusion_repairs.group_fusion_repairs_manager") + if not (is_paged_user or is_manager): + _logger.warning( + "On-call ack denied for repair %s - user %s is not the paged " + "user (%s) and not a Repairs Manager.", + repair.name, user.login, + repair.x_fc_on_call_paged_user_id.login or "(none)", + ) + 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) + Service.acknowledge(repair, user) return request.render("fusion_repairs.portal_on_call_ack_ok", { "repair_name": repair.name, }) diff --git a/fusion_repairs/data/repair_product_category_data.xml b/fusion_repairs/data/repair_product_category_data.xml index 52d36e35..8a34070c 100644 --- a/fusion_repairs/data/repair_product_category_data.xml +++ b/fusion_repairs/data/repair_product_category_data.xml @@ -25,6 +25,7 @@ 30 fa-wheelchair Power wheelchairs, scooters, and powered mobility devices. + diff --git a/fusion_repairs/data/self_check_data.xml b/fusion_repairs/data/self_check_data.xml index b74123dd..4c49e4df 100644 --- a/fusion_repairs/data/self_check_data.xml +++ b/fusion_repairs/data/self_check_data.xml @@ -41,6 +41,14 @@ Check both side rails are fully locked in the raised position. Alarm stops. + + Hospital Bed - One Section Won't Move + + 50 + one section,won't lift,stuck + Check nothing is caught under the bed or jamming the mechanism (sheets, blankets, cords). + Section moves freely. + @@ -59,6 +67,22 @@ Check both tires for full inflation - firm to thumb pressure. Wheelchair rolls freely. + + Wheelchair - Wobbly Wheel + + 30 + wobble,loose wheel + Try turning the axle nut gently by hand to feel if it is snug. + Wheel feels firm with no play. + + + Wheelchair - Footrest Loose + + 40 + footrest,footplate + Slide the footrest fully into its housing until you hear a click. + Footrest feels secure. + @@ -77,6 +101,14 @@ 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. + + Power Wheelchair - One Side Weaker + + 30 + one side weaker,pulls + Charge the batteries fully overnight before testing again. + Both sides equal power after a full charge. + @@ -103,6 +135,14 @@ Replace the remote / call-station batteries with fresh batteries. Call station responds. + + Stairlift - Beeping / Alarm + + 40 + beep,alarm + Confirm the seat swivel lock is engaged in the down position. + Beeping stops. + @@ -121,6 +161,15 @@ If outdoors, gently wipe the controls with a dry cloth and let dry. Controls respond. + + Porch Lift - Won't Stop at Floor + + 30 + won't stop,overshoot + Note exactly which floor it stops at - do not attempt repeat use. + Information captured for technician. + Do not use the lift again until a technician inspects it. + @@ -131,6 +180,15 @@ Check for hair or debris wrapped around the wheel axle. Wheel spins freely. + + Walker - Frame Wobbles + + 20 + wobble,loose + Check all height adjustment pins are fully engaged through both holes. + Frame feels solid. + Wobbly walkers cause falls - stop using until repaired if movement persists. + Rollator - Brake Won't Lock @@ -139,6 +197,15 @@ Push the brake lever fully down until you feel a click. Brake holds. + + Rollator - Seat Loose + + 20 + seat loose + Tighten the seat knobs by hand until firm. + Seat feels secure. + Do not sit on a loose rollator seat - fall risk. + @@ -157,6 +224,22 @@ Check the pump display for the error code shown, then restart the pump by unplugging for 30 seconds. Alarm clears. + + Mattress - Hissing / Leak + + 30 + hiss,leak + Listen at the valve - push the valve cap in firmly to ensure it is sealed. + Hissing stops. + + + Mattress - Not Heating + + 40 + cold,won't heat + Confirm the heat dial is set above zero and allow 15 minutes to warm. + Mattress feels warm. + diff --git a/fusion_repairs/models/repair_ai_service.py b/fusion_repairs/models/repair_ai_service.py index 76bd5362..9e37c96a 100644 --- a/fusion_repairs/models/repair_ai_service.py +++ b/fusion_repairs/models/repair_ai_service.py @@ -32,16 +32,26 @@ 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'\b(stop|should\s+stop)\s+using\b', re.I), re.compile(r'\bconsult\s+(your|a)\s+(doctor|physician|nurse)\b', re.I), + re.compile(r'\b(blood\s+pressure|heart\s+rate|pulse|oxygen)\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', +# Universal hard-escalate: ANY equipment category - fire / smoke / sparks / +# burning / injury / trapped is always an immediate escalation. Word +# boundaries prevent "unhurt" matching "hurt" and "fireman" matching "fire". +UNIVERSAL_ESCALATION_RE = re.compile( + r'\b(fire|smoke|burning|spark|injur(y|ed)|hurt|bleeding|trapped)\b', + re.I, +) + +# Category-specific safety symptoms - only fire if the category is flagged +# safety_critical=True on fusion.repair.product.category (stairlifts, +# porch lifts, power wheelchairs). "won.?t" handles both "won't" and "wont". +SAFETY_SYMPTOMS_RE = re.compile( + r"\b(stuck|motor|brake\s*fail|won.?t\s*stop|overshoot)\b", + re.I, ) @@ -121,12 +131,15 @@ class FusionRepairAIService(models.AbstractModel): 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')): + text = ' '.join(symptoms) + # Universal: fire / smoke / spark / burning / injury / trapped escalate + # regardless of equipment category. Electrical fire on a hospital bed + # is exactly as urgent as on a stairlift. + if UNIVERSAL_ESCALATION_RE.search(text): + return True + # Category-specific: 'stuck', 'motor', 'brake fail', etc. only escalate + # on safety-critical categories (stairlifts, porch lifts, power chairs). + if category and category.safety_critical and SAFETY_SYMPTOMS_RE.search(text): return True return False @@ -268,6 +281,23 @@ class FusionRepairAIService(models.AbstractModel): # ------------------------------------------------------------------ # DETERMINISTIC FALLBACK # ------------------------------------------------------------------ + @api.model + def _normalise(self, text): + """Strip punctuation + lowercase so 'wont move' matches 'won't move' + and vice versa. + + IMPORTANT: apostrophes are REMOVED (not replaced with space), so + "won't" -> "wont" matches user input "wont" (without apostrophe). + Other punctuation collapses to a single space. + """ + s = (text or "").lower() + # Remove ALL apostrophe variants (straight + curly) so contraction + # forms collide with apostrophe-less forms. + for apos in ("'", "\u2019", "\u2018", "\u02bc"): + s = s.replace(apos, "") + # Everything else non-alphanumeric -> single space. + return re.sub(r"[^a-z0-9 ]+", " ", s) + @api.model def _deterministic_fallback(self, category, symptoms): """Look up fusion.repair.self.check.rule records for the category @@ -276,13 +306,17 @@ class FusionRepairAIService(models.AbstractModel): Rule = self.env['fusion.repair.self.check.rule'].sudo() steps = [] if category: - haystack = ' '.join(symptoms).lower() + haystack = self._normalise(' '.join(symptoms)) 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()] + kws = [ + self._normalise(k) + 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 '', diff --git a/fusion_repairs/models/repair_on_call_service.py b/fusion_repairs/models/repair_on_call_service.py index d6f3d620..6cc133e5 100644 --- a/fusion_repairs/models/repair_on_call_service.py +++ b/fusion_repairs/models/repair_on_call_service.py @@ -39,22 +39,33 @@ class FusionRepairOnCallService(models.AbstractModel): # 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.""" + def find_next_on_call(self, exclude_user_ids=None, company_id=None): + """Return the highest-priority active on-call user, or empty recordset. + + Multi-company aware: when `company_id` is supplied, restricts to users + who belong to that company. + """ exclude_user_ids = exclude_user_ids or [] Users = self.env['res.users'].sudo() - return Users.search([ + domain = [ ('x_fc_on_call', '=', True), ('active', '=', True), ('id', 'not in', exclude_user_ids), - ], order='x_fc_on_call_priority asc, id asc', limit=1) + ] + if company_id: + domain.append(('company_ids', 'in', company_id)) + return Users.search( + domain, 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. + - Excludes anyone already acknowledged this cycle. + - Excludes the currently paged user (cron escalates to the NEXT priority). + - Skips during business hours unless force=True. + - Posts truthful chatter (different line on email send failure). """ repair.ensure_one() if not force and self._is_business_hours(): @@ -62,9 +73,16 @@ class FusionRepairOnCallService(models.AbstractModel): 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) + # CRITICAL: also exclude the currently-paged user so cron escalation + # actually moves to the NEXT priority instead of re-paging the same + # person forever. + exclude = set(repair.x_fc_on_call_acknowledged_user_ids.ids) + if repair.x_fc_on_call_paged_user_id: + exclude.add(repair.x_fc_on_call_paged_user_id.id) + target = self.find_next_on_call( + exclude_user_ids=list(exclude), + company_id=repair.company_id.id, + ) if not target: self._notify_office_no_oncall(repair) return self.env['res.users'] @@ -76,8 +94,15 @@ class FusionRepairOnCallService(models.AbstractModel): 'x_fc_on_call_paged_at': fields.Datetime.now(), }) - self._send_page_email(repair, target, token) - self._post_chatter(repair, target) + sent_ok = self._send_page_email(repair, target, token) + if sent_ok: + self._post_chatter(repair, target) + else: + # Truthful chatter when SMTP fails so the office can react. + repair.message_post(body=Markup(_( + 'Safety paged %(name)s but the page email failed to send. ' + 'Verify SMTP and retry, or contact the on-call manager directly.' + )) % {'name': target.name or target.login or ''}) return target @api.model @@ -109,10 +134,9 @@ class FusionRepairOnCallService(models.AbstractModel): ('x_fc_on_call_acknowledged_at', '=', False), ('state', 'not in', ('done', 'cancel')), ]) + # page_on_call now excludes the currently-paged user internally + # (see exclude set), so a plain call escalates to the next priority. 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) # ------------------------------------------------------------------ @@ -132,21 +156,30 @@ class FusionRepairOnCallService(models.AbstractModel): @api.model def _send_page_email(self, repair, target, token): + """Send the page email, return True on success, False on failure. + + force_send=True because this is the single most time-critical email + in the module - mail queue latency would defeat the point. + """ 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 '', - }) + if not tpl: + _logger.warning('On-call email template missing - cannot page %s', target.login) + return False + tpl.with_context( + on_call_token=token, + on_call_user=target, + ).send_mail(repair.id, force_send=True, email_values={ + 'email_to': target.email or target.partner_id.email or '', + }) + return True except Exception as e: _logger.warning('On-call page email failed for repair %s: %s', repair.name, e) + return False @api.model def _post_chatter(self, repair, target): @@ -170,3 +203,24 @@ class FusionRepairOnCallService(models.AbstractModel): 'configured. This safety repair was queued but no one was paged. ' 'Configure x_fc_on_call on a manager.' ))) + # Also send a real email to the company's office notification + # recipients so this doesn't get lost in chatter at 11 PM Saturday. + company_sudo = repair.company_id.sudo() + recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False) + emails = [p.email for p in (recipients or []) if p.email] + if not emails: + return + try: + self.env['mail.mail'].sudo().create({ + 'subject': '[CRITICAL] No on-call user configured - %s' % repair.name, + 'body_html': ( + '

Safety repair %s was just submitted ' + 'but no on-call user is configured ' + '(x_fc_on_call=True). No one was paged.

' + '

Set the flag on at least one manager so the next ' + 'after-hours safety call is paged.

' + ) % repair.name, + 'email_to': ','.join(emails), + }).send() + except Exception as e: + _logger.warning('Failed to send no-on-call office alert: %s', e) diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index 19f3ac51..d11f0cb1 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -130,6 +130,11 @@ class RepairOrder(models.Model): copy=False, ) + _on_call_token_unique = models.Constraint( + 'unique(x_fc_on_call_token)', + 'On-call acknowledgement tokens must be unique.', + ) + # Maintenance contract back-link (Phase 3) x_fc_maintenance_contract_id = fields.Many2one( 'fusion.repair.maintenance.contract', diff --git a/fusion_repairs/report/qr_sticker_report.xml b/fusion_repairs/report/qr_sticker_report.xml index 9f73f1fd..7f7a3528 100644 --- a/fusion_repairs/report/qr_sticker_report.xml +++ b/fusion_repairs/report/qr_sticker_report.xml @@ -52,28 +52,30 @@ }
- - - -
-
- QR - QR lib missing -
-
-
Scan for service
-
- + + + + +
+
+ QR + QR lib missing
-
- SN -
-
- Or visit: - +
+
Scan for service
+
+ +
+
+ SN +
+
+ Or visit: + +
-
+
diff --git a/fusion_repairs/wizard/qr_sticker_wizard.py b/fusion_repairs/wizard/qr_sticker_wizard.py index 7c0f4930..a1e18ce8 100644 --- a/fusion_repairs/wizard/qr_sticker_wizard.py +++ b/fusion_repairs/wizard/qr_sticker_wizard.py @@ -15,10 +15,13 @@ under Fusion Repairs > Configuration. import base64 import io +import logging from odoo import _, api, fields, models from odoo.exceptions import UserError +_logger = logging.getLogger(__name__) + class FusionRepairQRStickerWizard(models.TransientModel): _name = 'fusion.repair.qr.sticker.wizard' @@ -75,5 +78,6 @@ class FusionRepairQRStickerWizard(models.TransientModel): img.save(buf, format='PNG') b64 = base64.b64encode(buf.getvalue()).decode('ascii') return f"data:image/png;base64,{b64}" - except Exception: + except Exception as e: + _logger.warning('QR sticker generation failed for %s: %s', url, e) return ""