From c8bcad1579c6a7dc7c124529add3a21ac346f852 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 8 Sep 2026 08:23:22 -0700 Subject: [PATCH 1/2] RG-T55 RMS records analytics dashboards --- .../Areas/User/Records/Records.ar.resx | 113 +++ .../Areas/User/Records/Records.de.resx | 113 +++ .../Areas/User/Records/Records.el.resx | 113 +++ .../Areas/User/Records/Records.en.resx | 113 +++ .../Areas/User/Records/Records.es.resx | 113 +++ .../Areas/User/Records/Records.fr.resx | 113 +++ .../Areas/User/Records/Records.it.resx | 113 +++ .../Areas/User/Records/Records.pl.resx | 113 +++ .../Areas/User/Records/Records.sv.resx | 113 +++ .../Areas/User/Records/Records.uk.resx | 113 +++ Core/Resgrid.Model/FeatureFlagKeys.cs | 3 + .../Records/RecordsAnalyticsContracts.cs | 403 ++++++++++ .../Records/RecordsPreventionContracts.cs | 8 +- .../Repositories/IRmsIncidentRepositories.cs | 9 + .../IRmsPreventionRepositories.cs | 8 + .../Repositories/IRmsRepositories.cs | 15 + .../Services/IRecordsAnalyticsService.cs | 27 + .../Records/RecordsAnalyticsService.cs | 736 ++++++++++++++++++ .../Records/RecordsInvestigationsService.cs | 20 +- .../Records/RecordsPreventionGate.cs | 2 +- Core/Resgrid.Services/ServicesModule.cs | 4 +- .../Migrations/M0187_AddRmsAnalyticsFlag.cs | 23 + ...M0188_AddRmsAccessAuditCorrelationIndex.cs | 27 + .../Migrations/M0187_AddRmsAnalyticsFlagPg.cs | 23 + ...188_AddRmsAccessAuditCorrelationIndexPg.cs | 27 + .../RmsIncidentRepositories.cs | 20 + .../RmsPreventionRepositories.cs | 25 + .../RmsRepositories.cs | 81 +- .../TranslationCompletenessTests.cs | 7 +- .../Rms/RecordsAnalyticsServiceTests.cs | 330 ++++++++ .../Rms/RecordsInvestigationsServiceTests.cs | 24 + .../Rms/RmsIdentifierPinTests.cs | 4 +- Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs | 4 + .../v4/RecordAnalyticsController.cs | 81 ++ .../v4/Records/RecordsAnalyticsApiModels.cs | 13 + .../Resgrid.Web.Services.xml | 23 + .../Controllers/RecordsAnalyticsController.cs | 101 +++ .../User/Controllers/RecordsController.cs | 1 + .../Controllers/RecordsQualityController.cs | 2 +- .../Records/RecordsAnalyticsViewModels.cs | 78 ++ .../User/Models/Records/RecordsViewModels.cs | 2 + .../User/Views/RecordHydrants/Index.cshtml | 11 +- .../Views/RecordInvestigations/Details.cshtml | 2 +- .../Areas/User/Views/Records/Index.cshtml | 6 +- .../RecordsAnalytics/Accreditation.cshtml | 122 +++ .../RecordsAnalytics/CommunityRisk.cshtml | 76 ++ .../User/Views/RecordsAnalytics/Index.cshtml | 71 ++ .../ResponsePerformance.cshtml | 46 ++ .../Views/RecordsAnalytics/Workload.cshtml | 53 ++ .../RecordsAnalytics/_AnalyticsCounts.cshtml | 34 + .../RecordsAnalytics/_AnalyticsShell.cshtml | 77 ++ .../_AnalyticsTimeRows.cshtml | 38 + .../User/Views/Shared/_Navigation.cshtml | 7 +- 53 files changed, 3790 insertions(+), 14 deletions(-) create mode 100644 Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs create mode 100644 Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs create mode 100644 Core/Resgrid.Services/Records/RecordsAnalyticsService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0187_AddRmsAnalyticsFlag.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0188_AddRmsAccessAuditCorrelationIndex.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0187_AddRmsAnalyticsFlagPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0188_AddRmsAccessAuditCorrelationIndexPg.cs create mode 100644 Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Accreditation.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/CommunityRisk.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/ResponsePerformance.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Workload.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index fc08b209..04dd7be6 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1833,4 +1833,117 @@ قاطع الماء ملاحظات إمداد المياه تشغيلات Workflow + الاعتماد + نشطة الآن + الأنشطة + كل مجموعات المحطات + التعديلات + مقدمة + الجمهور المستهدف + المتوسط + بانتظار المراجعة + حسب نوع النداء + حسب فئة التدفق + حسب ساعة اليوم + حسب نوع الحادث + حسب الصنف + حسب الشهر + حسب الشخص + حسب البرنامج + حسب الخطورة + حسب المصدر + حسب مجموعة المحطة + حسب النوع + ملغاة + مخاطر المجتمع + مكتملة + مشروطة + مصححة + العدد + الحد من مخاطر المجتمع + متوسط الأيام من الجدولة إلى الإكمال + متوسط الأيام حتى التصحيح + متوسط الأيام من الطلب إلى الإصدار + مرفوضة + مصعّدة + الملخص التنفيذي + منتهية + راسبة + أول وصول + اختبارات التدفق في الفترة + من + تغطي الأرقام السجلات التي يمكنك فتحها فقط؛ يرى مسؤولو الإدارة إجماليات الإدارة. + مواد خطرة في الموقع + التحليلات + الأرقام الرئيسية + السجلات حسب يوم الأسبوع والساعة + الساعات + الساعات لكل عضو + في الخدمة + تقارير الحوادث + الحوادث + تم تفتيشها خلال 12 شهرًا + لوحات تُحسب عند الطلب من السجلات النهائية. المستخدم المقيّد بمجموعة يرى فقط السجلات التي يمكنه فتحها؛ مسؤولو الإدارة يرون الإدارة كاملة. + صادرة + دورة الحياة + الحد الأقصى + الوسيط + وسيط الساعات حتى الإنهاء + وسيط مدة المراجعة (س) + الأعضاء المدرَّبون + هذه الوحدة غير مفعّلة للإدارة. + شاغلون يحتاجون مساعدة + معدل قبول NERIS + مقبولة في NERIS + مرفوضة في NERIS + لا توجد سجلات نهائية في هذه الفترة. + بدون رشاشات + ملف مخاطر المنشآت + الوقت في الموقع + مكتملة في الموعد + المسودات المفتوحة + مفتوحة الآن + المخالفات المفتوحة + مفتوحة + الالتزامات المتأخرة الآن + المئين 90 + معدل النجاح + ناجحة + ساعات الأفراد + الفترة السابقة + قوائم الانتظار والالتزامات + ساعات السجلات + السجلات النهائية + السجلات المقروءة + سجلات بأوقات الوحدات + إعادة تفتيش مطلوبة + أداء الاستجابة + ملخص الاستجابة + استجابات الوحدات + معدل الإرجاع + المراجعة سارية + المراجعة متأخرة + ملغاة + سجلات الخروج + العينات + الحصة + مختبرة خلال 12 شهرًا + إلى + الإجمالي + إجمالي الاستجابة + التدريب + ساعات التدريب + سجلات التدريب + الانتقال + هدف الانتقال (ث) + الانطلاق + هدف الانطلاق (ث) + ساعات الوحدات في الموقع + شاغر + تم التحقق + ملغاة + متنازل عنها + أصبحت متأخرة خلال الفترة + ضمن الهدف + عبء العمل diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 8c69e9f7..49a0aa3e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1833,4 +1833,117 @@ Wasserabsperrung Hinweise zur Löschwasserversorgung Workflow-Läufe + Akkreditierung + Derzeit gültig + Aktivitäten + Alle Wachen/Gruppen + Nachträge + Beantragt + Erreichte Personen + Durchschnitt + Warten auf Prüfung + Nach Alarmart + Nach Durchflussklasse + Nach Tagesstunde + Nach Einsatzart + Nach Art + Nach Monat + Nach Person + Nach Programm + Nach Schweregrad + Nach Quelle + Nach Wache/Gruppe + Nach Typ + Abgesagt + Gemeinderisiko + Abgeschlossen + Mit Auflagen + Behoben + Anzahl + Gemeinde-Risikominderung + Ø Tage von Termin bis Abschluss + Ø Tage bis Behebung + Ø Tage von Antrag bis Erteilung + Abgelehnt + Eskaliert + Leitungsübersicht + Abgelaufen + Nicht bestanden + Erstes Eintreffen + Durchflussprüfungen im Zeitraum + Von + Die Zahlen umfassen nur die Datensätze, die Sie öffnen können; Abteilungsadministratoren sehen die Abteilungssummen. + Gefahrstoffe vor Ort + Auswertungen + Kennzahlen + Datensätze nach Wochentag und Stunde + Stunden + Stunden je Mitglied + In Betrieb + Einsatzberichte + Einsätze + In den letzten 12 Monaten kontrolliert + Dashboards, die bei Bedarf aus abgeschlossenen Datensätzen berechnet werden. Ein gruppenbeschränkter Betrachter sieht nur die Datensätze, die er öffnen kann; Abteilungsadministratoren sehen die ganze Abteilung. + Erteilt + Lebenszyklus + Höchstwert + Zentralwert + Zentralwert Stunden bis Abschluss + Zentralwert Prüfdauer (h) + Ausgebildete Mitglieder + Dieses Modul ist für die Abteilung nicht aktiviert. + Hilfsbedürftige Bewohner + NERIS-Annahmequote + NERIS angenommen + NERIS abgelehnt + Keine abgeschlossenen Datensätze in diesem Zeitraum. + Ohne Sprinkler + Objekt-Risikoprofil + Zeit vor Ort + Termingerecht abgeschlossen + Offene Entwürfe + Derzeit offen + Offene Verstöße + Eröffnet + Derzeit überfällige Pflichten + 90. Perzentil + Bestehensquote + Bestanden + Personalstunden + Vorperiode + Warteschlangen und Pflichten + Datensatzstunden + Abgeschlossene Datensätze + Gelesene Datensätze + Datensätze mit Einheitszeiten + Nachkontrollen erforderlich + Einsatzzeiten + Einsatzzeiten-Übersicht + Einheitseinsätze + Rücklaufquote + Überprüfung aktuell + Überprüfung überfällig + Widerrufen + Einsatzdatensätze + Messwerte + Anteil + In den letzten 12 Monaten geprüft + Bis + Gesamt + Gesamte Hilfsfrist + Ausbildung + Ausbildungsstunden + Ausbildungsdatensätze + Anfahrt + Anfahrtszeit-Ziel (s) + Ausrückzeit + Ausrückzeit-Ziel (s) + Einheitsstunden vor Ort + Leerstehend + Bestätigt + Annulliert + Erlassen + Im Zeitraum überfällig geworden + Im Ziel + Arbeitsaufkommen diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 686aca8c..d4903fce 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1833,4 +1833,117 @@ Διακοπή νερού Σημειώσεις υδροδότησης Εκτελέσεις Workflow + Πιστοποίηση + Ενεργά τώρα + Δραστηριότητες + Όλες οι ομάδες σταθμών + Τροποποιήσεις + Αιτήθηκαν + Κοινό που προσεγγίστηκε + Μέσος όρος + Σε αναμονή αναθεώρησης + Ανά τύπο κλήσης + Ανά κλάση ροής + Ανά ώρα ημέρας + Ανά τύπο συμβάντος + Ανά είδος + Ανά μήνα + Ανά άτομο + Ανά πρόγραμμα + Ανά σοβαρότητα + Ανά πηγή + Ανά ομάδα σταθμού + Ανά τύπο + Ακυρώθηκαν + Κοινοτικός κίνδυνος + Ολοκληρώθηκαν + Υπό όρους + Διορθώθηκαν + Πλήθος + Μείωση κοινοτικού κινδύνου + Μ.Ο. ημερών από προγραμματισμό έως ολοκλήρωση + Μ.Ο. ημερών έως διόρθωση + Μ.Ο. ημερών από αίτηση έως έκδοση + Απορρίφθηκαν + Κλιμακώθηκαν + Συνοπτική εικόνα + Έληξαν + Αποτυχημένες + Πρώτη άφιξη + Δοκιμές ροής στο διάστημα + Από + Τα στοιχεία καλύπτουν μόνο τις εγγραφές που μπορείτε να ανοίξετε· οι διαχειριστές τμήματος βλέπουν τα σύνολα του τμήματος. + Επικίνδυνα υλικά στο χώρο + Αναλυτικά στοιχεία + Βασικά μεγέθη + Εγγραφές ανά ημέρα και ώρα + Ώρες + Ώρες ανά μέλος + Σε λειτουργία + Αναφορές συμβάντων + Συμβάντα + Επιθεωρήθηκαν τους τελευταίους 12 μήνες + Πίνακες που υπολογίζονται κατ' απαίτηση από οριστικοποιημένες εγγραφές. Ένας χρήστης με περιορισμό ομάδας βλέπει μόνο τις εγγραφές που μπορεί να ανοίξει· οι διαχειριστές τμήματος βλέπουν όλο το τμήμα. + Εκδόθηκαν + Κύκλος ζωής + Μέγιστο + Διάμεσος + Διάμεσος ωρών έως οριστικοποίηση + Διάμεσος χρόνου αναθεώρησης (ω) + Εκπαιδευμένα μέλη + Αυτή η ενότητα δεν είναι ενεργή για το τμήμα. + Ένοικοι που χρειάζονται βοήθεια + Ποσοστό αποδοχής NERIS + Αποδεκτές NERIS + Απορριφθείσες NERIS + Δεν υπάρχουν οριστικοποιημένες εγγραφές σε αυτό το διάστημα. + Χωρίς καταιονιστήρες + Προφίλ κινδύνου κτιρίων + Χρόνος στο σημείο + Ολοκληρώθηκαν εμπρόθεσμα + Ανοικτά προσχέδια + Ανοικτές τώρα + Ανοικτές παραβάσεις + Άνοιξαν + Εκπρόθεσμες υποχρεώσεις τώρα + 90ό εκατοστημόριο + Ποσοστό επιτυχίας + Επιτυχείς + Ώρες προσωπικού + Προηγούμενη περίοδος + Ουρές και υποχρεώσεις + Ώρες εγγραφών + Οριστικοποιημένες εγγραφές + Εγγραφές που διαβάστηκαν + Εγγραφές με χρόνους μονάδων + Απαιτούνται επανελέγχοι + Απόδοση ανταπόκρισης + Σύνοψη ανταπόκρισης + Ανταποκρίσεις μονάδων + Ποσοστό επιστροφών + Επισκόπηση ενημερωμένη + Επισκόπηση εκπρόθεσμη + Ανακλήθηκαν + Εγγραφές εξόδων + Δείγματα + Μερίδιο + Ελέγχθηκαν τους τελευταίους 12 μήνες + Έως + Σύνολο + Συνολική ανταπόκριση + Εκπαίδευση + Ώρες εκπαίδευσης + Εγγραφές εκπαίδευσης + Μετάβαση + Στόχος μετάβασης (δ) + Εξόρμηση + Στόχος εξόρμησης (δ) + ώρες μονάδων στο σημείο + Κενά + Επαληθεύτηκαν + Ακυρωμένες + Παραιτήθηκαν + Έγιναν εκπρόθεσμες στο διάστημα + Εντός στόχου + Φόρτος εργασίας diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index a2678b1a..41cdb724 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1833,4 +1833,117 @@ Water shutoff Water supply notes Workflow runs + Accreditation + Active now + Activities + All station groups + Amendments + Applied + Audience reached + Average + Awaiting review + By call type + By flow class + By hour of day + By incident type + By kind + By month + By person + By program + By severity + By source + By station group + By type + Cancelled + Community risk + Completed + Conditional + Corrected + Count + Community risk reduction + Avg. days scheduled to completed + Avg. days to correction + Avg. days applied to issued + Denied + Escalated + Executive summary + Expired + Failed + First arrival + Flow tests in window + From + Figures cover only the Records you can open; department administrators see department totals. + Hazmat on site + Analytics + Headline figures + Records by weekday and hour + Hours + Hours per member + In service + Incident reports + Incidents + Inspected in last 12 months + Dashboards computed on demand over finalized Records. A group-scoped viewer sees only the Records they can open; department administrators see the department. + Issued + Lifecycle + Maximum + Median + Median hours to finalize + Median review turnaround (h) + Members trained + This module is not enabled for the department. + Occupants needing assistance + NERIS acceptance rate + NERIS accepted + NERIS rejected + No finalized Records in this window. + No sprinklers + Occupancy risk profile + Time on scene + Completed on schedule + Open drafts + Open now + Open violations + Opened + Obligations overdue now + 90th percentile + Pass rate + Passed + Personnel hours + Prior period + Queues and obligations + Record hours + Records finalized + Records read + Records with unit times + Re-inspections required + Response performance + Response summary + Unit responses + Return rate + Review current + Review overdue + Revoked + Run records + Samples + Share + Tested in last 12 months + To + Total + Total response + Training + Training hours + Training records + Travel + Travel target (s) + Turnout + Turnout target (s) + unit hours on scene + Vacant + Verified + Voided + Waived + Went overdue in window + Within target + Workload diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 6b648730..8047ad28 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1833,4 +1833,117 @@ Corte de agua Notas de suministro de agua Ejecuciones de Workflow + Acreditación + Activos ahora + Actividades + Todos los grupos de estación + Enmiendas + Solicitados + Público alcanzado + Promedio + Pendientes de revisión + Por tipo de llamada + Por clase de caudal + Por hora del día + Por tipo de incidente + Por clase + Por mes + Por persona + Por programa + Por gravedad + Por origen + Por grupo de estación + Por tipo + Canceladas + Riesgo comunitario + Completadas + Condicionales + Corregidas + Cantidad + Reducción del riesgo comunitario + Días promedio de programada a completada + Días promedio hasta corrección + Días promedio de solicitud a emisión + Denegados + Escaladas + Resumen ejecutivo + Vencidos + Reprobadas + Primera llegada + Pruebas de caudal en el período + Desde + Las cifras cubren solo los registros que puede abrir; los administradores del departamento ven los totales del departamento. + Materiales peligrosos en el sitio + Analítica + Cifras principales + Registros por día de la semana y hora + Horas + Horas por miembro + En servicio + Informes de incidente + Incidentes + Inspeccionados en los últimos 12 meses + Paneles calculados bajo demanda sobre los registros finalizados. Un usuario limitado a su grupo solo ve los registros que puede abrir; los administradores del departamento ven todo el departamento. + Emitidos + Ciclo de vida + Máximo + Mediana + Mediana de horas hasta finalizar + Mediana de revisión (h) + Miembros formados + Este módulo no está habilitado para el departamento. + Ocupantes que necesitan asistencia + Tasa de aceptación NERIS + Aceptadas por NERIS + Rechazadas por NERIS + No hay registros finalizados en este período. + Sin rociadores + Perfil de riesgo de ocupaciones + Tiempo en escena + Completadas a tiempo + Borradores abiertos + Abiertas ahora + Infracciones abiertas + Abiertas + Obligaciones vencidas ahora + Percentil 90 + Tasa de aprobación + Aprobadas + Horas de personal + Período anterior + Colas y obligaciones + Horas de registro + Registros finalizados + Registros leídos + Registros con tiempos de unidad + Reinspecciones requeridas + Rendimiento de respuesta + Resumen de respuesta + Respuestas de unidades + Tasa de devolución + Revisión vigente + Revisión vencida + Revocados + Registros de salida + Muestras + Proporción + Probados en los últimos 12 meses + Hasta + Total + Respuesta total + Formación + Horas de formación + Registros de formación + Trayecto + Objetivo de trayecto (s) + Salida + Objetivo de salida (s) + horas de unidad en escena + Vacantes + Verificadas + Anuladas + Exentas + Vencidas en el período + Dentro del objetivo + Carga de trabajo diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index e2f8e258..9d100c38 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1833,4 +1833,117 @@ Coupure d'eau Notes sur l'alimentation en eau Exécutions de Workflow + Accréditation + Actifs actuellement + Activités + Tous les groupes de caserne + Amendements + Demandés + Public touché + Moyenne + En attente de relecture + Par type d'appel + Par classe de débit + Par heure de la journée + Par type d'incident + Par nature + Par mois + Par personne + Par programme + Par gravité + Par origine + Par groupe de caserne + Par type + Annulées + Risque communautaire + Terminées + Sous conditions + Corrigées + Nombre + Réduction des risques communautaires + Jours moyens entre planification et fin + Jours moyens avant correction + Jours moyens entre demande et délivrance + Refusés + Escaladées + Synthèse de direction + Expirés + Non conformes + Première arrivée + Essais de débit sur la période + Du + Les chiffres ne couvrent que les dossiers que vous pouvez ouvrir ; les administrateurs du service voient les totaux du service. + Matières dangereuses sur site + Analyses + Chiffres clés + Dossiers par jour de semaine et heure + Heures + Heures par membre + En service + Rapports d'incident + Incidents + Inspectés depuis 12 mois + Tableaux de bord calculés à la demande sur les dossiers finalisés. Un utilisateur limité à son groupe ne voit que les dossiers qu'il peut ouvrir ; les administrateurs du service voient tout le service. + Délivrés + Cycle de vie + Maximum + Médiane + Médiane des heures avant finalisation + Médiane du délai de relecture (h) + Membres formés + Ce module n'est pas activé pour le service. + Occupants nécessitant une assistance + Taux d'acceptation NERIS + Acceptés par NERIS + Rejetés par NERIS + Aucun dossier finalisé sur cette période. + Sans sprinkleurs + Profil de risque des établissements + Temps sur place + Terminées dans les délais + Brouillons ouverts + Ouvertes actuellement + Infractions ouvertes + Ouvertes + Obligations en retard actuellement + 90e centile + Taux de conformité + Conformes + Heures du personnel + Période précédente + Files d'attente et obligations + Heures des dossiers + Dossiers finalisés + Dossiers lus + Dossiers avec horaires d'unité + Contre-visites requises + Performance d'intervention + Synthèse des interventions + Interventions d'unités + Taux de renvoi + Révision à jour + Révision en retard + Révoqués + Dossiers de sortie + Échantillons + Part + Testés depuis 12 mois + Au + Total + Délai total + Formation + Heures de formation + Dossiers de formation + Trajet + Objectif de trajet (s) + Départ + Objectif de départ (s) + heures d'unité sur place + Vacants + Vérifiées + Annulés + Levées + Passées en retard sur la période + Dans l'objectif + Charge de travail diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 1210741b..5f903670 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1833,4 +1833,117 @@ Interruzione acqua Note sulla fornitura idrica Esecuzioni Workflow + Accreditamento + Attivi ora + Attività + Tutti i gruppi di stazione + Emendamenti + Richiesti + Pubblico raggiunto + Media + In attesa di revisione + Per tipo di chiamata + Per classe di portata + Per ora del giorno + Per tipo di incidente + Per genere + Per mese + Per persona + Per programma + Per gravità + Per origine + Per gruppo di stazione + Per tipo + Annullate + Rischio della comunità + Completate + Condizionate + Corrette + Numero + Riduzione del rischio della comunità + Giorni medi da programmata a completata + Giorni medi alla correzione + Giorni medi da richiesta a rilascio + Negati + Escalate + Sintesi direzionale + Scaduti + Non superate + Primo arrivo + Prove di portata nel periodo + Dal + Le cifre coprono solo i record che puoi aprire; gli amministratori del dipartimento vedono i totali del dipartimento. + Materiali pericolosi in sede + Analisi + Cifre principali + Record per giorno della settimana e ora + Ore + Ore per membro + In servizio + Rapporti di incidente + Incidenti + Ispezionati negli ultimi 12 mesi + Dashboard calcolati su richiesta dai record finalizzati. Un utente limitato al proprio gruppo vede solo i record che può aprire; gli amministratori del dipartimento vedono l'intero dipartimento. + Rilasciati + Ciclo di vita + Massimo + Mediana + Mediana delle ore alla finalizzazione + Mediana del tempo di revisione (h) + Membri formati + Questo modulo non è abilitato per il dipartimento. + Occupanti che necessitano assistenza + Tasso di accettazione NERIS + Accettati da NERIS + Respinti da NERIS + Nessun record finalizzato in questo intervallo. + Senza sprinkler + Profilo di rischio degli edifici + Tempo sul posto + Completate nei tempi + Bozze aperte + Aperte ora + Violazioni aperte + Aperte + Obblighi scaduti ora + 90º percentile + Tasso di superamento + Superate + Ore del personale + Periodo precedente + Code e obblighi + Ore dei record + Record finalizzati + Record letti + Record con tempi delle unità + Reispezioni richieste + Prestazioni di risposta + Sintesi della risposta + Interventi delle unità + Tasso di restituzione + Revisione aggiornata + Revisione scaduta + Revocati + Record di uscita + Campioni + Quota + Testati negli ultimi 12 mesi + Al + Totale + Risposta totale + Formazione + Ore di formazione + Record di formazione + Percorrenza + Obiettivo di percorrenza (s) + Uscita + Obiettivo di uscita (s) + ore delle unità sul posto + Vuoti + Verificate + Annullati + Condonate + Diventate scadute nel periodo + Entro l'obiettivo + Carico di lavoro diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index 98820e40..74aa285a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1833,4 +1833,117 @@ Zawór wody Uwagi o zaopatrzeniu w wodę Uruchomienia Workflow + Akredytacja + Aktywne teraz + Działania + Wszystkie grupy stacji + Poprawki + Złożone + Dotarto do osób + Średnia + Oczekujące na przegląd + Według typu wezwania + Według klasy wydajności + Według godziny dnia + Według typu zdarzenia + Według rodzaju + Według miesiąca + Według osoby + Według programu + Według wagi + Według źródła + Według grupy stacji + Według typu + Anulowane + Ryzyko społeczności + Ukończone + Warunkowe + Usunięte + Liczba + Ograniczanie ryzyka społeczności + Śr. dni od zaplanowania do ukończenia + Śr. dni do usunięcia + Śr. dni od złożenia do wydania + Odrzucone + Eskalowane + Podsumowanie dla kierownictwa + Wygasłe + Niezaliczone + Pierwsze przybycie + Testy wydajności w okresie + Od + Liczby obejmują tylko rekordy, które możesz otworzyć; administratorzy jednostki widzą sumy dla jednostki. + Materiały niebezpieczne na miejscu + Analityka + Kluczowe liczby + Rekordy według dnia tygodnia i godziny + Godziny + Godziny na członka + Sprawne + Raporty ze zdarzeń + Zdarzenia + Skontrolowane w ostatnich 12 miesiącach + Pulpity obliczane na żądanie na podstawie sfinalizowanych rekordów. Użytkownik ograniczony do grupy widzi tylko rekordy, które może otworzyć; administratorzy jednostki widzą całą jednostkę. + Wydane + Cykl życia + Maksimum + Mediana + Mediana godzin do finalizacji + Mediana czasu przeglądu (h) + Przeszkoleni członkowie + Ten moduł nie jest włączony dla jednostki. + Osoby wymagające pomocy + Wskaźnik akceptacji NERIS + Zaakceptowane przez NERIS + Odrzucone przez NERIS + Brak sfinalizowanych rekordów w tym okresie. + Bez tryskaczy + Profil ryzyka obiektów + Czas na miejscu + Ukończone w terminie + Otwarte wersje robocze + Otwarte teraz + Otwarte naruszenia + Otwarte + Zobowiązania zaległe teraz + 90. percentyl + Wskaźnik zaliczeń + Zaliczone + Godziny personelu + Poprzedni okres + Kolejki i zobowiązania + Godziny rekordów + Sfinalizowane rekordy + Odczytane rekordy + Rekordy z czasami jednostek + Wymagane ponowne kontrole + Wydajność reagowania + Podsumowanie reagowania + Wyjazdy jednostek + Wskaźnik zwrotów + Przegląd aktualny + Przegląd zaległy + Cofnięte + Rekordy wyjazdów + Próbki + Udział + Sprawdzone w ostatnich 12 miesiącach + Do + Razem + Łączny czas reakcji + Szkolenia + Godziny szkoleń + Rekordy szkoleń + Dojazd + Cel czasu dojazdu (s) + Czas wyjazdu + Cel czasu wyjazdu (s) + godziny jednostek na miejscu + Puste + Zweryfikowane + Unieważnione + Odstąpiono + Przeterminowane w okresie + W granicach celu + Obciążenie pracą diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index 4f42f0b2..a2141de5 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1833,4 +1833,117 @@ Vattenavstängning Anteckningar om vattenförsörjning Workflow-körningar + Ackreditering + Aktiva nu + Aktiviteter + Alla stationsgrupper + Ändringar + Ansökta + Nådd publik + Medel + Väntar på granskning + Per larmtyp + Per flödesklass + Per timme på dygnet + Per händelsetyp + Per slag + Per månad + Per person + Per program + Per allvarlighetsgrad + Per källa + Per stationsgrupp + Per typ + Inställda + Samhällsrisk + Slutförda + Villkorade + Åtgärdade + Antal + Samhällsriskreducering + Snitt dagar från planerad till slutförd + Snitt dagar till åtgärd + Snitt dagar från ansökan till utfärdande + Nekade + Eskalerade + Ledningsöversikt + Utgångna + Underkända + Första ankomst + Flödestester under perioden + Från + Siffrorna omfattar bara de poster du kan öppna; avdelningsadministratörer ser avdelningens totaler. + Farligt gods på plats + Analys + Nyckeltal + Poster per veckodag och timme + Timmar + Timmar per medlem + I drift + Händelserapporter + Händelser + Inspekterade senaste 12 månaderna + Instrumentpaneler som beräknas vid behov från slutförda poster. En gruppbegränsad användare ser bara de poster som kan öppnas; avdelningsadministratörer ser hela avdelningen. + Utfärdade + Livscykel + Högsta + Medianvärde + Median timmar till slutförande + Median granskningstid (h) + Utbildade medlemmar + Den här modulen är inte aktiverad för avdelningen. + Boende som behöver hjälp + NERIS-godkännandegrad + NERIS godkända + NERIS avvisade + Inga slutförda poster i detta intervall. + Utan sprinkler + Riskprofil för byggnader + Tid på plats + Slutförda i tid + Öppna utkast + Öppna nu + Öppna avvikelser + Öppnade + Försenade åtaganden nu + 90:e percentilen + Godkännandegrad + Godkända + Personaltimmar + Föregående period + Köer och åtaganden + Posttimmar + Slutförda poster + Lästa poster + Poster med enhetstider + Ominspektioner krävs + Insatstider + Insatsöversikt + Enhetsinsatser + Returandel + Granskning aktuell + Granskning försenad + Återkallade + Utryckningsposter + Mätvärden + Andel + Testade senaste 12 månaderna + Till + Totalt + Total insatstid + Utbildning + Utbildningstimmar + Utbildningsposter + Körtid + Mål för körtid (s) + Utryckningstid + Mål för utryckningstid (s) + enhetstimmar på plats + Tomma + Verifierade + Makulerade + Efterskänkta + Blev försenade under perioden + Inom målet + Arbetsbelastning diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 6dde03de..c723c911 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1833,4 +1833,117 @@ Перекриття води Примітки про водопостачання Запуски Workflow + Акредитація + Активні зараз + Заходи + Усі групи станцій + Поправки + Подано + Охоплена аудиторія + Середнє + Очікують перевірки + За типом виклику + За класом витрати + За годиною доби + За типом інциденту + За видом + За місяцем + За особою + За програмою + За серйозністю + За джерелом + За групою станцій + За типом + Скасовано + Ризики громади + Завершено + Умовно + Виправлено + Кількість + Зниження ризиків громади + Сер. днів від запланованої до завершення + Сер. днів до виправлення + Сер. днів від подання до видачі + Відмовлено + Ескальовано + Зведення для керівництва + Прострочено + Не пройдено + Перше прибуття + Тести витрати за період + Від + Показники охоплюють лише записи, які ви можете відкрити; адміністратори підрозділу бачать підсумки підрозділу. + Небезпечні матеріали на об'єкті + Аналітика + Ключові показники + Записи за днем тижня та годиною + Години + Годин на члена + У роботі + Звіти про інциденти + Інциденти + Проінспектовано за останні 12 місяців + Панелі, що обчислюються на вимогу за фіналізованими записами. Користувач, обмежений групою, бачить лише записи, які може відкрити; адміністратори підрозділу бачать увесь підрозділ. + Видано + Життєвий цикл + Максимум + Медіана + Медіана годин до фіналізації + Медіана часу перевірки (год) + Навчено членів + Цей модуль не ввімкнено для підрозділу. + Мешканці, що потребують допомоги + Частка прийняття NERIS + Прийнято NERIS + Відхилено NERIS + Немає фіналізованих записів у цьому періоді. + Без спринклерів + Профіль ризику об'єктів + Час на місці + Завершено вчасно + Відкриті чернетки + Відкриті зараз + Відкриті порушення + Відкрито + Прострочені зобов'язання зараз + 90-й перцентиль + Частка проходження + Пройдено + Години персоналу + Попередній період + Черги та зобов'язання + Години записів + Фіналізовані записи + Прочитано записів + Записи з часами підрозділів + Потрібні повторні перевірки + Ефективність реагування + Зведення реагування + Виїзди підрозділів + Частка повернень + Перегляд актуальний + Перегляд прострочено + Відкликано + Записи виїздів + Вибірка + Частка + Перевірено за останні 12 місяців + До + Усього + Загальний час реагування + Навчання + Години навчання + Записи навчання + Час у дорозі + Ціль часу в дорозі (с) + Час виїзду + Ціль часу виїзду (с) + годин підрозділів на місці + Порожні + Перевірено + Анульовані + Скасовано вимогу + Стали простроченими за період + У межах цілі + Навантаження diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs index 2d609b03..e428edc0 100644 --- a/Core/Resgrid.Model/FeatureFlagKeys.cs +++ b/Core/Resgrid.Model/FeatureFlagKeys.cs @@ -75,5 +75,8 @@ public static class FeatureFlagKeys /// RMS-4 optional post-finalization quality review. Depends on Records.System. Seeded off by M0186. public const string RecordsQualityReview = "Records.QualityReview"; + + /// RMS-6 records analytics: response-performance, workload, executive, accreditation and community-risk dashboards over finalized Records. Depends on Records.System. Seeded off by M0187. + public const string RecordsAnalytics = "Records.Analytics"; } } diff --git a/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs b/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs new file mode 100644 index 00000000..ef8f040f --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Window and filters for the RMS-6 records analytics dashboards (RMS plan section 6, RMS-6). Every dashboard + /// is computed over finalized Records inside [Start, End) — the window is clamped by the service to at most + /// and the read to + /// rows, after which the result says it is truncated rather than silently under-counting. + /// + public sealed class RecordsAnalyticsQuery + { + /// Inclusive start (UTC). Defaults to 90 days before . + public DateTime? Start { get; set; } + + /// Exclusive end (UTC). Defaults to now. + public DateTime? End { get; set; } + + /// Restrict to Records whose station group (or, for a unit response, the unit's group snapshot) is this group. + public int? StationGroupId { get; set; } + + /// Restrict operational Records to one definition key (e.g. system.run). + public string DefinitionKey { get; set; } + + /// Turnout target in seconds (dispatched to en route). NFPA 1710 uses 80 s for fire, 60 s for EMS. + public int TurnoutTargetSeconds { get; set; } = 80; + + /// Travel target in seconds (en route to on scene). NFPA 1710 uses 240 s for the first arriving engine. + public int TravelTargetSeconds { get; set; } = 240; + } + + public static class RecordsAnalyticsLimits + { + /// Widest window a dashboard computes in one request. + public const int MaxWindowDays = 366; + + /// Plan section 5.12: the saved-report result cap; past it the dashboard reports truncation. + public const int RowCap = 50000; + + public const int DefaultWindowDays = 90; + } + + /// Fields every dashboard carries: the effective window, how it was scoped and whether the read hit the cap. + public abstract class RecordsAnalyticsBase + { + public int DepartmentId { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + public DateTime GeneratedOn { get; set; } = DateTime.UtcNow; + + /// True when the viewer is group-scoped, so every figure is over the Records they may open, not the department. + public bool GroupScoped { get; set; } + + /// True when the read stopped at the row cap; narrow the window. + public bool Truncated { get; set; } + + /// Records (operational plus incident reports) that fed the dashboard after scoping. + public int RecordsRead { get; set; } + + /// Set when a section could not be produced; the dashboard degrades rather than failing whole. + public List Warnings { get; set; } = new List(); + } + + /// One row of a grouped count: the grouping key, a display label resolved by the service, and totals. + public class RecordsAnalyticsCount + { + public string Key { get; set; } + public string Label { get; set; } + public int Count { get; set; } + + /// Hours attributable to the group when the dimension carries time (records, personnel, units); 0 otherwise. + public double Hours { get; set; } + + /// Share of the dimension's total, 0–100, when meaningful. + public double? Percent { get; set; } + } + + /// Distribution of one interval (turnout, travel, total response, on scene) in seconds. + public class RecordsResponseTimeStat + { + public int Samples { get; set; } + public double? AverageSeconds { get; set; } + public double? MedianSeconds { get; set; } + + /// 90th percentile, nearest-rank — the figure accreditation and NFPA 1710 reporting quote. + public double? P90Seconds { get; set; } + public double? MaxSeconds { get; set; } + + /// Target this interval was measured against, null when the interval has none. + public int? TargetSeconds { get; set; } + public int WithinTarget { get; set; } + public double? WithinTargetPercent { get; set; } + } + + /// Response-time distributions for one grouping (unit, station group, hour of day, month). + public class RecordsResponseTimeRow + { + public string Key { get; set; } + public string Label { get; set; } + public int Responses { get; set; } + public RecordsResponseTimeStat Turnout { get; set; } = new RecordsResponseTimeStat(); + public RecordsResponseTimeStat Travel { get; set; } = new RecordsResponseTimeStat(); + public RecordsResponseTimeStat TotalResponse { get; set; } = new RecordsResponseTimeStat(); + public RecordsResponseTimeStat OnScene { get; set; } = new RecordsResponseTimeStat(); + } + + /// + /// Response performance (RMS-6): turnout, travel, total response and time on scene from the attested unit + /// response rows of finalized operational Records and incident reports. A unit time is used only when both + /// timestamps exist, are ordered, and span less than a day — a mis-keyed time never becomes an outlier. + /// + public class RecordsResponsePerformance : RecordsAnalyticsBase + { + /// Unit responses with at least one usable interval. + public int Responses { get; set; } + + /// Records that contributed at least one usable unit response. + public int RecordsWithResponses { get; set; } + + public RecordsResponseTimeStat Turnout { get; set; } = new RecordsResponseTimeStat(); + public RecordsResponseTimeStat Travel { get; set; } = new RecordsResponseTimeStat(); + public RecordsResponseTimeStat TotalResponse { get; set; } = new RecordsResponseTimeStat(); + public RecordsResponseTimeStat OnScene { get; set; } = new RecordsResponseTimeStat(); + + /// First arriving unit per Record: earliest on-scene minus earliest dispatch. + public RecordsResponseTimeStat FirstArrival { get; set; } = new RecordsResponseTimeStat(); + + public List ByUnit { get; set; } = new List(); + public List ByStationGroup { get; set; } = new List(); + public List ByHourOfDay { get; set; } = new List(); + public List ByMonth { get; set; } = new List(); + + /// Operational definition versus NERIS incident report. + public List BySource { get; set; } = new List(); + } + + /// One cell of the weekday-by-hour heat map. + public class RecordsAnalyticsHeatCell + { + /// 0 = Sunday … 6 = Saturday (System.DayOfWeek). + public int Weekday { get; set; } + public int Hour { get; set; } + public int Count { get; set; } + } + + /// + /// Workload (RMS-6): what was recorded, by whom, for how long. Hours come from the Record's start/end (or a + /// participant's own span when it has one); a Record without an end contributes zero rather than a guess. + /// + public class RecordsWorkload : RecordsAnalyticsBase + { + public int RecordsFinalized { get; set; } + public int IncidentReportsFinalized { get; set; } + + /// Sum of Record durations in hours. + public double RecordHours { get; set; } + + /// Sum of per-participant hours (the Personnel Hours report's figure). + public double PersonnelHours { get; set; } + public double TrainingHours { get; set; } + public int UnitResponses { get; set; } + public double UnitOnSceneHours { get; set; } + + public List ByDefinition { get; set; } = new List(); + public List ByMonth { get; set; } = new List(); + public List ByAuthor { get; set; } = new List(); + public List ByStationGroup { get; set; } = new List(); + + /// Responses and on-scene hours per unit (unit utilization). + public List ByUnit { get; set; } = new List(); + + /// Records participated in and hours per person. + public List ByPerson { get; set; } = new List(); + public List ByWeekdayHour { get; set; } = new List(); + } + + /// A headline figure with the same figure for the preceding window of equal length. + public class RecordsKpi + { + public string Key { get; set; } + public double Current { get; set; } + public double Prior { get; set; } + + /// Percentage change from prior to current; null when the prior figure is zero. + public double? ChangePercent { get; set; } + } + + /// + /// Executive summary (RMS-6): the headline figures a chief reads once a month, each against the preceding + /// window, plus the process measures — time to finalize, review turnaround, return rate, obligations that went + /// overdue — that only the Records lifecycle can answer. + /// + public class RecordsExecutiveSummary : RecordsAnalyticsBase + { + public DateTime PriorStart { get; set; } + public DateTime PriorEnd { get; set; } + public List Kpis { get; set; } = new List(); + + /// Median hours from creation to first finalization. + public double? MedianHoursToFinalize { get; set; } + + /// Median hours from submitted-for-review to approval. + public double? MedianReviewHours { get; set; } + + /// Share of finalized Records that were returned at least once, 0–100. + public double? ReturnRatePercent { get; set; } + public double? NerisAcceptanceRatePercent { get; set; } + public double? TurnoutP90Seconds { get; set; } + public double? TotalResponseP90Seconds { get; set; } + public double? FirstArrivalP90Seconds { get; set; } + + /// Obligations sitting overdue right now (worker 42's persisted due states). + public int OverdueNow { get; set; } + + /// Obligations whose due state changed to overdue inside the window. + public int WentOverdue { get; set; } + public int OpenDrafts { get; set; } + public int AwaitingReview { get; set; } + public List ByDefinition { get; set; } = new List(); + public List ByMonth { get; set; } = new List(); + } + + public class RecordsInspectionMetrics + { + public int Completed { get; set; } + public int Passed { get; set; } + public int Failed { get; set; } + public int Conditional { get; set; } + public double? PassRatePercent { get; set; } + public int Cancelled { get; set; } + public int ReinspectionsRequired { get; set; } + public int OpenNow { get; set; } + public int OverdueNow { get; set; } + public double? AverageDaysScheduledToCompleted { get; set; } + + /// Completed on or before the scheduled date. + public int CompletedOnSchedule { get; set; } + public double? OnSchedulePercent { get; set; } + public List ByProgram { get; set; } = new List(); + public List ByMonth { get; set; } = new List(); + } + + public class RecordsViolationMetrics + { + public int Opened { get; set; } + public int Corrected { get; set; } + public int Verified { get; set; } + public int Waived { get; set; } + public int Escalated { get; set; } + public int OpenNow { get; set; } + public int OverdueNow { get; set; } + public double? AverageDaysToCorrection { get; set; } + public List BySeverity { get; set; } = new List(); + } + + public class RecordsPermitMetrics + { + public int Applied { get; set; } + public int Issued { get; set; } + public int Denied { get; set; } + public int Expired { get; set; } + public int Revoked { get; set; } + public int ActiveNow { get; set; } + public double? AverageDaysAppliedToIssued { get; set; } + public List ByType { get; set; } = new List(); + } + + public class RecordsHydrantMetrics + { + public int Total { get; set; } + public int InService { get; set; } + public int OutOfService { get; set; } + public int TestedWithin12Months { get; set; } + public double? TestedWithin12MonthsPercent { get; set; } + public int FlowTestsInWindow { get; set; } + public List ByFlowClass { get; set; } = new List(); + } + + public class RecordsOccupancyMetrics + { + public int Total { get; set; } + public int ReviewCurrent { get; set; } + public double? ReviewCurrentPercent { get; set; } + public int ReviewOverdue { get; set; } + public int InspectedWithin12Months { get; set; } + public double? InspectedWithin12MonthsPercent { get; set; } + public int HazmatOnSite { get; set; } + public int NoSprinklers { get; set; } + public int OccupantsNeedingAssistance { get; set; } + public int Vacant { get; set; } + public int OpenViolations { get; set; } + public List ByType { get; set; } = new List(); + public List ViolationsBySeverity { get; set; } = new List(); + } + + public class RecordsTrainingMetrics + { + public int Records { get; set; } + public double Hours { get; set; } + + /// Distinct participants on Training Records. + public int MembersTrained { get; set; } + public double? HoursPerMember { get; set; } + public List ByMonth { get; set; } = new List(); + } + + public class RecordsResponseSummary + { + public int Responses { get; set; } + public double? TurnoutP90Seconds { get; set; } + public double? TravelP90Seconds { get; set; } + public double? TotalResponseP90Seconds { get; set; } + public double? FirstArrivalP90Seconds { get; set; } + public double? TurnoutWithinTargetPercent { get; set; } + public double? TravelWithinTargetPercent { get; set; } + } + + /// + /// Accreditation (RMS-6): the CFAI/ISO-style evidence a department assembles from inspection, violation, + /// permit, hydrant, occupancy, training and response history. Each prevention section is present only when + /// its module is enabled; the flags say which. + /// + public class RecordsAccreditation : RecordsAnalyticsBase + { + public bool OccupancyEnabled { get; set; } + public bool InspectionsEnabled { get; set; } + public bool HydrantsEnabled { get; set; } + public bool PermitsEnabled { get; set; } + public RecordsInspectionMetrics Inspections { get; set; } + public RecordsViolationMetrics Violations { get; set; } + public RecordsPermitMetrics Permits { get; set; } + public RecordsHydrantMetrics Hydrants { get; set; } + public RecordsOccupancyMetrics Occupancies { get; set; } + public RecordsTrainingMetrics Training { get; set; } = new RecordsTrainingMetrics(); + public RecordsResponseSummary Response { get; set; } = new RecordsResponseSummary(); + } + + public class RecordsCrrMetrics + { + public int Activities { get; set; } + public int Audience { get; set; } + public int SmokeAlarmsInstalled { get; set; } + public double Hours { get; set; } + public List ByKind { get; set; } = new List(); + public List ByMonth { get; set; } = new List(); + } + + public class RecordsIncidentMetrics + { + public int IncidentReports { get; set; } + public int RunRecords { get; set; } + + /// Primary NERIS incident type on finalized incident reports. + public List ByIncidentType { get; set; } = new List(); + + /// Call type snapshot on finalized operational Records linked to a call. + public List ByCallType { get; set; } = new List(); + public List ByMonth { get; set; } = new List(); + public List ByHourOfDay { get; set; } = new List(); + } + + /// + /// Community risk (RMS-6): what the department is exposed to and what it is doing about it — incident mix, + /// occupancy risk profile, open violations, hydrants out of service, and CRR activity. Prevention sections + /// appear only when their module is enabled. + /// + public class RecordsCommunityRisk : RecordsAnalyticsBase + { + public bool CrrEnabled { get; set; } + public bool OccupancyEnabled { get; set; } + public bool InspectionsEnabled { get; set; } + public bool HydrantsEnabled { get; set; } + public RecordsCrrMetrics Crr { get; set; } + public RecordsIncidentMetrics Incidents { get; set; } = new RecordsIncidentMetrics(); + public RecordsOccupancyMetrics Occupancies { get; set; } + public RecordsHydrantMetrics Hydrants { get; set; } + } + + /// Slim projection of an operational Record's call context (no narrative, no restricted section) for a revision. + public class RmsRecordCallContext + { + public string RecordId { get; set; } + public string RevisionId { get; set; } + public string CallType { get; set; } + public int? CallPriority { get; set; } + public DateTime? CallLoggedOn { get; set; } + public int? UnitId { get; set; } + public DateTime? ActivityOn { get; set; } + } + + /// Slim projection of a revision header (never the snapshot) for lifecycle analytics. + public class RmsRevisionTransitionRow + { + public string RmsRevisionId { get; set; } + public string RecordId { get; set; } + public int RecordKind { get; set; } + public int Transition { get; set; } + public string DefinitionKey { get; set; } + public string ActorUserId { get; set; } + public DateTime CreatedOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs b/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs index 0092c181..1e4ee188 100644 --- a/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs @@ -15,7 +15,9 @@ public enum RecordsPreventionModule Permits = 4, Crr = 5, Investigations = 6, - QualityReview = 7 + QualityReview = 7, + /// RMS-6 records analytics dashboards (read-only; Records.Analytics). + Analytics = 8 } public static class RecordsPreventionModules @@ -31,6 +33,7 @@ public static string FlagKey(RecordsPreventionModule module) case RecordsPreventionModule.Crr: return FeatureFlagKeys.RecordsPreventionCrr; case RecordsPreventionModule.Investigations: return FeatureFlagKeys.RecordsInvestigations; case RecordsPreventionModule.QualityReview: return FeatureFlagKeys.RecordsQualityReview; + case RecordsPreventionModule.Analytics: return FeatureFlagKeys.RecordsAnalytics; default: throw new ArgumentOutOfRangeException(nameof(module)); } } @@ -38,7 +41,8 @@ public static string FlagKey(RecordsPreventionModule module) public static readonly IReadOnlyList All = new[] { RecordsPreventionModule.Occupancy, RecordsPreventionModule.Inspections, RecordsPreventionModule.Hydrants, - RecordsPreventionModule.Permits, RecordsPreventionModule.Crr, RecordsPreventionModule.Investigations, RecordsPreventionModule.QualityReview + RecordsPreventionModule.Permits, RecordsPreventionModule.Crr, RecordsPreventionModule.Investigations, RecordsPreventionModule.QualityReview, + RecordsPreventionModule.Analytics }; } diff --git a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs index 87ca865e..adba88ae 100644 --- a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs @@ -29,6 +29,13 @@ public sealed class RmsIncidentReportQuery public DateTime? FinalizedOnStart { get; set; } public DateTime? FinalizedOnEnd { get; set; } + /// + /// Half-open occurrence window [OccurredOnStart, OccurredOnEnd) on the call creation time, else FinalizedOn when + /// the report carries no call time (RMS-6 analytics bucket incidents by when they happened, not when they were filed). + /// + public DateTime? OccurredOnStart { get; set; } + public DateTime? OccurredOnEnd { get; set; } + public string ViewerUserId { get; set; } public int Skip { get; set; } public int Take { get; set; } = 50; @@ -55,6 +62,8 @@ public interface IRmsIncidentReportsRepository : IRepository public interface IRmsIncidentChildRepository : IRepository where T : class, IEntity { Task> GetForRecordAsync(int departmentId, string recordId, string revisionId); + /// Revision-bound child rows for many revisions at once (the attested set of finalized reports). + Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds); Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs index 979cca42..5d799998 100644 --- a/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs @@ -107,6 +107,8 @@ public interface IRmsInspectionsRepository : IRepository /// Latest completed inspection per occupancy for a program; the scheduler's frequency anchor. Task> GetLastCompletedByOccupancyAsync(int departmentId, string programId); Task> GetOpenForProgramAsync(int departmentId, string programId); + /// Live inspections whose activity date (CompletedOn, else ScheduledOn, else CreatedOn) is in [start, end), at most rows. + Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); Task TryBumpRowVersionAsync(int departmentId, string inspectionId, long expectedVersion, CancellationToken cancellationToken = default); } @@ -120,6 +122,8 @@ public interface IRmsViolationsRepository : IRepository Task CountOpenAsync(int departmentId); Task CountOverdueAsync(int departmentId, DateTime utcNow); Task> CountOpenByOccupancyAsync(int departmentId, IEnumerable occupancyIds); + /// Live violations opened (CreatedOn) in [start, end), at most rows. + Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); } public interface IRmsHydrantsRepository : IRepository @@ -137,6 +141,8 @@ public interface IRmsHydrantsRepository : IRepository public interface IRmsHydrantFlowTestsRepository : IRepository { Task> GetForHydrantAsync(int departmentId, string hydrantId); + /// Flow tests performed in [start, end) across the department, at most rows. + Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); } public interface IRmsHydrantMaintenancesRepository : IRepository @@ -168,6 +174,8 @@ public interface IRmsPermitsRepository : IRepository Task> GetForOccupancyAsync(int departmentId, string occupancyId); Task> GetExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc, int take); Task CountExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc); + /// Live permits applied for (AppliedOn) in [start, end), at most rows. + Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); Task TryBumpRowVersionAsync(int departmentId, string permitId, long expectedVersion, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index f75d852b..2defd8a9 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -54,6 +54,11 @@ public interface IRmsOperationalRecordsRepository : IRepository> GetOpenAsync(int departmentId); /// Live Finalized/Amended Records whose FinalizedOn is at or after the instant. Task> GetFinalizedSinceAsync(int departmentId, DateTime sinceUtc); + /// + /// Live Records in the given states whose occurrence (StartedOn, else FinalizedOn) falls in [start, end), oldest + /// first, at most rows (RMS-6 analytics; the caller treats a full page as truncation). + /// + Task> GetFinalizedInRangeAsync(int departmentId, IEnumerable states, DateTime startUtc, DateTime endUtc, int take); /// Records created in the window, newest first. Bounded; used by the Field Records rollout dashboard (RMS-1D). Task> GetCreatedSinceAsync(int departmentId, DateTime sinceUtc, int take); @@ -77,6 +82,8 @@ public interface IRmsOperationalRecordDetailsRepository : IRepository GetByRevisionAsync(int departmentId, string recordId, string revisionId); /// Working-draft detail rows for many records at once. Task> GetDraftsForRecordsAsync(int departmentId, IEnumerable recordIds); + /// Call context columns only (never narrative or the restricted section) for the revision-bound detail rows of many revisions. + Task> GetCallContextForRevisionsAsync(int departmentId, IEnumerable revisionIds); } public interface IRmsRecordParticipantsRepository : IRepository @@ -86,12 +93,16 @@ public interface IRmsRecordParticipantsRepository : IRepository> GetByUserAsync(int departmentId, string userId); /// Working-draft participant rows for many records at once. Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds); + /// Revision-bound participant rows for many revisions at once (the attested set of a finalized Record). + Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds); Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default); } public interface IRmsRecordUnitResponsesRepository : IRepository { Task> GetForRecordAsync(int departmentId, string recordId, string revisionId); + /// Revision-bound unit rows for many revisions at once (the attested set of a finalized Record). + Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds); Task> GetByUnitAsync(int departmentId, int unitId); /// Working-draft unit response rows for many records at once. Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds); @@ -155,6 +166,8 @@ public interface IRmsDepartmentCutoverEventsRepository : IRepository { Task> GetByIdsForDepartmentAsync(int departmentId, IEnumerable revisionIds); + /// Revision headers (never the snapshot) created in [start, end), oldest first, at most rows. + Task> GetTransitionsInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); Task> GetForRecordAsync(int departmentId, string recordId); Task GetByIdForDepartmentAsync(int departmentId, string revisionId); } @@ -238,6 +251,8 @@ public interface IRmsRecordDueStatesRepository : IRepository /// Count of obligations currently sitting overdue; the accountability view and dashboards read it. Task CountOverdueAsync(int departmentId); Task CountVisibleOverdueAsync(int departmentId, List visibleGroupIds, string userId); + /// Due-state rows modified in [start, end) - the due-state history the RMS-6 dashboards read - at most rows. + Task> GetChangedInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs b/Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs new file mode 100644 index 00000000..20d44696 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsAnalyticsService.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// RMS-6 records analytics (RMS plan section 6, RMS-6): response-performance, workload and executive dashboards + /// over finalized Records, their revisions and their due-state history, plus the accreditation and + /// community-risk dashboards that also read RMS-5 prevention history. + /// + /// Every read sits on the authorization path every other Records read uses: the viewer must be an active member + /// with Records access, the Records.Analytics flag must be on, and a group-scoped viewer sees figures + /// computed only over the Records they could open — cross-group department totals stay with department + /// administrators (plan section 11, question 38). No narrative, no restricted section and no protected column + /// is read; the dashboards count headers, attested unit and participant rows and prevention aggregates. + /// + /// + public interface IRecordsAnalyticsService + { + Task IsModuleEnabledAsync(int departmentId); + Task GetResponsePerformanceAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); + Task GetWorkloadAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); + Task GetExecutiveSummaryAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); + Task GetAccreditationAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); + Task GetCommunityRiskAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs new file mode 100644 index 00000000..cb3c9977 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs @@ -0,0 +1,736 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// RMS-6 records analytics (RMS plan section 6, RMS-6). Five dashboards computed on demand over finalized + /// Records: response performance, workload, executive summary, accreditation and community risk. + /// + /// Rules that every dashboard shares: the Records.Analytics flag and Records cutover gate the read; the + /// viewer must be an active member; a group-scoped viewer only ever counts Records the queue would show them + /// (the same rule, evaluated over the attested participant rows) + /// and incident reports the repository scopes the same way — so department-wide totals belong to department + /// administrators (plan section 11, question 38). Nothing here reads a narrative, a restricted section or a + /// protected column: headers, revision-bound unit and participant rows, call-context columns and prevention + /// aggregates are the whole input. The window is clamped to + /// and the read to rows; past the cap the result says it is truncated + /// instead of under-counting. Every section is computed independently and degrades to a warning. + /// + /// + public class RecordsAnalyticsService : IRecordsAnalyticsService + { + /// An interval longer than this is a mis-keyed time, not a response, and is excluded from every distribution. + public static readonly TimeSpan MaxInterval = TimeSpan.FromHours(24); + + /// A Record or participant span longer than this is clipped so one bad end time cannot swamp a month of hours. + public static readonly TimeSpan MaxDuration = TimeSpan.FromDays(7); + + private static readonly int[] OperationalFinal = { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Submitted, (int)RmsRecordState.Accepted }; + private static readonly int[] ReportFinal = { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Submitted, (int)RmsRecordState.Accepted, (int)RmsRecordState.Rejected, (int)RmsRecordState.Corrected }; + private const int ReportPage = 10000; + private const int PreventionPage = 50000; + private static readonly Regex SplitPascal = new Regex("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", RegexOptions.Compiled); + + private readonly RecordsPreventionGate _gate; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsRecordUnitResponsesRepository _units; + private readonly IRmsRecordParticipantsRepository _participants; + private readonly IRmsRecordGroupScopesRepository _scopes; + private readonly IRmsOperationalRecordDetailsRepository _details; + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsUnitResponsesRepository _reportUnits; + private readonly IRmsIncidentTypesRepository _incidentTypes; + private readonly IRmsRevisionsRepository _revisions; + private readonly IRmsRecordDueStatesRepository _dueStates; + private readonly IRmsInspectionsRepository _inspections; + private readonly IRmsInspectionProgramsRepository _programs; + private readonly IRmsViolationsRepository _violations; + private readonly IRmsPermitsRepository _permits; + private readonly IRmsPermitTypesRepository _permitTypes; + private readonly IRmsHydrantsRepository _hydrants; + private readonly IRmsHydrantFlowTestsRepository _flowTests; + private readonly IRmsOccupanciesRepository _occupancies; + private readonly IRmsCrrActivitiesRepository _crr; + private readonly IUnitsService _unitsService; + private readonly IDepartmentGroupsService _groupsService; + private readonly IDepartmentsService _departmentsService; + + public RecordsAnalyticsService(RecordsPreventionGate gate, IRecordsAuthorizationService authorization, IRmsOperationalRecordsRepository records, IRmsRecordUnitResponsesRepository units, + IRmsRecordParticipantsRepository participants, IRmsRecordGroupScopesRepository scopes, IRmsOperationalRecordDetailsRepository details, IRmsIncidentReportsRepository reports, + IRmsUnitResponsesRepository reportUnits, IRmsIncidentTypesRepository incidentTypes, IRmsRevisionsRepository revisions, IRmsRecordDueStatesRepository dueStates, + IRmsInspectionsRepository inspections, IRmsInspectionProgramsRepository programs, IRmsViolationsRepository violations, IRmsPermitsRepository permits, IRmsPermitTypesRepository permitTypes, + IRmsHydrantsRepository hydrants, IRmsHydrantFlowTestsRepository flowTests, IRmsOccupanciesRepository occupancies, IRmsCrrActivitiesRepository crr, + IUnitsService unitsService, IDepartmentGroupsService groupsService, IDepartmentsService departmentsService) + { + _gate = gate; _authorization = authorization; _records = records; _units = units; _participants = participants; _scopes = scopes; _details = details; _reports = reports; + _reportUnits = reportUnits; _incidentTypes = incidentTypes; _revisions = revisions; _dueStates = dueStates; _inspections = inspections; _programs = programs; _violations = violations; + _permits = permits; _permitTypes = permitTypes; _hydrants = hydrants; _flowTests = flowTests; _occupancies = occupancies; _crr = crr; + _unitsService = unitsService; _groupsService = groupsService; _departmentsService = departmentsService; + } + + public Task IsModuleEnabledAsync(int departmentId) => _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Analytics); + + #region Window, scope and dataset + + /// The effective window after clamping, the viewer's scope and the department time zone used for bucketing. + private sealed class Context + { + public int DepartmentId; + public string UserId; + public DateTime Start; + public DateTime End; + public DateTime Now; + public int? StationGroupId; + public string DefinitionKey; + public int TurnoutTarget; + public int TravelTarget; + public List Visible; + public string TimeZone; + public List Warnings = new List(); + public bool GroupScoped => Visible != null; + public TimeSpan Span => End - Start; + } + + /// One unit's attested response on one Record, from either aggregate, in one shape. + private sealed class UnitTiming + { + public string RecordKey; + public string Source; + public string DefinitionKey; + public int? UnitId; + public string UnitName; + public int? GroupId; + public DateTime? Dispatched; + public DateTime? Enroute; + public DateTime? OnScene; + public DateTime? Cleared; + public DateTime When; + public double? Turnout => Seconds(Dispatched, Enroute); + public double? Travel => Seconds(Enroute, OnScene); + public double? Total => Seconds(Dispatched, OnScene); + public double? TimeOnScene => Seconds(OnScene, Cleared); + public bool Usable => Turnout.HasValue || Travel.HasValue || Total.HasValue || TimeOnScene.HasValue; + } + + private sealed class Dataset + { + public List Records = new List(); + public Dictionary RevisionOf = new Dictionary(StringComparer.Ordinal); + public ILookup ParticipantsByRecord; + public List Units = new List(); + public List Reports = new List(); + public List ReportUnits = new List(); + public List Timings = new List(); + public bool Truncated; + public int RecordsRead => Records.Count + Reports.Count; + + public DateTime OccurredOn(RmsOperationalRecord r) => r.StartedOn ?? r.FinalizedOn ?? r.CreatedOn; + public DateTime OccurredOn(RmsIncidentReport r) => r.CallCreatedOn ?? r.FinalizedOn ?? r.CreatedOn; + } + + private async Task BeginAsync(int departmentId, string userId, RecordsAnalyticsQuery query) + { + await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Analytics); + await _gate.RequireViewerAsync(departmentId, userId); + query ??= new RecordsAnalyticsQuery(); + var now = DateTime.UtcNow; + var end = query.End ?? now; + var start = query.Start ?? end.AddDays(-RecordsAnalyticsLimits.DefaultWindowDays); + var context = new Context { DepartmentId = departmentId, UserId = userId, Now = now, StationGroupId = query.StationGroupId, DefinitionKey = string.IsNullOrWhiteSpace(query.DefinitionKey) ? null : query.DefinitionKey.Trim(), TurnoutTarget = Math.Max(0, query.TurnoutTargetSeconds), TravelTarget = Math.Max(0, query.TravelTargetSeconds) }; + if (start >= end) start = end.AddDays(-1); + if (end - start > TimeSpan.FromDays(RecordsAnalyticsLimits.MaxWindowDays)) + { + start = end.AddDays(-RecordsAnalyticsLimits.MaxWindowDays); + context.Warnings.Add($"The window was clamped to {RecordsAnalyticsLimits.MaxWindowDays} days."); + } + context.Start = DateTime.SpecifyKind(start, DateTimeKind.Utc); + context.End = DateTime.SpecifyKind(end, DateTimeKind.Utc); + context.Visible = (await _authorization.GetVisibleGroupIdsAsync(userId, departmentId))?.ToList(); + try { context.TimeZone = (await _departmentsService.GetDepartmentByIdAsync(departmentId, false))?.TimeZone; } + catch (Exception ex) { Logging.LogException(ex, $"Records analytics: department time zone unavailable for {departmentId}; bucketing in UTC."); } + return context; + } + + private T Finish(T result, Context context, Dataset data) where T : RecordsAnalyticsBase + { + result.DepartmentId = context.DepartmentId; result.Start = context.Start; result.End = context.End; result.GroupScoped = context.GroupScoped; + result.Truncated = data?.Truncated ?? false; result.RecordsRead = data?.RecordsRead ?? 0; + if (result.Truncated) result.Warnings.Add($"More than {RecordsAnalyticsLimits.RowCap:N0} Records fall in this window; the figures cover the first {RecordsAnalyticsLimits.RowCap:N0} by occurrence. Narrow the window."); + result.Warnings.AddRange(context.Warnings); + return result; + } + + /// Loads the finalized Records in the window, scoped to the viewer, plus their attested unit and participant rows. + private async Task LoadAsync(Context c, DateTime start, DateTime end, bool includeReports) + { + var data = new Dataset(); + var rows = ((await _records.GetFinalizedInRangeAsync(c.DepartmentId, OperationalFinal, start, end, RecordsAnalyticsLimits.RowCap + 1)) ?? Enumerable.Empty()) + .Where(r => r != null && r.DeletedOn == null && r.PurgedOn == null && !string.IsNullOrEmpty(r.CurrentRevisionId)).ToList(); + if (rows.Count > RecordsAnalyticsLimits.RowCap) { data.Truncated = true; rows = rows.Take(RecordsAnalyticsLimits.RowCap).ToList(); } + if (c.DefinitionKey != null) rows = rows.Where(r => string.Equals(r.DefinitionKey, c.DefinitionKey, StringComparison.OrdinalIgnoreCase)).ToList(); + if (c.StationGroupId.HasValue) rows = rows.Where(r => r.StationGroupId == c.StationGroupId).ToList(); + + var revisionIds = rows.Select(r => r.CurrentRevisionId).Distinct().ToList(); + var participants = ((await _participants.GetForRevisionsAsync(c.DepartmentId, revisionIds)) ?? Enumerable.Empty()).Where(p => p != null && p.DeletedOn == null).ToList(); + var participantsByRecord = participants.ToLookup(p => p.RecordId, StringComparer.Ordinal); + if (c.GroupScoped) + { + var scopes = ((await _scopes.GetForRecordsAsync(c.DepartmentId, rows.Select(r => r.RmsOperationalRecordId))) ?? Enumerable.Empty()).ToLookup(s => s.RecordId, StringComparer.Ordinal); + rows = rows.Where(r => RecordsReportingService.IsVisible(r, participantsByRecord[r.RmsOperationalRecordId], scopes[r.RmsOperationalRecordId], c.Visible, c.UserId)).ToList(); + var kept = rows.Select(r => r.RmsOperationalRecordId).ToHashSet(StringComparer.Ordinal); + participants = participants.Where(p => kept.Contains(p.RecordId)).ToList(); + participantsByRecord = participants.ToLookup(p => p.RecordId, StringComparer.Ordinal); + revisionIds = rows.Select(r => r.CurrentRevisionId).Distinct().ToList(); + } + data.Records = rows; + foreach (var r in rows) data.RevisionOf[r.RmsOperationalRecordId] = r.CurrentRevisionId; + data.ParticipantsByRecord = participantsByRecord; + data.Units = ((await _units.GetForRevisionsAsync(c.DepartmentId, revisionIds)) ?? Enumerable.Empty()).Where(u => u != null && u.DeletedOn == null && data.RevisionOf.ContainsKey(u.RecordId)).ToList(); + var byRecord = rows.ToDictionary(r => r.RmsOperationalRecordId, StringComparer.Ordinal); + foreach (var u in data.Units) + { + var record = byRecord[u.RecordId]; + data.Timings.Add(new UnitTiming { RecordKey = "o:" + u.RecordId, Source = "operational", DefinitionKey = record.DefinitionKey, UnitId = u.UnitId, UnitName = u.UnitNameSnapshot, GroupId = u.StationGroupIdSnapshot ?? record.StationGroupId, Dispatched = u.Dispatched, Enroute = u.Enroute, OnScene = u.OnScene, Cleared = u.Released ?? u.InQuarters, When = u.Dispatched ?? data.OccurredOn(record) }); + } + + if (includeReports) + { + var reports = ((await _reports.QueryAsync(c.DepartmentId, new RmsIncidentReportQuery { States = ReportFinal, OccurredOnStart = start, OccurredOnEnd = end, StationGroupId = c.StationGroupId, VisibleGroupIds = c.Visible, ViewerUserId = c.UserId, Skip = 0, Take = ReportPage })) ?? Enumerable.Empty()) + .Where(r => r != null && r.DeletedOn == null && r.PurgedOn == null && !string.IsNullOrEmpty(r.CurrentRevisionId)).ToList(); + if (reports.Count >= ReportPage) { data.Truncated = true; } + if (c.DefinitionKey != null && !string.Equals(c.DefinitionKey, RmsDefinitionKeys.NerisIncidentReport, StringComparison.OrdinalIgnoreCase)) reports = new List(); + data.Reports = reports; + var reportByRevision = reports.ToDictionary(r => r.CurrentRevisionId, StringComparer.Ordinal); + data.ReportUnits = ((await _reportUnits.GetForRevisionsAsync(c.DepartmentId, reports.Select(r => r.CurrentRevisionId))) ?? Enumerable.Empty()).Where(u => u != null && !u.UnableToDispatch && u.CanceledEnrouteOn == null && reportByRevision.ContainsKey(u.RevisionId)).ToList(); + foreach (var u in data.ReportUnits) + { + var report = reportByRevision[u.RevisionId]; + data.Timings.Add(new UnitTiming { RecordKey = "i:" + u.RecordId, Source = "incident", DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, UnitId = u.UnitId, UnitName = u.UnitNameSnapshot, GroupId = u.StationGroupIdSnapshot ?? report.StationGroupId, Dispatched = u.DispatchedOn, Enroute = u.EnrouteOn, OnScene = u.OnSceneOn, Cleared = u.ClearedOn, When = u.DispatchedOn ?? data.OccurredOn(report) }); + } + } + return data; + } + + #endregion + + #region Math + + private static double? Seconds(DateTime? from, DateTime? to) + { + if (!from.HasValue || !to.HasValue || to.Value < from.Value) return null; + var span = to.Value - from.Value; + return span > MaxInterval ? (double?)null : span.TotalSeconds; + } + + private static double Hours(DateTime? from, DateTime? to) + { + if (!from.HasValue || !to.HasValue || to.Value <= from.Value) return 0; + var span = to.Value - from.Value; + return (span > MaxDuration ? MaxDuration : span).TotalHours; + } + + private static double? Percentile(List sorted, double p) + { + if (sorted.Count == 0) return null; + var rank = (int)Math.Ceiling(p * sorted.Count) - 1; + return sorted[Math.Clamp(rank, 0, sorted.Count - 1)]; + } + + private static double? Median(IEnumerable values) + { + var sorted = values.OrderBy(v => v).ToList(); + if (sorted.Count == 0) return null; + return sorted.Count % 2 == 1 ? sorted[sorted.Count / 2] : (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2.0; + } + + private static double? Pct(int part, int whole) => whole == 0 ? (double?)null : Math.Round(100.0 * part / whole, 1); + + public static RecordsResponseTimeStat Stat(IEnumerable samples, int? targetSeconds) + { + var sorted = samples.Where(v => v.HasValue).Select(v => v.Value).OrderBy(v => v).ToList(); + var stat = new RecordsResponseTimeStat { Samples = sorted.Count, TargetSeconds = targetSeconds > 0 ? targetSeconds : null }; + if (sorted.Count == 0) return stat; + stat.AverageSeconds = Math.Round(sorted.Average(), 1); + stat.MedianSeconds = Math.Round(Median(sorted).Value, 1); + stat.P90Seconds = Math.Round(Percentile(sorted, 0.9).Value, 1); + stat.MaxSeconds = sorted[sorted.Count - 1]; + if (stat.TargetSeconds.HasValue) + { + stat.WithinTarget = sorted.Count(v => v <= stat.TargetSeconds.Value); + stat.WithinTargetPercent = Pct(stat.WithinTarget, sorted.Count); + } + return stat; + } + + private RecordsResponseTimeRow Row(string key, string label, List timings, Context c) => new RecordsResponseTimeRow + { + Key = key, Label = label, Responses = timings.Count, + Turnout = Stat(timings.Select(t => t.Turnout), c.TurnoutTarget), Travel = Stat(timings.Select(t => t.Travel), c.TravelTarget), + TotalResponse = Stat(timings.Select(t => t.Total), c.TurnoutTarget + c.TravelTarget), OnScene = Stat(timings.Select(t => t.TimeOnScene), null) + }; + + private static List FirstArrivals(IEnumerable timings) => timings.GroupBy(t => t.RecordKey, StringComparer.Ordinal) + .Select(g => Seconds(g.Where(t => t.Dispatched.HasValue).Min(t => t.Dispatched), g.Where(t => t.OnScene.HasValue).Min(t => t.OnScene))).ToList(); + + private DateTime Local(DateTime utc, Context c) + { + if (string.IsNullOrWhiteSpace(c.TimeZone)) return utc; + try { return DateTimeHelpers.GetLocalDateTime(utc, c.TimeZone); } + catch { return utc; } + } + + private string Month(DateTime utc, Context c) => Local(utc, c).ToString("yyyy-MM"); + + private static List Counts(IEnumerable items, Func key, Func label, Func hours = null, bool withPercent = true, IComparer order = null) + { + var list = items.Where(i => i != null && key(i) != null).ToList(); + var total = list.Count; + var rows = list.GroupBy(key, StringComparer.Ordinal).Select(g => new RecordsAnalyticsCount { Key = g.Key, Label = label?.Invoke(g.Key) ?? g.Key, Count = g.Count(), Hours = hours == null ? 0 : Math.Round(g.Sum(hours), 2), Percent = withPercent ? Pct(g.Count(), total) : null }); + return (order == null ? rows.OrderByDescending(r => r.Count).ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase) : rows.OrderBy(r => r.Key, order)).ToList(); + } + + private static string EnumLabel(int value) where TEnum : struct, Enum + { + var name = System.Enum.IsDefined(typeof(TEnum), value) ? System.Enum.GetName(typeof(TEnum), value) : value.ToString(); + return SplitPascal.Replace(name, " "); + } + + private static string DefinitionLabel(string key) + { + if (string.IsNullOrWhiteSpace(key)) return "-"; + if (RmsDefinitionKeys.LockedTypes.TryGetValue(key, out var type)) return SplitPascal.Replace(type.ToString(), " "); + return string.Equals(key, RmsDefinitionKeys.NerisIncidentReport, StringComparison.OrdinalIgnoreCase) ? "NERIS incident report" : key; + } + + private static async Task SectionAsync(RecordsAnalyticsBase result, string area, Func work) + { + try { await work(); } + catch (Exception ex) + { + Logging.LogException(ex, $"Records analytics: {area} unavailable for department {result.DepartmentId}."); + result.Warnings.Add($"{area} unavailable."); + } + } + + #endregion + + #region Labels + + private sealed class Labels + { + public Dictionary Units = new Dictionary(); + public Dictionary Groups = new Dictionary(); + public Dictionary People = new Dictionary(StringComparer.OrdinalIgnoreCase); + public string Unit(string key, string fallback = null) => int.TryParse(key, out var id) && Units.TryGetValue(id, out var n) ? n : (fallback ?? $"Unit {key}"); + public string Group(string key) => int.TryParse(key, out var id) && Groups.TryGetValue(id, out var n) ? n : (key == "0" ? "No group" : $"Group {key}"); + public string Person(string key) => People.TryGetValue(key ?? string.Empty, out var n) ? n : key; + } + + private async Task LabelsAsync(int departmentId, bool units, bool groups, bool people) + { + var labels = new Labels(); + try + { + if (units) foreach (var u in (await _unitsService.GetUnitsForDepartmentAsync(departmentId)) ?? new List()) labels.Units[u.UnitId] = u.Name; + if (groups) foreach (var g in (await _groupsService.GetAllGroupsForDepartmentAsync(departmentId)) ?? new List()) labels.Groups[g.DepartmentGroupId] = g.Name; + if (people) foreach (var p in (await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(departmentId)) ?? new List()) if (!string.IsNullOrEmpty(p.UserId)) labels.People[p.UserId] = p.Name; + } + catch (Exception ex) { Logging.LogException(ex, $"Records analytics: name lookup failed for department {departmentId}; keys are shown instead."); } + return labels; + } + + #endregion + + #region Response performance + + public async Task GetResponsePerformanceAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default) + { + var c = await BeginAsync(departmentId, userId, query); + var data = await LoadAsync(c, c.Start, c.End, true); + var result = new RecordsResponsePerformance(); + await SectionAsync(result, "Response times", async () => + { + var labels = await LabelsAsync(departmentId, true, true, false); + FillResponse(result, data, c, labels); + }); + return Finish(result, c, data); + } + + private void FillResponse(RecordsResponsePerformance r, Dataset data, Context c, Labels labels) + { + var usable = data.Timings.Where(t => t.Usable).ToList(); + r.Responses = usable.Count; + r.RecordsWithResponses = usable.Select(t => t.RecordKey).Distinct(StringComparer.Ordinal).Count(); + r.Turnout = Stat(usable.Select(t => t.Turnout), c.TurnoutTarget); + r.Travel = Stat(usable.Select(t => t.Travel), c.TravelTarget); + r.TotalResponse = Stat(usable.Select(t => t.Total), c.TurnoutTarget + c.TravelTarget); + r.OnScene = Stat(usable.Select(t => t.TimeOnScene), null); + r.FirstArrival = Stat(FirstArrivals(usable), c.TurnoutTarget + c.TravelTarget); + r.ByUnit = usable.Where(t => t.UnitId.HasValue).GroupBy(t => t.UnitId.Value).Select(g => Row(g.Key.ToString(), labels.Unit(g.Key.ToString(), g.Select(t => t.UnitName).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n))), g.ToList(), c)).OrderByDescending(x => x.Responses).ToList(); + r.ByStationGroup = usable.GroupBy(t => t.GroupId ?? 0).Select(g => Row(g.Key.ToString(), labels.Group(g.Key.ToString()), g.ToList(), c)).OrderByDescending(x => x.Responses).ToList(); + r.ByHourOfDay = Enumerable.Range(0, 24).Select(h => Row(h.ToString("00"), h.ToString("00") + ":00", usable.Where(t => Local(t.When, c).Hour == h).ToList(), c)).ToList(); + r.ByMonth = usable.GroupBy(t => Month(t.When, c)).OrderBy(g => g.Key, StringComparer.Ordinal).Select(g => Row(g.Key, g.Key, g.ToList(), c)).ToList(); + r.BySource = usable.GroupBy(t => t.Source, StringComparer.Ordinal).Select(g => Row(g.Key, g.Key == "incident" ? DefinitionLabel(RmsDefinitionKeys.NerisIncidentReport) : "Operational records", g.ToList(), c)).ToList(); + } + + #endregion + + #region Workload + + public async Task GetWorkloadAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default) + { + var c = await BeginAsync(departmentId, userId, query); + var data = await LoadAsync(c, c.Start, c.End, true); + var result = new RecordsWorkload(); + await SectionAsync(result, "Workload", async () => + { + var labels = await LabelsAsync(departmentId, true, true, true); + FillWorkload(result, data, c, labels); + }); + return Finish(result, c, data); + } + + private static double RecordHours(RmsOperationalRecord r) => Hours(r.StartedOn, r.EndedOn); + + private static double ParticipantHours(RmsRecordParticipant p, RmsOperationalRecord r) => p.ParticipationStart.HasValue && p.ParticipationEnd.HasValue ? Hours(p.ParticipationStart, p.ParticipationEnd) : RecordHours(r); + + private void FillWorkload(RecordsWorkload w, Dataset data, Context c, Labels labels) + { + var byRecord = data.Records.ToDictionary(r => r.RmsOperationalRecordId, StringComparer.Ordinal); + w.RecordsFinalized = data.Records.Count; + w.IncidentReportsFinalized = data.Reports.Count; + w.RecordHours = Math.Round(data.Records.Sum(RecordHours), 2); + var people = data.Records.SelectMany(r => data.ParticipantsByRecord[r.RmsOperationalRecordId].Where(p => !string.IsNullOrEmpty(p.UserId)).Select(p => (p.UserId, Record: r, Hours: ParticipantHours(p, r)))).ToList(); + w.PersonnelHours = Math.Round(people.Sum(x => x.Hours), 2); + w.TrainingHours = Math.Round(people.Where(x => string.Equals(x.Record.DefinitionKey, RmsDefinitionKeys.Training, StringComparison.OrdinalIgnoreCase)).Sum(x => x.Hours), 2); + var usable = data.Timings.Where(t => t.Usable).ToList(); + w.UnitResponses = usable.Count; + w.UnitOnSceneHours = Math.Round(usable.Sum(t => (t.TimeOnScene ?? 0) / 3600.0), 2); + w.ByDefinition = Counts(data.Records, r => r.DefinitionKey, DefinitionLabel, RecordHours); + if (data.Reports.Count > 0) w.ByDefinition.Add(new RecordsAnalyticsCount { Key = RmsDefinitionKeys.NerisIncidentReport, Label = DefinitionLabel(RmsDefinitionKeys.NerisIncidentReport), Count = data.Reports.Count, Percent = null }); + w.ByMonth = Counts(data.Records.Select(r => (Key: Month(data.OccurredOn(r), c), Hours: RecordHours(r))).Concat(data.Reports.Select(r => (Key: Month(data.OccurredOn(r), c), Hours: 0.0))), x => x.Key, k => k, x => x.Hours, false, StringComparer.Ordinal); + w.ByAuthor = Counts(data.Records, r => r.AuthorUserId, labels.Person, RecordHours); + w.ByStationGroup = Counts(data.Records.Select(r => (Key: (r.StationGroupId ?? 0).ToString(), Hours: RecordHours(r))).Concat(data.Reports.Select(r => (Key: (r.StationGroupId ?? 0).ToString(), Hours: 0.0))), x => x.Key, labels.Group, x => x.Hours); + w.ByUnit = Counts(usable.Where(t => t.UnitId.HasValue), t => t.UnitId.Value.ToString(), k => labels.Unit(k, usable.Where(t => t.UnitId.ToString() == k).Select(t => t.UnitName).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n))), t => (t.TimeOnScene ?? 0) / 3600.0); + w.ByPerson = Counts(people, x => x.UserId, labels.Person, x => x.Hours); + var heat = new int[7, 24]; + foreach (var when in data.Records.Select(r => data.OccurredOn(r)).Concat(data.Reports.Select(r => data.OccurredOn(r)))) + { + var local = Local(when, c); + heat[(int)local.DayOfWeek, local.Hour]++; + } + for (var d = 0; d < 7; d++) for (var h = 0; h < 24; h++) w.ByWeekdayHour.Add(new RecordsAnalyticsHeatCell { Weekday = d, Hour = h, Count = heat[d, h] }); + } + + #endregion + + #region Executive summary + + public async Task GetExecutiveSummaryAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default) + { + var c = await BeginAsync(departmentId, userId, query); + var data = await LoadAsync(c, c.Start, c.End, true); + var result = new RecordsExecutiveSummary { PriorStart = c.Start - c.Span, PriorEnd = c.Start }; + Dataset prior = null; + await SectionAsync(result, "Prior period", async () => { prior = await LoadAsync(c, result.PriorStart, result.PriorEnd, true); }); + await SectionAsync(result, "Headline figures", async () => + { + var current = await KpisAsync(c, data, c.Start, c.End); + var before = prior == null ? new Dictionary() : await KpisAsync(c, prior, result.PriorStart, result.PriorEnd); + foreach (var kv in current) + { + before.TryGetValue(kv.Key, out var p); + result.Kpis.Add(new RecordsKpi { Key = kv.Key, Current = kv.Value, Prior = p, ChangePercent = p == 0 ? (double?)null : Math.Round(100.0 * (kv.Value - p) / p, 1) }); + } + }); + await SectionAsync(result, "Lifecycle measures", () => + { + result.MedianHoursToFinalize = Round(Median(data.Records.Where(r => r.FinalizedOn.HasValue && r.FinalizedOn >= r.CreatedOn).Select(r => (r.FinalizedOn.Value - r.CreatedOn).TotalHours))); + result.MedianReviewHours = Round(Median(data.Records.Where(r => r.SubmittedForReviewOn.HasValue && r.ApprovedOn.HasValue && r.ApprovedOn >= r.SubmittedForReviewOn).Select(r => (r.ApprovedOn.Value - r.SubmittedForReviewOn.Value).TotalHours))); + result.ReturnRatePercent = Pct(data.Records.Count(r => r.ReturnCount > 0), data.Records.Count); + var accepted = data.Reports.Count(r => r.AcceptedOn.HasValue); + var rejected = data.Reports.Count(r => r.RejectedOn.HasValue && !r.AcceptedOn.HasValue); + result.NerisAcceptanceRatePercent = Pct(accepted, accepted + rejected); + var usable = data.Timings.Where(t => t.Usable).ToList(); + result.TurnoutP90Seconds = Stat(usable.Select(t => t.Turnout), null).P90Seconds; + result.TotalResponseP90Seconds = Stat(usable.Select(t => t.Total), null).P90Seconds; + result.FirstArrivalP90Seconds = Stat(FirstArrivals(usable), null).P90Seconds; + result.ByDefinition = Counts(data.Records, r => r.DefinitionKey, DefinitionLabel, RecordHours); + if (data.Reports.Count > 0) result.ByDefinition.Add(new RecordsAnalyticsCount { Key = RmsDefinitionKeys.NerisIncidentReport, Label = DefinitionLabel(RmsDefinitionKeys.NerisIncidentReport), Count = data.Reports.Count }); + result.ByMonth = Counts(data.Records.Select(r => Month(data.OccurredOn(r), c)).Concat(data.Reports.Select(r => Month(data.OccurredOn(r), c))), k => k, k => k, null, false, StringComparer.Ordinal); + return Task.CompletedTask; + }); + await SectionAsync(result, "Queues and obligations", async () => + { + result.OpenDrafts = await _records.CountVisibleAsync(c.DepartmentId, new[] { (int)RmsRecordState.Draft }, c.Visible, c.UserId); + result.AwaitingReview = await _records.CountVisibleAsync(c.DepartmentId, new[] { (int)RmsRecordState.ReadyForReview }, c.Visible, c.UserId); + result.OverdueNow = await _dueStates.CountVisibleOverdueAsync(c.DepartmentId, c.Visible, c.UserId); + result.WentOverdue = await WentOverdueAsync(c, data, c.Start, c.End); + }); + return Finish(result, c, data); + } + + private static double? Round(double? value) => value.HasValue ? Math.Round(value.Value, 1) : (double?)null; + + private async Task WentOverdueAsync(Context c, Dataset data, DateTime start, DateTime end) + { + var rows = ((await _dueStates.GetChangedInRangeAsync(c.DepartmentId, start, end, RecordsAnalyticsLimits.RowCap)) ?? Enumerable.Empty()) + .Where(d => d.OverdueCount > 0 && d.LastEmittedOn.HasValue && d.LastEmittedOn >= start && d.LastEmittedOn < end && (d.LastEmittedState == (int)RmsDueState.Overdue || d.LastEmittedState == (int)RmsDueState.Cleared)); + if (!c.GroupScoped) return rows.Count(); + // A group-scoped viewer only counts obligations on Records they can open. + var mine = data.Records.Select(r => r.RmsOperationalRecordId).Concat(data.Reports.Select(r => r.RmsIncidentReportId)).ToHashSet(StringComparer.Ordinal); + return rows.Count(d => mine.Contains(d.RecordId)); + } + + private async Task> KpisAsync(Context c, Dataset data, DateTime start, DateTime end) + { + var k = new Dictionary(StringComparer.Ordinal) + { + ["finalized"] = data.Records.Count, + ["incidentReports"] = data.Reports.Count, + ["nerisAccepted"] = data.Reports.Count(r => r.AcceptedOn.HasValue), + ["nerisRejected"] = data.Reports.Count(r => r.RejectedOn.HasValue && !r.AcceptedOn.HasValue), + ["unitResponses"] = data.Timings.Count(t => t.Usable) + }; + var people = data.Records.SelectMany(r => data.ParticipantsByRecord[r.RmsOperationalRecordId].Select(p => (Record: r, Hours: ParticipantHours(p, r)))).ToList(); + k["personnelHours"] = Math.Round(people.Sum(x => x.Hours), 1); + k["trainingHours"] = Math.Round(people.Where(x => string.Equals(x.Record.DefinitionKey, RmsDefinitionKeys.Training, StringComparison.OrdinalIgnoreCase)).Sum(x => x.Hours), 1); + var transitions = ((await _revisions.GetTransitionsInRangeAsync(c.DepartmentId, start, end, RecordsAnalyticsLimits.RowCap)) ?? Enumerable.Empty()).ToList(); + if (c.GroupScoped) + { + var mine = data.Records.Select(r => r.RmsOperationalRecordId).Concat(data.Reports.Select(r => r.RmsIncidentReportId)).ToHashSet(StringComparer.Ordinal); + transitions = transitions.Where(t => mine.Contains(t.RecordId)).ToList(); + } + k["amended"] = transitions.Count(t => t.Transition == (int)RmsRevisionTransition.Amended); + k["voided"] = transitions.Count(t => t.Transition == (int)RmsRevisionTransition.Voided); + return k; + } + + #endregion + + #region Accreditation + + public async Task GetAccreditationAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default) + { + var c = await BeginAsync(departmentId, userId, query); + var data = await LoadAsync(c, c.Start, c.End, true); + var result = new RecordsAccreditation + { + OccupancyEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Occupancy), + InspectionsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections), + HydrantsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Hydrants), + PermitsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Permits) + }; + await SectionAsync(result, "Training", () => { result.Training = TrainingMetrics(data, c); return Task.CompletedTask; }); + await SectionAsync(result, "Response summary", () => { result.Response = ResponseSummary(data, c); return Task.CompletedTask; }); + if (result.InspectionsEnabled) + { + await SectionAsync(result, "Inspections", async () => { result.Inspections = await InspectionMetricsAsync(c); }); + await SectionAsync(result, "Violations", async () => { result.Violations = await ViolationMetricsAsync(c); }); + } + if (result.PermitsEnabled) await SectionAsync(result, "Permits", async () => { result.Permits = await PermitMetricsAsync(c); }); + if (result.HydrantsEnabled) await SectionAsync(result, "Hydrants", async () => { result.Hydrants = await HydrantMetricsAsync(c); }); + if (result.OccupancyEnabled) await SectionAsync(result, "Occupancies", async () => { result.Occupancies = await OccupancyMetricsAsync(c, result.InspectionsEnabled); }); + return Finish(result, c, data); + } + + private RecordsTrainingMetrics TrainingMetrics(Dataset data, Context c) + { + var training = data.Records.Where(r => string.Equals(r.DefinitionKey, RmsDefinitionKeys.Training, StringComparison.OrdinalIgnoreCase)).ToList(); + var people = training.SelectMany(r => data.ParticipantsByRecord[r.RmsOperationalRecordId].Where(p => !string.IsNullOrEmpty(p.UserId)).Select(p => (p.UserId, Record: r, Hours: ParticipantHours(p, r)))).ToList(); + var members = people.Select(x => x.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + var hours = Math.Round(people.Sum(x => x.Hours), 2); + return new RecordsTrainingMetrics + { + Records = training.Count, Hours = hours, MembersTrained = members, HoursPerMember = members == 0 ? (double?)null : Math.Round(hours / members, 2), + ByMonth = Counts(people.Select(x => (Key: Month(data.OccurredOn(x.Record), c), x.Hours)), x => x.Key, k => k, x => x.Hours, false, StringComparer.Ordinal) + }; + } + + private RecordsResponseSummary ResponseSummary(Dataset data, Context c) + { + var usable = data.Timings.Where(t => t.Usable).ToList(); + var turnout = Stat(usable.Select(t => t.Turnout), c.TurnoutTarget); + var travel = Stat(usable.Select(t => t.Travel), c.TravelTarget); + return new RecordsResponseSummary + { + Responses = usable.Count, TurnoutP90Seconds = turnout.P90Seconds, TravelP90Seconds = travel.P90Seconds, + TotalResponseP90Seconds = Stat(usable.Select(t => t.Total), null).P90Seconds, FirstArrivalP90Seconds = Stat(FirstArrivals(usable), null).P90Seconds, + TurnoutWithinTargetPercent = turnout.WithinTargetPercent, TravelWithinTargetPercent = travel.WithinTargetPercent + }; + } + + private async Task InspectionMetricsAsync(Context c) + { + var rows = ((await _inspections.GetForRangeAsync(c.DepartmentId, c.Start, c.End, PreventionPage)) ?? Enumerable.Empty()).Where(i => i != null && i.DeletedOn == null).ToList(); + var completed = rows.Where(i => i.CompletedOn.HasValue && i.CompletedOn >= c.Start && i.CompletedOn < c.End).ToList(); + var programs = ((await _programs.GetForDepartmentAsync(c.DepartmentId, true)) ?? Enumerable.Empty()).ToDictionary(p => p.RmsInspectionProgramId, p => p.Name, StringComparer.Ordinal); + var m = new RecordsInspectionMetrics + { + Completed = completed.Count, + Passed = completed.Count(i => i.Result == (int)RmsInspectionResult.Pass), + Failed = completed.Count(i => i.Result == (int)RmsInspectionResult.Fail), + Conditional = completed.Count(i => i.Result == (int)RmsInspectionResult.Conditional), + Cancelled = rows.Count(i => i.State == (int)RmsInspectionState.Cancelled), + ReinspectionsRequired = rows.Count(i => i.State == (int)RmsInspectionState.ReinspectionRequired || !string.IsNullOrEmpty(i.ParentInspectionId)), + CompletedOnSchedule = completed.Count(i => !i.ScheduledOn.HasValue || i.CompletedOn.Value.Date <= i.ScheduledOn.Value.Date), + OpenNow = await _inspections.CountAsync(c.DepartmentId, new RmsInspectionQuery { States = new List { (int)RmsInspectionState.Scheduled, (int)RmsInspectionState.InProgress, (int)RmsInspectionState.ReinspectionRequired } }), + OverdueNow = await _inspections.CountAsync(c.DepartmentId, new RmsInspectionQuery { States = new List { (int)RmsInspectionState.Scheduled }, ScheduledBefore = c.Now }), + ByProgram = Counts(completed, i => i.RmsInspectionProgramId ?? "none", k => k == "none" ? "No program" : programs.TryGetValue(k, out var n) ? n : k), + ByMonth = Counts(completed.Select(i => Month(i.CompletedOn.Value, c)), k => k, k => k, null, false, StringComparer.Ordinal) + }; + var scored = m.Passed + m.Failed + m.Conditional; + m.PassRatePercent = Pct(m.Passed, scored); + m.OnSchedulePercent = Pct(m.CompletedOnSchedule, completed.Count); + var lead = completed.Where(i => i.ScheduledOn.HasValue).Select(i => (i.CompletedOn.Value - i.ScheduledOn.Value).TotalDays).ToList(); + m.AverageDaysScheduledToCompleted = lead.Count == 0 ? (double?)null : Math.Round(lead.Average(), 1); + return m; + } + + private async Task ViolationMetricsAsync(Context c) + { + var rows = ((await _violations.GetForRangeAsync(c.DepartmentId, c.Start, c.End, PreventionPage)) ?? Enumerable.Empty()).Where(v => v != null && v.DeletedOn == null).ToList(); + var corrected = rows.Where(v => v.CorrectedOn.HasValue && v.CorrectedOn >= v.CreatedOn).ToList(); + return new RecordsViolationMetrics + { + Opened = rows.Count, + Corrected = corrected.Count, + Verified = rows.Count(v => v.State == (int)RmsViolationState.Verified), + Waived = rows.Count(v => v.State == (int)RmsViolationState.Waived), + Escalated = rows.Count(v => v.State == (int)RmsViolationState.Escalated), + OpenNow = await _violations.CountOpenAsync(c.DepartmentId), + OverdueNow = await _violations.CountOverdueAsync(c.DepartmentId, c.Now), + AverageDaysToCorrection = corrected.Count == 0 ? (double?)null : Math.Round(corrected.Average(v => (v.CorrectedOn.Value - v.CreatedOn).TotalDays), 1), + BySeverity = Counts(rows, v => v.Severity.ToString(), k => EnumLabel(int.Parse(k))) + }; + } + + private async Task PermitMetricsAsync(Context c) + { + var rows = ((await _permits.GetForRangeAsync(c.DepartmentId, c.Start, c.End, PreventionPage)) ?? Enumerable.Empty()).Where(p => p != null && p.DeletedOn == null).ToList(); + var types = ((await _permitTypes.GetForDepartmentAsync(c.DepartmentId, true)) ?? Enumerable.Empty()).ToDictionary(t => t.RmsPermitTypeId, t => t.Name, StringComparer.Ordinal); + var issued = rows.Where(p => p.IssuedOn.HasValue && p.IssuedOn >= p.AppliedOn).ToList(); + return new RecordsPermitMetrics + { + Applied = rows.Count, + Issued = issued.Count, + Denied = rows.Count(p => p.State == (int)RmsPermitState.Denied), + Expired = rows.Count(p => p.State == (int)RmsPermitState.Expired), + Revoked = rows.Count(p => p.State == (int)RmsPermitState.Revoked), + ActiveNow = await _permits.CountAsync(c.DepartmentId, new RmsPermitQuery { States = new List { (int)RmsPermitState.Issued } }), + AverageDaysAppliedToIssued = issued.Count == 0 ? (double?)null : Math.Round(issued.Average(p => (p.IssuedOn.Value - p.AppliedOn).TotalDays), 1), + ByType = Counts(rows, p => p.RmsPermitTypeId ?? "none", k => k == "none" ? "No type" : types.TryGetValue(k, out var n) ? n : k) + }; + } + + private async Task HydrantMetricsAsync(Context c) + { + var rows = ((await _hydrants.GetAllLiveAsync(c.DepartmentId)) ?? Enumerable.Empty()).Where(h => h != null && h.DeletedOn == null).ToList(); + var yearAgo = c.Now.AddDays(-365); + var m = new RecordsHydrantMetrics + { + Total = rows.Count, + InService = rows.Count(h => h.InService), + OutOfService = rows.Count(h => !h.InService), + TestedWithin12Months = rows.Count(h => h.LastTestedOn.HasValue && h.LastTestedOn >= yearAgo), + FlowTestsInWindow = ((await _flowTests.GetForRangeAsync(c.DepartmentId, c.Start, c.End, PreventionPage)) ?? Enumerable.Empty()).Count(), + ByFlowClass = Counts(rows, h => h.FlowClass.ToString(), k => "Class " + EnumLabel(int.Parse(k))) + }; + m.TestedWithin12MonthsPercent = Pct(m.TestedWithin12Months, rows.Count); + return m; + } + + private async Task OccupancyMetricsAsync(Context c, bool inspectionsEnabled) + { + var rows = ((await _occupancies.GetAllLiveAsync(c.DepartmentId)) ?? Enumerable.Empty()).Where(o => o != null && o.DeletedOn == null && o.Status != (int)RmsOccupancyStatus.Merged).ToList(); + var yearAgo = c.Now.AddDays(-365); + var m = new RecordsOccupancyMetrics + { + Total = rows.Count, + ReviewCurrent = rows.Count(o => o.NextReviewDue.HasValue && o.NextReviewDue >= c.Now), + ReviewOverdue = rows.Count(o => o.NextReviewDue.HasValue && o.NextReviewDue < c.Now), + InspectedWithin12Months = rows.Count(o => o.LastInspectedOn.HasValue && o.LastInspectedOn >= yearAgo), + HazmatOnSite = rows.Count(o => o.HazmatOnSite), + NoSprinklers = rows.Count(o => o.SprinklerType == (int)RmsSprinklerType.None), + OccupantsNeedingAssistance = rows.Count(o => o.HasOccupantsNeedingAssistance), + Vacant = rows.Count(o => o.Status == (int)RmsOccupancyStatus.Vacant), + ByType = Counts(rows, o => o.OccupancyType.ToString(), k => EnumLabel(int.Parse(k))) + }; + m.ReviewCurrentPercent = Pct(m.ReviewCurrent, rows.Count); + m.InspectedWithin12MonthsPercent = Pct(m.InspectedWithin12Months, rows.Count); + if (inspectionsEnabled) + { + m.OpenViolations = await _violations.CountOpenAsync(c.DepartmentId); + var open = ((await _violations.GetOpenAsync(c.DepartmentId, ReportPage)) ?? Enumerable.Empty()).ToList(); + m.ViolationsBySeverity = Counts(open, v => v.Severity.ToString(), k => EnumLabel(int.Parse(k))); + } + return m; + } + + #endregion + + #region Community risk + + public async Task GetCommunityRiskAsync(int departmentId, string userId, RecordsAnalyticsQuery query, CancellationToken cancellationToken = default) + { + var c = await BeginAsync(departmentId, userId, query); + var data = await LoadAsync(c, c.Start, c.End, true); + var result = new RecordsCommunityRisk + { + CrrEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Crr), + OccupancyEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Occupancy), + InspectionsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections), + HydrantsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Hydrants) + }; + await SectionAsync(result, "Incidents", async () => { result.Incidents = await IncidentMetricsAsync(c, data); }); + if (result.CrrEnabled) await SectionAsync(result, "Community risk reduction", async () => { result.Crr = await CrrMetricsAsync(c); }); + if (result.OccupancyEnabled) await SectionAsync(result, "Occupancies", async () => { result.Occupancies = await OccupancyMetricsAsync(c, result.InspectionsEnabled); }); + if (result.HydrantsEnabled) await SectionAsync(result, "Hydrants", async () => { result.Hydrants = await HydrantMetricsAsync(c); }); + return Finish(result, c, data); + } + + private async Task IncidentMetricsAsync(Context c, Dataset data) + { + var runs = data.Records.Where(r => r.CallId.HasValue).ToList(); + var contexts = ((await _details.GetCallContextForRevisionsAsync(c.DepartmentId, runs.Select(r => r.CurrentRevisionId))) ?? Enumerable.Empty()).Where(x => x != null).ToList(); + var types = ((await _incidentTypes.GetForRevisionsAsync(c.DepartmentId, data.Reports.Select(r => r.CurrentRevisionId))) ?? Enumerable.Empty()).Where(t => t != null && !string.IsNullOrWhiteSpace(t.TypeCode)).ToList(); + var primary = types.GroupBy(t => t.RevisionId, StringComparer.Ordinal).Select(g => g.OrderByDescending(t => t.IsPrimary).ThenBy(t => t.Ordinal).First()).ToList(); + var occurrences = data.Records.Select(r => data.OccurredOn(r)).Concat(data.Reports.Select(r => data.OccurredOn(r))).ToList(); + return new RecordsIncidentMetrics + { + IncidentReports = data.Reports.Count, + RunRecords = runs.Count, + ByIncidentType = Counts(primary, t => t.TypeCode, k => k.Replace("||", " / ")), + ByCallType = Counts(contexts, x => string.IsNullOrWhiteSpace(x.CallType) ? "unknown" : x.CallType.Trim(), k => k == "unknown" ? "Unknown" : k), + ByMonth = Counts(occurrences.Select(o => Month(o, c)), k => k, k => k, null, false, StringComparer.Ordinal), + ByHourOfDay = Enumerable.Range(0, 24).Select(h => new RecordsAnalyticsCount { Key = h.ToString("00"), Label = h.ToString("00") + ":00", Count = occurrences.Count(o => Local(o, c).Hour == h) }).ToList() + }; + } + + private async Task CrrMetricsAsync(Context c) + { + var rows = ((await _crr.GetForRangeAsync(c.DepartmentId, c.Start, c.End, PreventionPage)) ?? Enumerable.Empty()).Where(a => a != null && a.DeletedOn == null).ToList(); + return new RecordsCrrMetrics + { + Activities = rows.Count, + Audience = rows.Sum(a => a.AudienceCount), + SmokeAlarmsInstalled = rows.Sum(a => a.SmokeAlarmsInstalled), + Hours = Math.Round((double)rows.Sum(a => a.HoursSpent), 2), + ByKind = Counts(rows, a => a.Kind.ToString(), k => EnumLabel(int.Parse(k)), a => (double)a.HoursSpent), + ByMonth = Counts(rows, a => Month(a.OccurredOn, c), k => k, a => (double)a.HoursSpent, false, StringComparer.Ordinal) + }; + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs index 431099d3..79582ad6 100644 --- a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs +++ b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs @@ -144,13 +144,29 @@ public async Task OpenAsync(int departmentId, string userI public async Task UpdateAsync(int departmentId, string userId, RmsInvestigationCase input, CancellationToken cancellationToken = default) { if (input == null) throw new ArgumentNullException(nameof(input)); - var (investigation, _) = await RequireMemberAsync(departmentId, userId, input.RmsInvestigationCaseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); + var (investigation, role) = await RequireMemberAsync(departmentId, userId, input.RmsInvestigationCaseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); RequireOpen(investigation); if (input.RowVersion != 0 && input.RowVersion != investigation.RowVersion) throw new InvalidOperationException("The case changed since it was loaded. Reload it before saving."); var existing = new RmsInvestigationCase { IncidentSummary = investigation.IncidentSummary, CauseDetail = investigation.CauseDetail, OriginDescription = investigation.OriginDescription, Findings = investigation.Findings, ClosureReason = investigation.ClosureReason }; investigation.Title = RecordsPreventionGate.Require(input.Title, 250, "A case needs a title."); investigation.IncidentSummary = RecordsPreventionGate.Trim(input.IncidentSummary, 8000); investigation.RmsOccupancyId = RecordsPreventionGate.Trim(input.RmsOccupancyId, 36); investigation.CallId = input.CallId; - if (!string.IsNullOrWhiteSpace(input.LeadInvestigatorUserId)) investigation.LeadInvestigatorUserId = input.LeadInvestigatorUserId.Trim(); + // Only a lead reassigns the lead. An investigator may edit the case body, so the posted value is + // authoritative for nobody else: a tampered field is denied rather than quietly applied. + var requestedLead = RecordsPreventionGate.Trim(input.LeadInvestigatorUserId, 128); + if (requestedLead != null && requestedLead != investigation.LeadInvestigatorUserId) + { + if (role != RmsInvestigationRole.Lead) + { + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Denied, AuditPurpose, investigation.RmsInvestigationCaseId, + new { reason = "only a lead investigator may reassign the lead investigator" }, successful: false, cancellationToken: cancellationToken); + throw new UnauthorizedAccessException("Only a lead investigator may reassign the lead investigator."); + } + + if (!await _authorization.IsActiveMemberAsync(requestedLead, departmentId)) + throw new ArgumentException("The lead investigator must be an active member of the department."); + + investigation.LeadInvestigatorUserId = requestedLead; + } investigation.ModifiedOn = DateTime.UtcNow; investigation.RowVersion++; await SealAndUpdateAsync(departmentId, userId, investigation, existing, cancellationToken); await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, AuditPurpose, investigation.RmsInvestigationCaseId, new { action = "updated" }, cancellationToken: cancellationToken); diff --git a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs index 506a825d..47e42b8a 100644 --- a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs +++ b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs @@ -100,7 +100,7 @@ public Task AuditAsync(int departmentId, string userId, RmsAccessAuditAction act Successful = successful, OccurredOn = DateTime.UtcNow, Purpose = Trim(purpose, 100), - CorrelationId = Trim(aggregateId, 100), + CorrelationId = Trim(aggregateId, 36), IpAddress = Trim(ipAddress, 64), OriginClient = (int)RmsOriginClient.Web, DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 8938efda..76ac39ff 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using Autofac; using Resgrid.Model.Providers; using Resgrid.Model.Services; @@ -308,6 +308,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-6 records analytics + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0187_AddRmsAnalyticsFlag.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0187_AddRmsAnalyticsFlag.cs new file mode 100644 index 00000000..3e804e10 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0187_AddRmsAnalyticsFlag.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// RMS-6 records analytics (RMS plan section 6, RMS-6; registry M0187): seeds the Records.Analytics feature flag off. + /// The dashboards are computed on demand over the existing Records, revision, due-state and prevention tables, so + /// no table or index is added. Guarded for safe retry. + /// + [Migration(187)] + public class M0187_AddRmsAnalyticsFlag : Migration + { + public override void Up() + { + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = 'Records.Analytics') INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) VALUES ('Records.Analytics', 'Records Analytics', 'RMS-6 response-performance, workload, executive, accreditation and community-risk dashboards over finalized Records. Requires Records.System. Seeded off.', 'Records', 0);"); + } + + public override void Down() + { + Delete.FromTable("FeatureFlags").Row(new { FlagKey = "Records.Analytics" }); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0188_AddRmsAccessAuditCorrelationIndex.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0188_AddRmsAccessAuditCorrelationIndex.cs new file mode 100644 index 00000000..46eb23e8 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0188_AddRmsAccessAuditCorrelationIndex.cs @@ -0,0 +1,27 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// RMS-5 prevention and investigation audits (registry M0188): indexes RmsAccessAudits by correlation. These + /// aggregates are not Records, so RecordsPreventionGate.AuditAsync leaves RecordId null and rides the aggregate id + /// on CorrelationId; the case access-audit view reads back on that column and the M0155 indexes do not cover it. + /// Existence-guarded for safe retry. + /// + [Migration(188)] + public class M0188_AddRmsAccessAuditCorrelationIndex : Migration + { + public override void Up() + { + if (Schema.Table("RmsAccessAudits").Exists() && !Schema.Table("RmsAccessAudits").Index("IX_RmsAccessAudits_Department_Correlation_Occurred").Exists()) + Create.Index("IX_RmsAccessAudits_Department_Correlation_Occurred").OnTable("RmsAccessAudits") + .OnColumn("DepartmentId").Ascending().OnColumn("CorrelationId").Ascending().OnColumn("OccurredOn").Descending(); + } + + public override void Down() + { + if (Schema.Table("RmsAccessAudits").Exists() && Schema.Table("RmsAccessAudits").Index("IX_RmsAccessAudits_Department_Correlation_Occurred").Exists()) + Delete.Index("IX_RmsAccessAudits_Department_Correlation_Occurred").OnTable("RmsAccessAudits"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0187_AddRmsAnalyticsFlagPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0187_AddRmsAnalyticsFlagPg.cs new file mode 100644 index 00000000..7feb6cd4 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0187_AddRmsAnalyticsFlagPg.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// RMS-6 records analytics (RMS plan section 6, RMS-6; registry M0187): seeds the Records.Analytics feature flag off. + /// The dashboards are computed on demand over the existing Records, revision, due-state and prevention tables, so + /// no table or index is added. Guarded for safe retry. + /// + [Migration(187)] + public class M0187_AddRmsAnalyticsFlagPg : Migration + { + public override void Up() + { + Execute.Sql("INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) SELECT 'Records.Analytics', 'Records Analytics', 'RMS-6 response-performance, workload, executive, accreditation and community-risk dashboards over finalized Records. Requires Records.System. Seeded off.', 'Records', false WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = 'Records.Analytics');"); + } + + public override void Down() + { + Delete.FromTable("featureflags").Row(new { flagkey = "Records.Analytics" }); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0188_AddRmsAccessAuditCorrelationIndexPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0188_AddRmsAccessAuditCorrelationIndexPg.cs new file mode 100644 index 00000000..3455e69b --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0188_AddRmsAccessAuditCorrelationIndexPg.cs @@ -0,0 +1,27 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// RMS-5 prevention and investigation audits (registry M0188): indexes rmsaccessaudits by correlation. These + /// aggregates are not Records, so RecordsPreventionGate.AuditAsync leaves RecordId null and rides the aggregate id + /// on CorrelationId; the case access-audit view reads back on that column and the M0155 indexes do not cover it. + /// Existence-guarded for safe retry. + /// + [Migration(188)] + public class M0188_AddRmsAccessAuditCorrelationIndexPg : Migration + { + public override void Up() + { + if (Schema.Table("rmsaccessaudits").Exists() && !Schema.Table("rmsaccessaudits").Index("IX_RmsAccessAudits_Department_Correlation_Occurred").Exists()) + Create.Index("IX_RmsAccessAudits_Department_Correlation_Occurred").OnTable("rmsaccessaudits") + .OnColumn("departmentid").Ascending().OnColumn("correlationid").Ascending().OnColumn("occurredon").Descending(); + } + + public override void Down() + { + if (Schema.Table("rmsaccessaudits").Exists() && Schema.Table("rmsaccessaudits").Index("IX_RmsAccessAudits_Department_Correlation_Occurred").Exists()) + Delete.Index("IX_RmsAccessAudits_Department_Correlation_Occurred").OnTable("rmsaccessaudits"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs index 9c443529..0202adc3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs @@ -125,6 +125,16 @@ public Task> GetRetentionCandidatesAsync(int depa where.Append($" AND {C("FinalizedOn")} IS NOT NULL AND {C("FinalizedOn")} < {P}FinalizedOnEnd"); parameters.Add("FinalizedOnEnd", query.FinalizedOnEnd.Value); } + if (query.OccurredOnStart.HasValue) + { + where.Append($" AND COALESCE({C("CallCreatedOn")}, {C("FinalizedOn")}) >= {P}OccurredOnStart"); + parameters.Add("OccurredOnStart", query.OccurredOnStart.Value); + } + if (query.OccurredOnEnd.HasValue) + { + where.Append($" AND COALESCE({C("CallCreatedOn")}, {C("FinalizedOn")}) < {P}OccurredOnEnd"); + parameters.Add("OccurredOnEnd", query.OccurredOnEnd.Value); + } if (query.VisibleGroupIds != null) { @@ -215,6 +225,16 @@ public Task> GetForRecordAsync(int departmentId, string recordId, new { DepartmentId = departmentId, RecordId = recordId, RevisionId = revisionId }); } + public async Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds) + { + var rows = new List(); + foreach (var ids in (revisionIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync( + $"SELECT * FROM {Tbl(_table)} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RevisionId", "Ids")} ORDER BY {Col("RecordId")}, {Col(_orderBy)}", + new { DepartmentId = departmentId, Ids = InListValue(ids) })); + return rows; + } + public Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default) { return ExecuteAsync( diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs index 78d186b9..08a6a33a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs @@ -282,6 +282,13 @@ public async Task> GetLastCompletedByOccupancyAsyn public Task> GetOpenForProgramAsync(int departmentId, string programId) => QueryAsync($"SELECT * FROM {Tbl("RmsInspections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionProgramId")} = {P}ProgramId AND {Col("State")} IN ({(int)RmsInspectionState.Scheduled}, {(int)RmsInspectionState.InProgress}, {(int)RmsInspectionState.ReinspectionRequired}) AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, ProgramId = programId }); + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + { + var activity = $"COALESCE({Col("CompletedOn")}, {Col("ScheduledOn")}, {Col("CreatedOn")})"; + var p = RmsPreventionSql.Paged(departmentId, 0, take, 50000); p.Add("Start", startUtc); p.Add("End", endUtc); + return QueryAsync($"SELECT * FROM {Tbl("RmsInspections")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {activity} >= {P}Start AND {activity} < {P}End AND {Col("DeletedOn")} IS NULL ORDER BY {activity}, {Col("RmsInspectionId")} {Paging()}", p); + } + public async Task TryBumpRowVersionAsync(int departmentId, string inspectionId, long expectedVersion, CancellationToken cancellationToken = default) => await ExecuteAsync($"UPDATE {Tbl("RmsInspections")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInspectionId")} = {P}Id AND {Col("RowVersion")} = {P}Expected", new { DepartmentId = departmentId, Id = inspectionId, Expected = expectedVersion, Now = DateTime.UtcNow }, cancellationToken) == 1; @@ -330,6 +337,12 @@ public async Task> CountOpenByOccupancyAsync(int depart new { DepartmentId = departmentId, Ids = ids }); return rows.ToDictionary(r => r.OccupancyId, r => r.Open, StringComparer.Ordinal); } + + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 50000); p.Add("Start", startUtc); p.Add("End", endUtc); + return QueryAsync($"SELECT * FROM {Tbl("RmsViolations")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("CreatedOn")} >= {P}Start AND {Col("CreatedOn")} < {P}End AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CreatedOn")}, {Col("RmsViolationId")} {Paging()}", p); + } } public class RmsHydrantsRepository : RmsRepositoryBase, IRmsHydrantsRepository @@ -377,6 +390,12 @@ public RmsHydrantFlowTestsRepository(IConnectionProvider connectionProvider, Sql public Task> GetForHydrantAsync(int departmentId, string hydrantId) => QueryAsync($"SELECT * FROM {Tbl("RmsHydrantFlowTests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsHydrantId")} = {P}Id ORDER BY {Col("TestedOn")} DESC", new { DepartmentId = departmentId, Id = hydrantId }); + + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 50000); p.Add("Start", startUtc); p.Add("End", endUtc); + return QueryAsync($"SELECT * FROM {Tbl("RmsHydrantFlowTests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("TestedOn")} >= {P}Start AND {Col("TestedOn")} < {P}End ORDER BY {Col("TestedOn")}, {Col("RmsHydrantFlowTestId")} {Paging()}", p); + } } public class RmsHydrantMaintenancesRepository : RmsRepositoryBase, IRmsHydrantMaintenancesRepository @@ -444,6 +463,12 @@ public Task> GetExpiringAsync(int departmentId, DateTime public Task CountExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc) => ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsPermits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {(int)RmsPermitState.Issued} AND {Col("ExpiresOn")} > {P}Now AND {Col("ExpiresOn")} <= {P}Horizon AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Now = utcNow, Horizon = horizonUtc }); + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + { + var p = RmsPreventionSql.Paged(departmentId, 0, take, 50000); p.Add("Start", startUtc); p.Add("End", endUtc); + return QueryAsync($"SELECT * FROM {Tbl("RmsPermits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("AppliedOn")} >= {P}Start AND {Col("AppliedOn")} < {P}End AND {Col("DeletedOn")} IS NULL ORDER BY {Col("AppliedOn")}, {Col("RmsPermitId")} {Paging()}", p); + } + public async Task TryBumpRowVersionAsync(int departmentId, string permitId, long expectedVersion, CancellationToken cancellationToken = default) => await ExecuteAsync($"UPDATE {Tbl("RmsPermits")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsPermitId")} = {P}Id AND {Col("RowVersion")} = {P}Expected", new { DepartmentId = departmentId, Id = permitId, Expected = expectedVersion, Now = DateTime.UtcNow }, cancellationToken) == 1; diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index a36d6527..282bed54 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -353,6 +353,23 @@ public Task> GetFinalizedSinceAsync(int depart new { DepartmentId = departmentId, States = InListValue(states), Since = sinceUtc }); } + public Task> GetFinalizedInRangeAsync(int departmentId, IEnumerable states, DateTime startUtc, DateTime endUtc, int take) + { + // Occurrence = StartedOn when the author recorded one, else the finalization instant: a Record with no + // start still belongs to the period it was filed in rather than vanishing from every dashboard. + var occurred = $"COALESCE({Col("StartedOn")}, {Col("FinalizedOn")})"; + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("States", InListValue(states)); + parameters.Add("Start", startUtc); + parameters.Add("End", endUtc); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 5000 : Math.Min(take, 200000)); + return QueryAsync( + $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States")} AND {occurred} >= {P}Start AND {occurred} < {P}End AND {Col("DeletedOn")} IS NULL AND {Col("PurgedOn")} IS NULL ORDER BY {occurred}, {Col("RmsOperationalRecordId")} {Paging()}", + parameters); + } + public Task> GetCreatedSinceAsync(int departmentId, DateTime sinceUtc, int take) { var parameters = new DynamicParameters(); @@ -472,6 +489,17 @@ public Task GetByRevisionAsync(int departmentId, str $"SELECT * FROM {Tbl("RmsOperationalRecordDetails")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("RevisionId")} = {P}RevisionId", new { DepartmentId = departmentId, RecordId = recordId, RevisionId = revisionId }); } + + public async Task> GetCallContextForRevisionsAsync(int departmentId, IEnumerable revisionIds) + { + // Explicit column list: the narrative and the Coroner restricted section never leave the table for analytics. + var rows = new List(); + foreach (var ids in (revisionIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync( + $"SELECT {Col("RecordId")} AS RecordId, {Col("RevisionId")} AS RevisionId, {Col("CallType")} AS CallType, {Col("CallPriority")} AS CallPriority, {Col("CallLoggedOn")} AS CallLoggedOn, {Col("UnitId")} AS UnitId, {Col("ActivityOn")} AS ActivityOn FROM {Tbl("RmsOperationalRecordDetails")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RevisionId", "Ids")}", + new { DepartmentId = departmentId, Ids = InListValue(ids) })); + return rows; + } } public class RmsRecordParticipantsRepository : RmsRepositoryBase, IRmsRecordParticipantsRepository @@ -493,6 +521,16 @@ public Task> GetForRecordsAsync(int department parameters); } + public async Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds) + { + var rows = new List(); + foreach (var ids in (revisionIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordParticipants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RevisionId", "Ids")} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("RecordId")}, {Col("Ordinal")}", + new { DepartmentId = departmentId, Ids = InListValue(ids) })); + return rows; + } + public Task> GetForRecordAsync(int departmentId, string recordId, string revisionId) { var revisionClause = revisionId == null ? $"{Col("RevisionId")} IS NULL" : $"{Col("RevisionId")} = {P}RevisionId"; @@ -535,6 +573,16 @@ public Task> GetForRecordsAsync(int departmen parameters); } + public async Task> GetForRevisionsAsync(int departmentId, IEnumerable revisionIds) + { + var rows = new List(); + foreach (var ids in (revisionIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordUnitResponses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RevisionId", "Ids")} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("RecordId")}, {Col("Ordinal")}", + new { DepartmentId = departmentId, Ids = InListValue(ids) })); + return rows; + } + public Task> GetForRecordAsync(int departmentId, string recordId, string revisionId) { var revisionClause = revisionId == null ? $"{Col("RevisionId")} IS NULL" : $"{Col("RevisionId")} = {P}RevisionId"; @@ -795,6 +843,20 @@ public Task> GetForRecordAsync(int departmentId, string new { DepartmentId = departmentId, RecordId = recordId }); } + public Task> GetTransitionsInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + { + // Header columns only: the snapshot is the attested content and analytics never needs it. + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Start", startUtc); + parameters.Add("End", endUtc); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 5000 : Math.Min(take, 200000)); + return QueryAsync( + $"SELECT {Col("RmsRevisionId")} AS RmsRevisionId, {Col("RecordId")} AS RecordId, {Col("RecordKind")} AS RecordKind, {Col("Transition")} AS Transition, {Col("DefinitionKey")} AS DefinitionKey, {Col("ActorUserId")} AS ActorUserId, {Col("CreatedOn")} AS CreatedOn FROM {Tbl("RmsRevisions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("CreatedOn")} >= {P}Start AND {Col("CreatedOn")} < {P}End ORDER BY {Col("CreatedOn")}, {Col("RmsRevisionId")} {Paging()}", + parameters); + } + public Task GetByIdForDepartmentAsync(int departmentId, string revisionId) { return QueryFirstOrDefaultAsync( @@ -841,9 +903,13 @@ public Task CountByActionSinceAsync(int departmentId, int action, DateTime return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsAccessAudits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Action")} = {P}Action AND {Col("OccurredOn")} >= {P}Since", new { DepartmentId = departmentId, Action = action, Since = sinceUtc }); } + /// + /// Prevention and investigation aggregates are not Records, so RecordsPreventionGate.AuditAsync leaves RecordId + /// null and rides the aggregate id on CorrelationId. Match on that column: RecordId never carries a case id. + /// public Task> GetForAggregateAsync(int departmentId, string aggregateId, int take) { - return QueryAsync($"SELECT * FROM {Tbl("RmsAccessAudits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id ORDER BY {Col("OccurredOn")} DESC, {Col("RmsAccessAuditId")} DESC {Paging()}", new { DepartmentId = departmentId, Id = aggregateId, Skip = 0, Take = Math.Clamp(take, 1, 1000) }); + return QueryAsync($"SELECT * FROM {Tbl("RmsAccessAudits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("CorrelationId")} = {P}Id ORDER BY {Col("OccurredOn")} DESC, {Col("RmsAccessAuditId")} DESC {Paging()}", new { DepartmentId = departmentId, Id = aggregateId, Skip = 0, Take = Math.Clamp(take, 1, 1000) }); } public Task> GetForRecordAsync(int departmentId, string recordId, int take) @@ -1218,6 +1284,19 @@ public Task CountVisibleOverdueAsync(int departmentId, List visibleGro new { DepartmentId = departmentId, Overdue = (int)RmsDueState.Overdue, VisibleGroupIds = InListValue(visibleGroupIds), Viewer = userId }); } + public Task> GetChangedInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + { + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("Start", startUtc); + parameters.Add("End", endUtc); + parameters.Add("Skip", 0); + parameters.Add("Take", take <= 0 ? 5000 : Math.Min(take, 200000)); + return QueryAsync( + $"SELECT * FROM {Tbl("RmsRecordDueStates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ModifiedOn")} >= {P}Start AND {Col("ModifiedOn")} < {P}End ORDER BY {Col("ModifiedOn")}, {Col("RmsRecordDueStateId")} {Paging()}", + parameters); + } + public Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default) { return ExecuteAsync( diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index 09b16be7..ba20327a 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -191,6 +191,11 @@ private static Dictionary Load(string path) "Records|fr|ReviewCycle", // "Cycle" "Records|fr|SectionNumber", // "Section" "Records|it|FileName", // "File" + // Records analytics (2026-09-08): the target-language word is the English one. + "Records|fr|AnalyticsMax", // "Maximum" + "Records|es|AnalyticsTotal", // "Total" + "Records|fr|AnalyticsTotal", // "Total" + "Records|fr|AnalyticsIncidents", // "Incidents" // Counted phrases whose wording matches English. "CommunicationTest|es|ScopeRoles", // {0} roles diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs new file mode 100644 index 00000000..8244591f --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using static Resgrid.Tests.Rms.RmsPreventionHarness; + +namespace Resgrid.Tests.Rms +{ + /// + /// RMS-6 records analytics (RMS plan section 6, RMS-6): response-time distributions, workload and personnel hours, + /// the executive summary against the prior window, accreditation and community-risk sections, and the rules every + /// dashboard shares - the flag, the viewer gate, group scoping, the window clamp and the row cap. + /// + [TestFixture] + public class RecordsAnalyticsServiceTests + { + private static readonly DateTime T0 = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Start = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime End = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc); + + private RmsPreventionHarness _h; + private Mock _participants; + private Mock _scopes; + private Mock _details; + private Mock _reportUnits; + private Mock _incidentTypes; + private Mock _revisions; + private Mock _dueStates; + private Mock _unitsService; + private Mock _groupsService; + private Mock _departmentsService; + private List _records; + private List _units; + private List _people; + private List _reports; + private List _reportUnitRows; + private RecordsAnalyticsService _svc; + + [SetUp] + public void SetUp() + { + _h = new RmsPreventionHarness(); + _participants = new Mock(); _scopes = new Mock(); _details = new Mock(); + _reportUnits = new Mock(); _incidentTypes = new Mock(); _revisions = new Mock(); _dueStates = new Mock(); + _unitsService = new Mock(); _groupsService = new Mock(); _departmentsService = new Mock(); + _records = new List(); _units = new List(); _people = new List(); _reports = new List(); _reportUnitRows = new List(); + + _h.Records.Setup(r => r.GetFinalizedInRangeAsync(Dept, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, IEnumerable states, DateTime s, DateTime e, int take) => _records.Where(r => states.Contains(r.State) && (r.StartedOn ?? r.FinalizedOn) >= s && (r.StartedOn ?? r.FinalizedOn) < e).Take(take).ToList()); + _h.Records.Setup(r => r.CountVisibleAsync(Dept, It.IsAny>(), It.IsAny>(), It.IsAny())).ReturnsAsync((int d, IEnumerable states, List g, string u) => states.Contains((int)RmsRecordState.Draft) ? 3 : 2); + _h.Units.Setup(u => u.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToHashSet(); return _units.Where(u => set.Contains(u.RevisionId)).ToList(); }); + _participants.Setup(p => p.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToHashSet(); return _people.Where(p => set.Contains(p.RevisionId)).ToList(); }); + _scopes.Setup(s => s.GetForRecordsAsync(Dept, It.IsAny>())).ReturnsAsync(new List()); + _details.Setup(d => d.GetCallContextForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => ids.Select(id => new RmsRecordCallContext { RevisionId = id, CallType = id.EndsWith("2") ? "EMS" : "Fire" }).ToList()); + _h.Reports.Setup(r => r.QueryAsync(Dept, It.IsAny())).ReturnsAsync((int d, RmsIncidentReportQuery q) => _reports.Where(r => (r.CallCreatedOn ?? r.FinalizedOn) >= q.OccurredOnStart && (r.CallCreatedOn ?? r.FinalizedOn) < q.OccurredOnEnd).ToList()); + _reportUnits.Setup(u => u.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToHashSet(); return _reportUnitRows.Where(u => set.Contains(u.RevisionId)).ToList(); }); + _incidentTypes.Setup(t => t.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => ids.SelectMany(id => new[] { new RmsIncidentType { RevisionId = id, TypeCode = "FIRE||STRUCTURE", IsPrimary = false, Ordinal = 0 }, new RmsIncidentType { RevisionId = id, TypeCode = "MEDICAL||CARDIAC", IsPrimary = true, Ordinal = 1 } }).ToList()); + _revisions.Setup(r => r.GetTransitionsInRangeAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _dueStates.Setup(d => d.GetChangedInRangeAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _dueStates.Setup(d => d.CountVisibleOverdueAsync(Dept, It.IsAny>(), It.IsAny())).ReturnsAsync(4); + _unitsService.Setup(u => u.GetUnitsForDepartmentAsync(Dept)).ReturnsAsync(new List { new Unit { UnitId = 1, Name = "Engine 1" }, new Unit { UnitId = 2, Name = "Medic 2" } }); + _groupsService.Setup(g => g.GetAllGroupsForDepartmentAsync(Dept)).ReturnsAsync(new List { new DepartmentGroup { DepartmentGroupId = 10, Name = "Station 10" } }); + _departmentsService.Setup(d => d.GetAllPersonnelNamesForDepartmentAsync(Dept)).ReturnsAsync(new List { new PersonName { UserId = "u-a", FirstName = "Ann", LastName = "Author" } }); + _departmentsService.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, TimeZone = "UTC" }); + + _svc = new RecordsAnalyticsService(_h.Gate, _h.Authorization.Object, _h.Records.Object, _h.Units.Object, _participants.Object, _scopes.Object, _details.Object, _h.Reports.Object, _reportUnits.Object, + _incidentTypes.Object, _revisions.Object, _dueStates.Object, _h.Inspections, _h.Programs, _h.Violations, _h.Permits, _h.PermitTypes, _h.Hydrants, _h.FlowTests, _h.Occupancies, _h.Crr, + _unitsService.Object, _groupsService.Object, _departmentsService.Object); + } + + private static RecordsAnalyticsQuery Q() => new RecordsAnalyticsQuery { Start = Start, End = End }; + + private RmsOperationalRecord Record(string id, string definition = RmsDefinitionKeys.Run, DateTime? started = null, double hours = 2, string author = "u-a", int? group = 10, int? callId = 1, int returns = 0) + { + var s = started ?? T0; + var r = new RmsOperationalRecord { RmsOperationalRecordId = id, DepartmentId = Dept, DefinitionKey = definition, RecordType = (int)RmsDefinitionKeys.LockedTypes[definition], State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rev-" + id, StartedOn = s, EndedOn = s.AddHours(hours), AuthorUserId = author, OwnerUserId = author, StationGroupId = group, CallId = callId, CreatedOn = s.AddHours(-1), SubmittedForReviewOn = s.AddHours(hours + 1), ApprovedOn = s.AddHours(hours + 3), FinalizedOn = s.AddHours(hours + 4), ReturnCount = returns }; + _records.Add(r); + return r; + } + + private void UnitTimes(string recordId, int unitId, int turnoutSeconds, int travelSeconds, int onSceneMinutes, DateTime? dispatched = null, int? group = 10) + { + var d = dispatched ?? T0; + _units.Add(new RmsRecordUnitResponse { RecordId = recordId, RevisionId = "rev-" + recordId, DepartmentId = Dept, UnitId = unitId, UnitNameSnapshot = "Unit " + unitId, StationGroupIdSnapshot = group, Dispatched = d, Enroute = d.AddSeconds(turnoutSeconds), OnScene = d.AddSeconds(turnoutSeconds + travelSeconds), Released = d.AddSeconds(turnoutSeconds + travelSeconds).AddMinutes(onSceneMinutes) }); + } + + private void Person(string recordId, string userId, DateTime? from = null, DateTime? to = null) + => _people.Add(new RmsRecordParticipant { RecordId = recordId, RevisionId = "rev-" + recordId, DepartmentId = Dept, UserId = userId, ParticipationStart = from, ParticipationEnd = to }); + + [Test] + public async Task Response_performance_computes_percentiles_targets_and_first_arrival_and_drops_garbage_times() + { + Record("r1"); Record("r2"); Record("r3"); + UnitTimes("r1", 1, 60, 200, 30); UnitTimes("r1", 2, 90, 300, 20); + UnitTimes("r2", 1, 70, 250, 40); UnitTimes("r2", 2, 120, 400, 10); + UnitTimes("r3", 1, 80, 230, 15); + // Reversed and absurd intervals never enter a distribution. + _units.Add(new RmsRecordUnitResponse { RecordId = "r3", RevisionId = "rev-r3", DepartmentId = Dept, UnitId = 2, Dispatched = T0, Enroute = T0.AddSeconds(-30), OnScene = T0.AddDays(3) }); + var q = Q(); q.TurnoutTargetSeconds = 80; q.TravelTargetSeconds = 240; + + var p = await _svc.GetResponsePerformanceAsync(Dept, Member, q); + + p.Responses.Should().Be(5, "the sixth unit row has no usable interval"); + p.RecordsWithResponses.Should().Be(3); + p.Turnout.Samples.Should().Be(5); + p.Turnout.P90Seconds.Should().Be(120, "nearest-rank 90th of 60,70,80,90,120"); + p.Turnout.MedianSeconds.Should().Be(80); + p.Turnout.WithinTarget.Should().Be(3, "60, 70 and 80 are within 80 s"); + p.Turnout.WithinTargetPercent.Should().Be(60); + p.Travel.P90Seconds.Should().Be(400); + p.TotalResponse.TargetSeconds.Should().Be(320); + p.TotalResponse.P90Seconds.Should().Be(520); + p.OnScene.MedianSeconds.Should().Be(20 * 60); + p.FirstArrival.Samples.Should().Be(3); + p.FirstArrival.MedianSeconds.Should().Be(310, "first arrivals are r1 260 s, r2 320 s and r3 310 s"); + p.ByUnit.Should().HaveCount(2); + p.ByUnit.First(u => u.Key == "1").Label.Should().Be("Engine 1", "unit names resolve through the units service"); + p.ByUnit.First(u => u.Key == "1").Responses.Should().Be(3); + p.ByStationGroup.Single().Label.Should().Be("Station 10"); + p.ByHourOfDay.Should().HaveCount(24); + p.ByHourOfDay.Single(h => h.Responses > 0).Key.Should().Be("00"); + p.ByMonth.Single().Key.Should().Be("2026-08"); + p.BySource.Single().Key.Should().Be("operational"); + p.GroupScoped.Should().BeFalse(); + p.Truncated.Should().BeFalse(); + p.RecordsRead.Should().Be(3); + } + + [Test] + public async Task Incident_reports_join_the_distributions_and_first_arrival_uses_the_earliest_unit() + { + _reports.Add(new RmsIncidentReport { RmsIncidentReportId = "i1", DepartmentId = Dept, State = (int)RmsRecordState.Accepted, CurrentRevisionId = "rev-i1", CallCreatedOn = T0, FinalizedOn = T0.AddDays(1), AcceptedOn = T0.AddDays(2), StationGroupId = 10, CreatedOn = T0 }); + _reportUnitRows.Add(new RmsUnitResponse { RecordId = "i1", RevisionId = "rev-i1", DepartmentId = Dept, UnitId = 1, DispatchedOn = T0, EnrouteOn = T0.AddSeconds(50), OnSceneOn = T0.AddSeconds(350), ClearedOn = T0.AddMinutes(45) }); + _reportUnitRows.Add(new RmsUnitResponse { RecordId = "i1", RevisionId = "rev-i1", DepartmentId = Dept, UnitId = 2, DispatchedOn = T0.AddSeconds(10), EnrouteOn = T0.AddSeconds(100), OnSceneOn = T0.AddSeconds(500) }); + _reportUnitRows.Add(new RmsUnitResponse { RecordId = "i1", RevisionId = "rev-i1", DepartmentId = Dept, UnitId = 3, UnableToDispatch = true, DispatchedOn = T0, EnrouteOn = T0.AddSeconds(5) }); + + var p = await _svc.GetResponsePerformanceAsync(Dept, Member, Q()); + + p.Responses.Should().Be(2, "an unable-to-dispatch unit is not a response"); + p.FirstArrival.Samples.Should().Be(1); + p.FirstArrival.MedianSeconds.Should().Be(350, "earliest on-scene minus earliest dispatch"); + p.BySource.Single().Key.Should().Be("incident"); + p.OnScene.Samples.Should().Be(1); + p.RecordsRead.Should().Be(1); + } + + [Test] + public async Task Workload_totals_hours_by_person_definition_unit_and_heat_map() + { + Record("r1", RmsDefinitionKeys.Training, T0, 3); Record("r2", RmsDefinitionKeys.Run, T0.AddDays(1).AddHours(14), 1.5); Record("r3", RmsDefinitionKeys.Work, T0.AddDays(-1), 4, author: "u-b"); + Person("r1", "u-a"); Person("r1", "u-b", T0, T0.AddHours(1)); Person("r2", "u-a"); Person("r3", "u-b"); + UnitTimes("r2", 1, 60, 240, 30); + _reports.Add(new RmsIncidentReport { RmsIncidentReportId = "i1", DepartmentId = Dept, State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rev-i1", CallCreatedOn = T0.AddDays(2), FinalizedOn = T0.AddDays(3), CreatedOn = T0 }); + + var w = await _svc.GetWorkloadAsync(Dept, Member, Q()); + + w.RecordsFinalized.Should().Be(3); + w.IncidentReportsFinalized.Should().Be(1); + w.RecordHours.Should().Be(8.5); + w.PersonnelHours.Should().Be(3 + 1 + 1.5 + 4, "a participant span wins over the record duration when it exists"); + w.TrainingHours.Should().Be(4, "both training participants: 3 h record duration plus a 1 h span"); + w.UnitResponses.Should().Be(1); + w.UnitOnSceneHours.Should().Be(0.5); + w.ByDefinition.Should().HaveCount(4).And.Contain(d => d.Key == RmsDefinitionKeys.Training && d.Label == "Training" && d.Hours == 3); + w.ByDefinition.Should().Contain(d => d.Key == RmsDefinitionKeys.NerisIncidentReport && d.Count == 1); + w.ByAuthor.First(a => a.Key == "u-a").Label.Should().Be("Ann Author"); + w.ByAuthor.First(a => a.Key == "u-a").Count.Should().Be(2); + w.ByPerson.First(a => a.Key == "u-b").Hours.Should().Be(5); + w.ByUnit.Single().Label.Should().Be("Engine 1"); + w.ByMonth.Should().ContainSingle(m => m.Key == "2026-07" && m.Count == 1).And.ContainSingle(m => m.Key == "2026-08" && m.Count == 3); + w.ByWeekdayHour.Should().HaveCount(7 * 24); + w.ByWeekdayHour.Single(c => c.Weekday == (int)T0.AddDays(1).DayOfWeek && c.Hour == 14).Count.Should().Be(1); + w.ByWeekdayHour.Sum(c => c.Count).Should().Be(4); + } + + [Test] + public async Task Executive_summary_compares_with_the_prior_window_and_reads_lifecycle_and_queues() + { + Record("r1", started: T0, returns: 1); Record("r2", started: T0.AddDays(3)); Record("old", started: Start.AddDays(-10)); + Person("r1", "u-a"); Person("r2", "u-a"); + _reports.Add(new RmsIncidentReport { RmsIncidentReportId = "i1", DepartmentId = Dept, State = (int)RmsRecordState.Accepted, CurrentRevisionId = "rev-i1", CallCreatedOn = T0, FinalizedOn = T0.AddDays(1), AcceptedOn = T0.AddDays(2), CreatedOn = T0 }); + _reports.Add(new RmsIncidentReport { RmsIncidentReportId = "i2", DepartmentId = Dept, State = (int)RmsRecordState.Rejected, CurrentRevisionId = "rev-i2", CallCreatedOn = T0, FinalizedOn = T0.AddDays(1), RejectedOn = T0.AddDays(2), CreatedOn = T0 }); + _revisions.Setup(r => r.GetTransitionsInRangeAsync(Dept, Start, End, It.IsAny())).ReturnsAsync(new List { new RmsRevisionTransitionRow { RecordId = "r1", Transition = (int)RmsRevisionTransition.Amended }, new RmsRevisionTransitionRow { RecordId = "r2", Transition = (int)RmsRevisionTransition.Voided } }); + _dueStates.Setup(d => d.GetChangedInRangeAsync(Dept, Start, End, It.IsAny())).ReturnsAsync(new List + { + new RmsRecordDueState { RecordId = "r1", OverdueCount = 1, LastEmittedState = (int)RmsDueState.Overdue, LastEmittedOn = T0 }, + new RmsRecordDueState { RecordId = "r2", OverdueCount = 0, LastEmittedState = (int)RmsDueState.DueSoon, LastEmittedOn = T0 }, + new RmsRecordDueState { RecordId = "old", OverdueCount = 2, LastEmittedState = (int)RmsDueState.Cleared, LastEmittedOn = Start.AddDays(-1) } + }); + + var s = await _svc.GetExecutiveSummaryAsync(Dept, Member, Q()); + + s.PriorStart.Should().Be(Start - (End - Start)); s.PriorEnd.Should().Be(Start); + var finalized = s.Kpis.Single(k => k.Key == "finalized"); + finalized.Current.Should().Be(2); finalized.Prior.Should().Be(1); finalized.ChangePercent.Should().Be(100); + s.Kpis.Single(k => k.Key == "incidentReports").Current.Should().Be(2); + s.Kpis.Single(k => k.Key == "nerisAccepted").Current.Should().Be(1); + s.Kpis.Single(k => k.Key == "nerisRejected").Current.Should().Be(1); + s.Kpis.Single(k => k.Key == "amended").Current.Should().Be(1); + s.Kpis.Single(k => k.Key == "voided").Current.Should().Be(1); + s.Kpis.Single(k => k.Key == "personnelHours").Current.Should().Be(4); + s.Kpis.Single(k => k.Key == "personnelHours").ChangePercent.Should().BeNull("a zero prior figure has no percentage change"); + s.MedianHoursToFinalize.Should().Be(7, "created 1 h before start, finalized 2 h after a 2 h record ends"); + s.MedianReviewHours.Should().Be(2); + s.ReturnRatePercent.Should().Be(50); + s.NerisAcceptanceRatePercent.Should().Be(50); + s.OpenDrafts.Should().Be(3); s.AwaitingReview.Should().Be(2); s.OverdueNow.Should().Be(4); + s.WentOverdue.Should().Be(1, "only the row that went overdue inside the window counts"); + s.ByMonth.Should().ContainSingle(m => m.Key == "2026-08" && m.Count == 4); + } + + [Test] + public async Task Group_scoped_viewer_only_counts_records_they_could_open_and_passes_scope_to_the_incident_query() + { + _h.Authorization.Setup(a => a.GetVisibleGroupIdsAsync(Member, Dept)).ReturnsAsync(new List { 10 }); + Record("mine", author: Member, group: 99); Record("theirs", author: "u-z", group: 99); Record("scoped", author: "u-z", group: 10); Record("shared", author: "u-z", group: 99); + Person("shared", Member); + _scopes.Setup(s => s.GetForRecordsAsync(Dept, It.IsAny>())).ReturnsAsync(new List { new RmsRecordGroupScope { RecordId = "scoped", DepartmentGroupId = 10 } }); + UnitTimes("mine", 1, 60, 200, 10); UnitTimes("theirs", 1, 60, 200, 10); UnitTimes("scoped", 1, 60, 200, 10); UnitTimes("shared", 1, 60, 200, 10); + RmsIncidentReportQuery captured = null; + _h.Reports.Setup(r => r.QueryAsync(Dept, It.IsAny())).Callback((int d, RmsIncidentReportQuery q) => captured = q).ReturnsAsync(new List()); + + var w = await _svc.GetWorkloadAsync(Dept, Member, Q()); + + w.GroupScoped.Should().BeTrue(); + w.RecordsFinalized.Should().Be(3, "the author's own, the participant's and the group-scoped record; never the fourth"); + w.UnitResponses.Should().Be(3); + captured.VisibleGroupIds.Should().BeEquivalentTo(new[] { 10 }); + captured.ViewerUserId.Should().Be(Member); + captured.States.Should().Contain((int)RmsRecordState.Accepted); + } + + [Test] + public async Task Gate_flag_viewer_window_clamp_and_row_cap_apply_to_every_dashboard() + { + Func outsider = () => _svc.GetWorkloadAsync(Dept, Outsider, Q()); + await outsider.Should().ThrowAsync(); + + _h.DisabledFlags.Add(FeatureFlagKeys.RecordsAnalytics); + (await _svc.IsModuleEnabledAsync(Dept)).Should().BeFalse(); + Func off = () => _svc.GetExecutiveSummaryAsync(Dept, Admin, Q()); + await off.Should().ThrowAsync(); + _h.DisabledFlags.Clear(); + + var wide = await _svc.GetWorkloadAsync(Dept, Member, new RecordsAnalyticsQuery { Start = End.AddDays(-800), End = End }); + (wide.End - wide.Start).TotalDays.Should().Be(RecordsAnalyticsLimits.MaxWindowDays); + wide.Warnings.Should().ContainSingle(w => w.Contains("clamped")); + + for (var i = 0; i < RecordsAnalyticsLimits.RowCap + 1; i++) _records.Add(new RmsOperationalRecord { RmsOperationalRecordId = "x" + i, DepartmentId = Dept, DefinitionKey = RmsDefinitionKeys.Run, RecordType = 1, State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rx" + i, StartedOn = T0, CreatedOn = T0, FinalizedOn = T0 }); + var capped = await _svc.GetWorkloadAsync(Dept, Member, Q()); + capped.Truncated.Should().BeTrue(); + capped.RecordsFinalized.Should().Be(RecordsAnalyticsLimits.RowCap); + capped.Warnings.Should().ContainSingle(w => w.Contains("Narrow the window")); + } + + [Test] + public async Task Accreditation_reads_prevention_history_only_for_enabled_modules_and_training_from_records() + { + var occ = _h.SeedOccupancy(); occ.NextReviewDue = DateTime.UtcNow.AddMonths(3); occ.LastInspectedOn = DateTime.UtcNow.AddMonths(-2); occ.HazmatOnSite = true; + var stale = _h.SeedOccupancy("Old Barn", "1 Farm Ln", occupancyType: 5); stale.NextReviewDue = DateTime.UtcNow.AddMonths(-1); stale.SprinklerType = (int)RmsSprinklerType.None; + _h.Programs.Rows.Add(new RmsInspectionProgram { RmsInspectionProgramId = "prog", DepartmentId = Dept, Name = "Annual", IsActive = true }); + _h.Inspections.Rows.Add(new RmsInspection { RmsInspectionId = "i1", DepartmentId = Dept, RmsOccupancyId = occ.RmsOccupancyId, RmsInspectionProgramId = "prog", State = (int)RmsInspectionState.Completed, Result = (int)RmsInspectionResult.Pass, ScheduledOn = T0.AddDays(2), CompletedOn = T0, CreatedOn = T0 }); + _h.Inspections.Rows.Add(new RmsInspection { RmsInspectionId = "i2", DepartmentId = Dept, RmsOccupancyId = occ.RmsOccupancyId, RmsInspectionProgramId = "prog", State = (int)RmsInspectionState.ReinspectionRequired, Result = (int)RmsInspectionResult.Fail, ScheduledOn = T0, CompletedOn = T0.AddDays(3), CreatedOn = T0 }); + _h.Inspections.Rows.Add(new RmsInspection { RmsInspectionId = "i3", DepartmentId = Dept, RmsOccupancyId = stale.RmsOccupancyId, State = (int)RmsInspectionState.Scheduled, ScheduledOn = DateTime.UtcNow.AddDays(-5), CreatedOn = T0 }); + _h.Violations.Rows.Add(new RmsViolation { RmsViolationId = "v1", DepartmentId = Dept, RmsOccupancyId = occ.RmsOccupancyId, Severity = (int)RmsViolationSeverity.Serious, State = (int)RmsViolationState.Verified, CreatedOn = T0, CorrectedOn = T0.AddDays(10), DueOn = T0.AddDays(30) }); + _h.Violations.Rows.Add(new RmsViolation { RmsViolationId = "v2", DepartmentId = Dept, RmsOccupancyId = occ.RmsOccupancyId, Severity = (int)RmsViolationSeverity.Minor, State = (int)RmsViolationState.Open, CreatedOn = T0, DueOn = DateTime.UtcNow.AddDays(-1) }); + _h.PermitTypes.Rows.Add(new RmsPermitType { RmsPermitTypeId = "pt", DepartmentId = Dept, Name = "Hot work", IsActive = true }); + _h.Permits.Rows.Add(new RmsPermit { RmsPermitId = "p1", DepartmentId = Dept, RmsPermitTypeId = "pt", State = (int)RmsPermitState.Issued, AppliedOn = T0, IssuedOn = T0.AddDays(4), ExpiresOn = DateTime.UtcNow.AddYears(1), CreatedOn = T0 }); + _h.Permits.Rows.Add(new RmsPermit { RmsPermitId = "p2", DepartmentId = Dept, RmsPermitTypeId = "pt", State = (int)RmsPermitState.Denied, AppliedOn = T0, CreatedOn = T0 }); + _h.Hydrants.Rows.Add(new RmsHydrant { RmsHydrantId = "h1", DepartmentId = Dept, InService = true, LastTestedOn = DateTime.UtcNow.AddMonths(-3), FlowClass = (int)RmsHydrantFlowClass.A, CreatedOn = T0 }); + _h.Hydrants.Rows.Add(new RmsHydrant { RmsHydrantId = "h2", DepartmentId = Dept, InService = false, LastTestedOn = DateTime.UtcNow.AddYears(-2), FlowClass = (int)RmsHydrantFlowClass.C, CreatedOn = T0 }); + _h.FlowTests.Rows.Add(new RmsHydrantFlowTest { RmsHydrantFlowTestId = "ft", DepartmentId = Dept, RmsHydrantId = "h1", TestedOn = T0, CreatedOn = T0 }); + Record("t1", RmsDefinitionKeys.Training, T0, 2); Record("t2", RmsDefinitionKeys.Training, T0.AddDays(1), 3); + Person("t1", "u-a"); Person("t1", "u-b"); Person("t2", "u-a"); + UnitTimes("t1", 1, 60, 200, 10); + + var a = await _svc.GetAccreditationAsync(Dept, Member, Q()); + + a.InspectionsEnabled.Should().BeTrue(); a.Inspections.Completed.Should().Be(2); a.Inspections.Passed.Should().Be(1); a.Inspections.Failed.Should().Be(1); a.Inspections.PassRatePercent.Should().Be(50); + a.Inspections.CompletedOnSchedule.Should().Be(1); a.Inspections.OnSchedulePercent.Should().Be(50); a.Inspections.AverageDaysScheduledToCompleted.Should().Be(0.5, "(-2 + 3) / 2"); + a.Inspections.ReinspectionsRequired.Should().Be(1); a.Inspections.OpenNow.Should().Be(2); a.Inspections.OverdueNow.Should().Be(1); + a.Inspections.ByProgram.Single().Label.Should().Be("Annual"); + a.Violations.Opened.Should().Be(2); a.Violations.Corrected.Should().Be(1); a.Violations.Verified.Should().Be(1); a.Violations.AverageDaysToCorrection.Should().Be(10); a.Violations.OpenNow.Should().Be(1); a.Violations.OverdueNow.Should().Be(1); + a.Violations.BySeverity.Should().Contain(s => s.Label == "Serious" && s.Count == 1); + a.Permits.Applied.Should().Be(2); a.Permits.Issued.Should().Be(1); a.Permits.Denied.Should().Be(1); a.Permits.ActiveNow.Should().Be(1); a.Permits.AverageDaysAppliedToIssued.Should().Be(4); a.Permits.ByType.Single().Label.Should().Be("Hot work"); + a.Hydrants.Total.Should().Be(2); a.Hydrants.InService.Should().Be(1); a.Hydrants.TestedWithin12Months.Should().Be(1); a.Hydrants.TestedWithin12MonthsPercent.Should().Be(50); a.Hydrants.FlowTestsInWindow.Should().Be(1); + a.Hydrants.ByFlowClass.Should().Contain(c => c.Label == "Class A"); + a.Occupancies.Total.Should().Be(2); a.Occupancies.ReviewCurrent.Should().Be(1); a.Occupancies.ReviewOverdue.Should().Be(1); a.Occupancies.InspectedWithin12Months.Should().Be(1); a.Occupancies.HazmatOnSite.Should().Be(1); a.Occupancies.NoSprinklers.Should().Be(1); a.Occupancies.OpenViolations.Should().Be(1); + a.Training.Records.Should().Be(2); a.Training.Hours.Should().Be(7); a.Training.MembersTrained.Should().Be(2); a.Training.HoursPerMember.Should().Be(3.5); + a.Response.Responses.Should().Be(1); a.Response.TurnoutP90Seconds.Should().Be(60); + + _h.DisabledFlags.Add(FeatureFlagKeys.RecordsPreventionInspections); _h.DisabledFlags.Add(FeatureFlagKeys.RecordsPreventionPermits); + var off = await _svc.GetAccreditationAsync(Dept, Member, Q()); + off.InspectionsEnabled.Should().BeFalse(); off.Inspections.Should().BeNull(); off.Violations.Should().BeNull(); off.Permits.Should().BeNull(); + off.Hydrants.Should().NotBeNull(); off.Occupancies.Should().NotBeNull(); + off.Occupancies.OpenViolations.Should().Be(0, "violations are read only while the inspections module is on"); + } + + [Test] + public async Task Community_risk_sums_crr_and_classifies_incidents_by_primary_type_and_call_type() + { + _h.Crr.Rows.Add(new RmsCrrActivity { RmsCrrActivityId = "c1", DepartmentId = Dept, Kind = (int)RmsCrrActivityKind.SmokeAlarmInstallation, OccurredOn = T0, AudienceCount = 12, SmokeAlarmsInstalled = 9, HoursSpent = 2.5m, CreatedOn = T0 }); + _h.Crr.Rows.Add(new RmsCrrActivity { RmsCrrActivityId = "c2", DepartmentId = Dept, Kind = (int)RmsCrrActivityKind.SchoolProgram, OccurredOn = T0.AddDays(20), AudienceCount = 200, HoursSpent = 4m, CreatedOn = T0 }); + _h.Crr.Rows.Add(new RmsCrrActivity { RmsCrrActivityId = "c3", DepartmentId = Dept, Kind = (int)RmsCrrActivityKind.SchoolProgram, OccurredOn = Start.AddDays(-2), AudienceCount = 999, CreatedOn = T0 }); + Record("r1", started: T0.AddHours(9)); Record("r2", started: T0.AddHours(21)); Record("w1", RmsDefinitionKeys.Work, T0, callId: null); + _reports.Add(new RmsIncidentReport { RmsIncidentReportId = "i1", DepartmentId = Dept, State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rev-i1", CallCreatedOn = T0.AddHours(9), FinalizedOn = T0.AddDays(1), CreatedOn = T0 }); + _h.Hydrants.Rows.Add(new RmsHydrant { RmsHydrantId = "h1", DepartmentId = Dept, InService = false, FlowClass = (int)RmsHydrantFlowClass.B, CreatedOn = T0 }); + + var r = await _svc.GetCommunityRiskAsync(Dept, Member, Q()); + + r.Crr.Activities.Should().Be(2); r.Crr.Audience.Should().Be(212); r.Crr.SmokeAlarmsInstalled.Should().Be(9); r.Crr.Hours.Should().Be(6.5); + r.Crr.ByKind.Should().Contain(k => k.Label == "Smoke Alarm Installation" && k.Count == 1 && k.Hours == 2.5); + r.Incidents.IncidentReports.Should().Be(1); r.Incidents.RunRecords.Should().Be(2, "only records linked to a call carry a call type"); + r.Incidents.ByIncidentType.Single().Should().Match(c => c.Label == "MEDICAL / CARDIAC" && c.Count == 1); + r.Incidents.ByCallType.Should().Contain(c => c.Key == "EMS" && c.Count == 1).And.Contain(c => c.Key == "Fire" && c.Count == 1); + r.Incidents.ByHourOfDay.Single(h => h.Key == "09").Count.Should().Be(2); + r.Incidents.ByMonth.Single().Count.Should().Be(4); + r.Hydrants.OutOfService.Should().Be(1); + r.OccupancyEnabled.Should().BeTrue(); r.Occupancies.Total.Should().Be(0); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs index 57166bdd..65407b12 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs @@ -159,6 +159,30 @@ public async Task Export_is_audited_and_refused_while_content_is_concealed() (await export.Should().ThrowAsync()).Which.Reason.Should().Be("step_up_required"); } + [Test] + public async Task Only_a_lead_reassigns_the_lead_investigator() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, null, "Summary"); + var caseId = investigation.RmsInvestigationCaseId; + await _h.InvestigationsService.AddMemberAsync(Dept, Admin, caseId, Investigator, RmsInvestigationRole.Investigator); + + // An investigator edits the case body, so the posted lead field reaches the service; it must not be applied. + Func steal = () => _h.InvestigationsService.UpdateAsync(Dept, Investigator, + new RmsInvestigationCase { RmsInvestigationCaseId = caseId, Title = "Warehouse fire", LeadInvestigatorUserId = Investigator }); + await steal.Should().ThrowAsync(); + investigation.LeadInvestigatorUserId.Should().Be(Admin); + _h.Audits.Rows.Should().Contain(a => a.Action == (int)RmsAccessAuditAction.Denied && a.CorrelationId == caseId && !a.Successful); + + // Resending the current lead is the ordinary no-op an unchanged form posts. + await _h.InvestigationsService.UpdateAsync(Dept, Investigator, + new RmsInvestigationCase { RmsInvestigationCaseId = caseId, Title = "Warehouse fire, dock", LeadInvestigatorUserId = Admin }); + investigation.Title.Should().Be("Warehouse fire, dock"); + + await _h.InvestigationsService.UpdateAsync(Dept, Admin, + new RmsInvestigationCase { RmsInvestigationCaseId = caseId, Title = "Warehouse fire, dock", LeadInvestigatorUserId = Investigator }); + investigation.LeadInvestigatorUserId.Should().Be(Investigator); + } + [Test] public async Task Investigation_files_are_restricted_and_readable_only_by_members() { diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index b5694b89..aa187e7e 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -161,6 +161,8 @@ public void Feature_flag_keys_match_the_registry() FeatureFlagKeys.RecordsPreventionCrr.Should().Be("Records.Prevention.Crr"); FeatureFlagKeys.RecordsInvestigations.Should().Be("Records.Investigations"); FeatureFlagKeys.RecordsQualityReview.Should().Be("Records.QualityReview"); + FeatureFlagKeys.RecordsAnalytics.Should().Be("Records.Analytics"); + RecordsPreventionModules.FlagKey(RecordsPreventionModule.Analytics).Should().Be("Records.Analytics"); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs index b185db23..064f5286 100644 --- a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs +++ b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs @@ -133,6 +133,7 @@ public class FakeInspections : InMemoryRepo, IRmsInspectionsRepos public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(i => i.RmsOccupancyId == occupancyId).ToList()); public Task> GetLastCompletedByOccupancyAsync(int departmentId, string programId) => Task.FromResult>(Live(departmentId).Where(i => i.RmsInspectionProgramId == programId && i.CompletedOn.HasValue).GroupBy(i => i.RmsOccupancyId).ToDictionary(g => g.Key, g => g.Max(i => i.CompletedOn.Value))); public Task> GetOpenForProgramAsync(int departmentId, string programId) => Task.FromResult>(Live(departmentId).Where(i => i.RmsInspectionProgramId == programId && (i.State == (int)RmsInspectionState.Scheduled || i.State == (int)RmsInspectionState.InProgress || i.State == (int)RmsInspectionState.ReinspectionRequired)).ToList()); + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) => Task.FromResult>(Live(departmentId).Where(i => { var d = i.CompletedOn ?? i.ScheduledOn ?? i.CreatedOn; return d >= startUtc && d < endUtc; }).Take(take).ToList()); public Task TryBumpRowVersionAsync(int departmentId, string inspectionId, long expectedVersion, CancellationToken cancellationToken = default) => Task.FromResult(true); } @@ -146,6 +147,7 @@ public class FakeViolations : InMemoryRepo, IRmsViolationsReposito public Task CountOpenAsync(int departmentId) => Task.FromResult(Live(departmentId).Count(v => v.IsOpen)); public Task CountOverdueAsync(int departmentId, DateTime utcNow) => Task.FromResult(Live(departmentId).Count(v => v.IsOpen && v.DueOn < utcNow)); public Task> CountOpenByOccupancyAsync(int departmentId, IEnumerable occupancyIds) { var ids = occupancyIds.ToHashSet(); return Task.FromResult>(Live(departmentId).Where(v => v.IsOpen && ids.Contains(v.RmsOccupancyId)).GroupBy(v => v.RmsOccupancyId).ToDictionary(g => g.Key, g => g.Count())); } + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) => Task.FromResult>(Live(departmentId).Where(v => v.CreatedOn >= startUtc && v.CreatedOn < endUtc).Take(take).ToList()); } public class FakeHydrants : InMemoryRepo, IRmsHydrantsRepository @@ -163,6 +165,7 @@ public class FakeHydrants : InMemoryRepo, IRmsHydrantsRepository public class FakeFlowTests : InMemoryRepo, IRmsHydrantFlowTestsRepository { public Task> GetForHydrantAsync(int departmentId, string hydrantId) => Task.FromResult>(Rows.Where(t => t.DepartmentId == departmentId && t.RmsHydrantId == hydrantId).ToList()); + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) => Task.FromResult>(Rows.Where(t => t.DepartmentId == departmentId && t.TestedOn >= startUtc && t.TestedOn < endUtc).Take(take).ToList()); } public class FakeMaintenance : InMemoryRepo, IRmsHydrantMaintenancesRepository @@ -185,6 +188,7 @@ public class FakePermits : InMemoryRepo, IRmsPermitsRepository public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(p => p.RmsOccupancyId == occupancyId).ToList()); public Task> GetExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc, int take) => Task.FromResult>(Live(departmentId).Where(p => p.State == (int)RmsPermitState.Issued && p.ExpiresOn != null && p.ExpiresOn <= horizonUtc).Take(take).ToList()); public Task CountExpiringAsync(int departmentId, DateTime utcNow, DateTime horizonUtc) => Task.FromResult(Live(departmentId).Count(p => p.State == (int)RmsPermitState.Issued && p.ExpiresOn > utcNow && p.ExpiresOn <= horizonUtc)); + public Task> GetForRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) => Task.FromResult>(Live(departmentId).Where(p => p.AppliedOn >= startUtc && p.AppliedOn < endUtc).Take(take).ToList()); public Task TryBumpRowVersionAsync(int departmentId, string permitId, long expectedVersion, CancellationToken cancellationToken = default) => Task.FromResult(true); } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs new file mode 100644 index 00000000..59230501 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Models.v4.Records; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// RMS-6 records analytics (RMS plan section 6, RMS-6): response-performance, workload, executive, accreditation and + /// community-risk dashboards over finalized Records. Member principals with Record_View only; a group-scoped member + /// gets figures over the Records they can open, a department administrator the department (plan section 11, q. 38). + /// The window is [start, end) in UTC, defaulting to the last 90 days and clamped to 366; a truncated result says so. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordAnalyticsController : RecordsPreventionApiControllerBase + { + private readonly IRecordsAnalyticsService _analytics; + + public RecordAnalyticsController(IRecordsAnalyticsService analytics, IRecordsCutoverService cutover) : base(cutover) + { + _analytics = analytics; + } + + private static RecordsAnalyticsQuery Query(DateTime? start, DateTime? end, int? stationGroupId, string definitionKey, int turnoutTargetSeconds, int travelTargetSeconds) + => new RecordsAnalyticsQuery { Start = start, End = end, StationGroupId = stationGroupId, DefinitionKey = definitionKey, TurnoutTargetSeconds = turnoutTargetSeconds, TravelTargetSeconds = travelTargetSeconds }; + + /// Turnout, travel, total response, time on scene and first arrival; by unit, station group, hour of day and month. + [HttpGet("ResponsePerformance")] + public async Task> ResponsePerformance(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, string definitionKey = null, int turnoutTargetSeconds = 80, int travelTargetSeconds = 240, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new RecordsResponsePerformanceResult { Data = await _analytics.GetResponsePerformanceAsync(DepartmentId, UserId, Query(start, end, stationGroupId, definitionKey, turnoutTargetSeconds, travelTargetSeconds), cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// Records, hours and unit utilization by definition, month, author, station group, unit and person, plus the weekday/hour heat map. + [HttpGet("Workload")] + public async Task> Workload(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, string definitionKey = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new RecordsWorkloadResult { Data = await _analytics.GetWorkloadAsync(DepartmentId, UserId, Query(start, end, stationGroupId, definitionKey, 80, 240), cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// Headline figures against the preceding window of equal length, lifecycle medians, return and acceptance rates, queues and overdue obligations. + [HttpGet("Executive")] + public async Task> Executive(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, string definitionKey = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new RecordsExecutiveSummaryResult { Data = await _analytics.GetExecutiveSummaryAsync(DepartmentId, UserId, Query(start, end, stationGroupId, definitionKey, 80, 240), cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// Inspection, violation, permit, hydrant, occupancy, training and response evidence; prevention sections only when their module is on. + [HttpGet("Accreditation")] + public async Task> Accreditation(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, int turnoutTargetSeconds = 80, int travelTargetSeconds = 240, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new RecordsAccreditationResult { Data = await _analytics.GetAccreditationAsync(DepartmentId, UserId, Query(start, end, stationGroupId, null, turnoutTargetSeconds, travelTargetSeconds), cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + + /// Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. + [HttpGet("CommunityRisk")] + public async Task> CommunityRisk(DateTime? start = null, DateTime? end = null, int? stationGroupId = null, CancellationToken cancellationToken = default) + { + if (!await FlagOnAsync()) return NotFound(); + try { return Ok(Done(new RecordsCommunityRiskResult { Data = await _analytics.GetCommunityRiskAsync(DepartmentId, UserId, Query(start, end, stationGroupId, null, 80, 240), cancellationToken), PageSize = 1 })); } + catch (Exception ex) { return Fail(ex); } + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs new file mode 100644 index 00000000..59644a82 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordsAnalyticsApiModels.cs @@ -0,0 +1,13 @@ +using Resgrid.Model; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + // RMS-6 records analytics (plan section 6, RMS-6). The dashboard contracts live in the Model and are returned + // as-is: every figure is already scoped, labeled and rounded by the service, and none carries record content. + + public class RecordsResponsePerformanceResult : StandardApiResponseV4Base { public RecordsResponsePerformance Data { get; set; } } + public class RecordsWorkloadResult : StandardApiResponseV4Base { public RecordsWorkload Data { get; set; } } + public class RecordsExecutiveSummaryResult : StandardApiResponseV4Base { public RecordsExecutiveSummary Data { get; set; } } + public class RecordsAccreditationResult : StandardApiResponseV4Base { public RecordsAccreditation Data { get; set; } } + public class RecordsCommunityRiskResult : StandardApiResponseV4Base { public RecordsCommunityRisk Data { get; set; } } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 36bb9684..610aad99 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2749,6 +2749,29 @@ while the Incident Commander/PIO has public sharing enabled. Disabling sharing revokes the token immediately. + + + RMS-6 records analytics (RMS plan section 6, RMS-6): response-performance, workload, executive, accreditation and + community-risk dashboards over finalized Records. Member principals with Record_View only; a group-scoped member + gets figures over the Records they can open, a department administrator the department (plan section 11, q. 38). + The window is [start, end) in UTC, defaulting to the last 90 days and clamped to 366; a truncated result says so. + + + + Turnout, travel, total response, time on scene and first arrival; by unit, station group, hour of day and month. + + + Records, hours and unit utilization by definition, month, author, station group, unit and person, plus the weekday/hour heat map. + + + Headline figures against the preceding window of equal length, lifecycle medians, return and acceptance rates, queues and overdue obligations. + + + Inspection, violation, permit, hydrant, occupancy, training and response evidence; prevention sections only when their module is on. + + + Incident mix, occupancy risk profile, open violations, hydrants and CRR activity; prevention sections only when their module is on. + Definition management and runtime catalog over v4 (RMS plan section 5.4, RMS-1B): template browse, clone/new diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs new file mode 100644 index 00000000..11150fc8 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsAnalyticsController.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// RMS-6 records analytics (RMS plan section 6, RMS-6): executive summary, response performance, workload, + /// accreditation and community risk. The service scopes every figure to what the viewer may open; this + /// controller only resolves the window and the filter lists. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.Record_View)] + public class RecordsAnalyticsController : RecordsPreventionMvcControllerBase + { + private const string Flag = FeatureFlagKeys.RecordsAnalytics; + + private readonly IRecordsAnalyticsService _analytics; + private readonly IRecordDefinitionsService _definitions; + private readonly IDepartmentGroupsService _groups; + + public RecordsAnalyticsController(IRecordsAnalyticsService analytics, IRecordDefinitionsService definitions, IDepartmentGroupsService groups, IRecordsCutoverService cutover, + IFeatureToggleService featureToggles, IStringLocalizer localizer) : base(cutover, featureToggles, localizer) + { + _analytics = analytics; + _definitions = definitions; + _groups = groups; + } + + [HttpGet] + public Task Index(string start = null, string end = null, int? stationGroupId = null, string definitionKey = null) + => Page(new RecordsExecutiveView { Action = nameof(Index), ShowDefinitionFilter = true }, start, end, stationGroupId, definitionKey, null, null, + async (m, q, ct) => m.Summary = await _analytics.GetExecutiveSummaryAsync(DepartmentId, UserId, q, ct)); + + [HttpGet] + public Task ResponsePerformance(string start = null, string end = null, int? stationGroupId = null, string definitionKey = null, int? turnoutTargetSeconds = null, int? travelTargetSeconds = null) + => Page(new RecordsResponsePerformanceView { Action = nameof(ResponsePerformance), ShowDefinitionFilter = true, ShowTargets = true }, start, end, stationGroupId, definitionKey, turnoutTargetSeconds, travelTargetSeconds, + async (m, q, ct) => m.Performance = await _analytics.GetResponsePerformanceAsync(DepartmentId, UserId, q, ct)); + + [HttpGet] + public Task Workload(string start = null, string end = null, int? stationGroupId = null, string definitionKey = null) + => Page(new RecordsWorkloadView { Action = nameof(Workload), ShowDefinitionFilter = true }, start, end, stationGroupId, definitionKey, null, null, + async (m, q, ct) => m.Workload = await _analytics.GetWorkloadAsync(DepartmentId, UserId, q, ct)); + + [HttpGet] + public Task Accreditation(string start = null, string end = null, int? stationGroupId = null, int? turnoutTargetSeconds = null, int? travelTargetSeconds = null) + => Page(new RecordsAccreditationView { Action = nameof(Accreditation), ShowTargets = true }, start, end, stationGroupId, null, turnoutTargetSeconds, travelTargetSeconds, + async (m, q, ct) => m.Accreditation = await _analytics.GetAccreditationAsync(DepartmentId, UserId, q, ct)); + + [HttpGet] + public Task CommunityRisk(string start = null, string end = null, int? stationGroupId = null) + => Page(new RecordsCommunityRiskView { Action = nameof(CommunityRisk) }, start, end, stationGroupId, null, null, null, + async (m, q, ct) => m.Risk = await _analytics.GetCommunityRiskAsync(DepartmentId, UserId, q, ct)); + + private async Task Page(T model, string start, string end, int? stationGroupId, string definitionKey, int? turnoutTarget, int? travelTarget, Func load) where T : RecordsAnalyticsBaseView + { + if (!await ModuleOnAsync(Flag)) return NotFound(); + try + { + Prepare(model); + // Dates arrive as yyyy-MM-dd from the filter form; the end date is inclusive on the page, exclusive in the query. + var endDay = ParseUtc(end)?.Date ?? DateTime.UtcNow.Date; + var startDay = ParseUtc(start)?.Date ?? endDay.AddDays(-RecordsAnalyticsLimits.DefaultWindowDays); + var query = new RecordsAnalyticsQuery + { + Start = startDay, End = endDay.AddDays(1), StationGroupId = stationGroupId > 0 ? stationGroupId : null, DefinitionKey = string.IsNullOrWhiteSpace(definitionKey) ? null : definitionKey, + TurnoutTargetSeconds = Math.Clamp(turnoutTarget ?? 80, 0, 3600), TravelTargetSeconds = Math.Clamp(travelTarget ?? 240, 0, 7200) + }; + await load(model, query, HttpContext.RequestAborted); + model.Start = model.Result?.Start ?? query.Start.Value; model.End = (model.Result?.End ?? query.End.Value).AddDays(-1); + model.StationGroupId = query.StationGroupId; model.DefinitionKey = query.DefinitionKey; + model.TurnoutTargetSeconds = query.TurnoutTargetSeconds; model.TravelTargetSeconds = query.TravelTargetSeconds; + await PopulateAsync(model); + return View(model); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (RecordsModuleDisabledException) { return NotFound(); } + } + + private async Task PopulateAsync(RecordsAnalyticsBaseView model) + { + model.Groups = new[] { new SelectListItem { Value = "", Text = Localizer["AnalyticsAllGroups"].Value } } + .Concat(((await _groups.GetAllGroupsForDepartmentAsync(DepartmentId)) ?? new List()).OrderBy(g => g.Name).Select(g => new SelectListItem { Value = g.DepartmentGroupId.ToString(), Text = g.Name, Selected = g.DepartmentGroupId == model.StationGroupId })) + .ToList(); + if (!model.ShowDefinitionFilter) return; + model.Definitions = new[] { new SelectListItem { Value = "", Text = Localizer["AllDefinitions"].Value } } + .Concat(((await _definitions.ListAsync(DepartmentId)) ?? new List()).Select(d => new SelectListItem { Value = d.Key, Text = d.Name, Selected = string.Equals(d.Key, model.DefinitionKey, StringComparison.OrdinalIgnoreCase) })) + .ToList(); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 696f9235..946b63f8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -212,6 +212,7 @@ public async Task Index(int? year, string definitionKey, string s Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), IsDepartmentAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(), QualityReviewOn = await _featureToggles.IsEnabledAsync(FeatureFlagKeys.RecordsQualityReview, DepartmentId), + AnalyticsOn = await _featureToggles.IsEnabledAsync(FeatureFlagKeys.RecordsAnalytics, DepartmentId), Year = year, DefinitionKey = definitionKey, StateFilter = state, diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs index b1f6dbab..697b60fa 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsQualityController.cs @@ -217,7 +217,7 @@ public async Task Add(int parentKind, string parentId, string des { byte[] bytes; using (var stream = new System.IO.MemoryStream()) { await file.CopyToAsync(stream, cancellationToken); bytes = stream.ToArray(); } - await _attachments.AddAsync(DepartmentId, UserId, (RmsPreventionParentKind)parentKind, parentId, file.FileName, file.ContentType, bytes, description, restricted, cancellationToken); + await _attachments.AddAsync(DepartmentId, UserId, (RmsPreventionParentKind)parentKind, parentId, System.IO.Path.GetFileName(file.FileName), file.ContentType, bytes, description, restricted, cancellationToken); Notify("AttachmentAdded"); } catch (Exception ex) { var f = Fail(ex); if (f != null) return f; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs new file mode 100644 index 00000000..36a2c5fb --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsAnalyticsViewModels.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Records +{ + /// Display helpers for the RMS-6 analytics pages: seconds as m:ss, percentages, deltas. + public static class RmsAnalyticsDisplay + { + public static string Seconds(double? value) + { + if (!value.HasValue) return "-"; + var total = (int)Math.Round(value.Value); + var hours = total / 3600; var minutes = (total % 3600) / 60; var seconds = total % 60; + return hours > 0 ? $"{hours}:{minutes:00}:{seconds:00}" : $"{minutes}:{seconds:00}"; + } + + public static string Percent(double? value) => value.HasValue ? value.Value.ToString("0.0") + "%" : "-"; + public static string Number(double? value) => value.HasValue ? value.Value.ToString("0.#") : "-"; + public static string Hours(double value) => value.ToString("0.#"); + + public static string DeltaClass(double? change) => !change.HasValue ? "text-muted" : change.Value > 0 ? "text-navy" : change.Value < 0 ? "text-danger" : "text-muted"; + public static string Delta(double? change) => !change.HasValue ? "-" : (change.Value > 0 ? "+" : "") + change.Value.ToString("0.#") + "%"; + + /// Per-cell heat-map class from 0 (empty) to 4 (busiest) relative to the page's maximum. + public static string Heat(int count, int max) => count == 0 || max == 0 ? "heat-0" : "heat-" + Math.Clamp((int)Math.Ceiling(4.0 * count / max), 1, 4); + } + + /// Filter state shared by every analytics page; the controller resolves it from the query string. + public abstract class RecordsAnalyticsBaseView : RecordsPreventionBaseView + { + public DateTime Start { get; set; } + public DateTime End { get; set; } + public int? StationGroupId { get; set; } + public string DefinitionKey { get; set; } + public int TurnoutTargetSeconds { get; set; } = 80; + public int TravelTargetSeconds { get; set; } = 240; + public List Groups { get; set; } = new List(); + public List Definitions { get; set; } = new List(); + + /// The controller action this page posts its filter back to. + public string Action { get; set; } + public bool ShowDefinitionFilter { get; set; } + public bool ShowTargets { get; set; } + public abstract RecordsAnalyticsBase Result { get; } + } + + public class RecordsExecutiveView : RecordsAnalyticsBaseView + { + public RecordsExecutiveSummary Summary { get; set; } + public override RecordsAnalyticsBase Result => Summary; + } + + public class RecordsResponsePerformanceView : RecordsAnalyticsBaseView + { + public RecordsResponsePerformance Performance { get; set; } + public override RecordsAnalyticsBase Result => Performance; + } + + public class RecordsWorkloadView : RecordsAnalyticsBaseView + { + public RecordsWorkload Workload { get; set; } + public override RecordsAnalyticsBase Result => Workload; + } + + public class RecordsAccreditationView : RecordsAnalyticsBaseView + { + public RecordsAccreditation Accreditation { get; set; } + public override RecordsAnalyticsBase Result => Accreditation; + } + + public class RecordsCommunityRiskView : RecordsAnalyticsBaseView + { + public RecordsCommunityRisk Risk { get; set; } + public override RecordsAnalyticsBase Result => Risk; + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index 27f61c28..290c2db7 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -87,6 +87,8 @@ public class RecordsIndexView : RecordsBaseView public RecordsModuleState ModuleState { get; set; } /// RMS-4 quality review flag (Records.QualityReview); shows the button on the index only. public bool QualityReviewOn { get; set; } + /// RMS-6 analytics flag (Records.Analytics); shows the button on the index only. + public bool AnalyticsOn { get; set; } public Department Department { get; set; } public bool IsDepartmentAdmin { get; set; } public List Records { get; set; } = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml index 6780757d..159ad063 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml @@ -95,7 +95,16 @@ points.forEach(function (p) { var color = p.InService ? colors[p.FlowClass] || '#999' : '#000'; var m = L.circleMarker([p.Lat, p.Lon], { radius: 7, color: color, fillColor: color, fillOpacity: 0.8 }).addTo(map); - m.bindPopup('' + p.HydrantNumber + '' + (p.FlowGpm ? '
' + p.FlowGpm + ' gpm' : '') + (p.InService ? '' : '
@L["OutOfService"]')); + // Hydrant numbers are department-entered, so the popup is built from text nodes: markup in stored + // data renders as the characters it is instead of executing inside the popup. + var popup = document.createElement('div'); + var link = document.createElement('a'); + link.href = '@Url.Action("Details", "RecordHydrants", new { area = "User" })?id=' + encodeURIComponent(p.HydrantId); + link.textContent = p.HydrantNumber || ''; + popup.appendChild(link); + if (p.FlowGpm) { popup.appendChild(document.createElement('br')); popup.appendChild(document.createTextNode(p.FlowGpm + ' gpm')); } + if (!p.InService) { popup.appendChild(document.createElement('br')); var outOfService = document.createElement('b'); outOfService.textContent = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(L["OutOfService"].Value)); popup.appendChild(outOfService); } + m.bindPopup(popup); bounds.push([p.Lat, p.Lon]); }); if (bounds.length) { map.fitBounds(bounds, { padding: [20, 20] }); } else { map.setView([39.5, -98.35], 4); } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml index f44396cd..0a63c7f0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml @@ -37,7 +37,7 @@ @Html.AntiForgeryToken()

@RmsEnumDisplay.Utc(c.OpenedOn) · @Model.UserName(c.OpenedByUserId)

-
@if (Model.IsLead && !c.IsClosed) { } else {

@Model.UserName(c.LeadInvestigatorUserId)

}
+
@if (Model.IsLead && !c.IsClosed) { } else {

@Model.UserName(c.LeadInvestigatorUserId)

}
@if (!string.IsNullOrWhiteSpace(c.RmsOccupancyId)) { @L["ViewRecordHeader"] }
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml index 4194afb5..a1befe0c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml @@ -1,4 +1,4 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Model.Helpers @using Resgrid.Web.Helpers @using Resgrid.Web.Areas.User.Models.Records @@ -74,6 +74,10 @@ { @localizer["QualityReview"] } + @if (Model.ModuleState.RecordsUsable && Model.AnalyticsOn) + { + @localizer["AnalyticsHeader"] + } @if (Model.IsDepartmentAdmin) { @localizer["RecordsHealth"] diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Accreditation.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Accreditation.cshtml new file mode 100644 index 00000000..fa28c1c1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Accreditation.cshtml @@ -0,0 +1,122 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordsAccreditationView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["AnalyticsAccreditation"]; + var a = Model.Accreditation; +} +@await Html.PartialAsync("_AnalyticsShell", Model) +
+ @if (a != null) + { +
+
+
@L["AnalyticsResponseSummary"]
+
+
@L["AnalyticsResponses"]
@a.Response.Responses
+
@L["AnalyticsTurnout"] @L["AnalyticsP90"]
@RmsAnalyticsDisplay.Seconds(a.Response.TurnoutP90Seconds) · @L["AnalyticsWithinTarget"] @RmsAnalyticsDisplay.Percent(a.Response.TurnoutWithinTargetPercent)
+
@L["AnalyticsTravel"] @L["AnalyticsP90"]
@RmsAnalyticsDisplay.Seconds(a.Response.TravelP90Seconds) · @L["AnalyticsWithinTarget"] @RmsAnalyticsDisplay.Percent(a.Response.TravelWithinTargetPercent)
+
@L["AnalyticsTotalResponse"] @L["AnalyticsP90"]
@RmsAnalyticsDisplay.Seconds(a.Response.TotalResponseP90Seconds)
+
@L["AnalyticsFirstArrival"] @L["AnalyticsP90"]
@RmsAnalyticsDisplay.Seconds(a.Response.FirstArrivalP90Seconds)
+
+
+
@L["AnalyticsTraining"]
+
+
@L["AnalyticsTrainingRecords"]
@a.Training.Records
+
@L["AnalyticsTrainingHours"]
@RmsAnalyticsDisplay.Hours(a.Training.Hours)
+
@L["AnalyticsMembersTrained"]
@a.Training.MembersTrained
+
@L["AnalyticsHoursPerMember"]
@RmsAnalyticsDisplay.Number(a.Training.HoursPerMember)
+
+ @await Html.PartialAsync("_AnalyticsCounts", a.Training.ByMonth, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByMonth"].Value }, { "Chart", "bar" }, { "ShowHours", true }, { "Take", 24 } }) +
+
+
+
@L["Inspections"]
+ @if (!a.InspectionsEnabled || a.Inspections == null) {

@L["AnalyticsModuleOff"]

} + else + { + var i = a.Inspections; +
+
@L["AnalyticsCompleted"]
@i.Completed (@L["AnalyticsPassed"] @i.Passed · @L["AnalyticsFailed"] @i.Failed · @L["AnalyticsConditional"] @i.Conditional)
+
@L["AnalyticsPassRate"]
@RmsAnalyticsDisplay.Percent(i.PassRatePercent)
+
@L["AnalyticsOnSchedule"]
@RmsAnalyticsDisplay.Percent(i.OnSchedulePercent) (@i.CompletedOnSchedule)
+
@L["AnalyticsDaysScheduledToCompleted"]
@RmsAnalyticsDisplay.Number(i.AverageDaysScheduledToCompleted)
+
@L["AnalyticsReinspections"]
@i.ReinspectionsRequired
+
@L["AnalyticsCancelled"]
@i.Cancelled
+
@L["AnalyticsOpenNow"]
@i.OpenNow (@i.OverdueNow @L["Overdue"])
+
+ @await Html.PartialAsync("_AnalyticsCounts", i.ByProgram, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByProgram"].Value } }) + } +
+
@L["Violations"]
+ @if (!a.InspectionsEnabled || a.Violations == null) {

@L["AnalyticsModuleOff"]

} + else + { + var v = a.Violations; +
+
@L["AnalyticsOpened"]
@v.Opened
+
@L["AnalyticsCorrected"]
@v.Corrected (@L["AnalyticsVerified"] @v.Verified · @L["AnalyticsWaived"] @v.Waived · @L["AnalyticsEscalated"] @v.Escalated)
+
@L["AnalyticsDaysToCorrection"]
@RmsAnalyticsDisplay.Number(v.AverageDaysToCorrection)
+
@L["AnalyticsOpenNow"]
@v.OpenNow (@v.OverdueNow @L["Overdue"])
+
+ @await Html.PartialAsync("_AnalyticsCounts", v.BySeverity, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsBySeverity"].Value } }) + } +
+
+
+
+
+
@L["Permits"]
+ @if (!a.PermitsEnabled || a.Permits == null) {

@L["AnalyticsModuleOff"]

} + else + { + var pm = a.Permits; +
+
@L["AnalyticsApplied"]
@pm.Applied
+
@L["AnalyticsIssued"]
@pm.Issued
+
@L["AnalyticsDenied"]
@pm.Denied
+
@L["AnalyticsExpired"]
@pm.Expired · @L["AnalyticsRevoked"] @pm.Revoked
+
@L["AnalyticsActiveNow"]
@pm.ActiveNow
+
@L["AnalyticsDaysToIssue"]
@RmsAnalyticsDisplay.Number(pm.AverageDaysAppliedToIssued)
+
+ @await Html.PartialAsync("_AnalyticsCounts", pm.ByType, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByType"].Value } }) + } +
+
+
+
@L["Hydrants"]
+ @if (!a.HydrantsEnabled || a.Hydrants == null) {

@L["AnalyticsModuleOff"]

} + else + { + var h = a.Hydrants; +
+
@L["AnalyticsTotal"]
@h.Total
+
@L["AnalyticsInService"]
@h.InService · @L["OutOfService"] @h.OutOfService
+
@L["AnalyticsTestedWithin12Months"]
@RmsAnalyticsDisplay.Percent(h.TestedWithin12MonthsPercent) (@h.TestedWithin12Months)
+
@L["AnalyticsFlowTests"]
@h.FlowTestsInWindow
+
+ @await Html.PartialAsync("_AnalyticsCounts", h.ByFlowClass, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByFlowClass"].Value } }) + } +
+
+
+
@L["Occupancies"]
+ @if (!a.OccupancyEnabled || a.Occupancies == null) {

@L["AnalyticsModuleOff"]

} + else + { + var o = a.Occupancies; +
+
@L["AnalyticsTotal"]
@o.Total
+
@L["AnalyticsReviewCurrent"]
@RmsAnalyticsDisplay.Percent(o.ReviewCurrentPercent) (@o.ReviewCurrent) · @L["AnalyticsReviewOverdue"] @o.ReviewOverdue
+
@L["AnalyticsInspectedWithin12Months"]
@RmsAnalyticsDisplay.Percent(o.InspectedWithin12MonthsPercent) (@o.InspectedWithin12Months)
+
@L["AnalyticsHazmat"]
@o.HazmatOnSite
+
@L["AnalyticsOpenViolations"]
@o.OpenViolations
+
+ @await Html.PartialAsync("_AnalyticsCounts", o.ByType, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByType"].Value } }) + } +
+
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/CommunityRisk.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/CommunityRisk.cshtml new file mode 100644 index 00000000..d0b5ed70 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/CommunityRisk.cshtml @@ -0,0 +1,76 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordsCommunityRiskView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["AnalyticsCommunityRisk"]; + var r = Model.Risk; +} +@await Html.PartialAsync("_AnalyticsShell", Model) +
+ @if (r != null) + { +
@L["AnalyticsIncidents"]
@L["AnalyticsIncidentReports"]: @r.Incidents.IncidentReports · @L["AnalyticsRunRecords"]: @r.Incidents.RunRecords
+
+
@await Html.PartialAsync("_AnalyticsCounts", r.Incidents.ByMonth, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByMonth"].Value }, { "Chart", "line" }, { "Take", 24 } })
+
@await Html.PartialAsync("_AnalyticsCounts", r.Incidents.ByHourOfDay.Where(c => c.Count > 0).ToList(), new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByHour"].Value }, { "Chart", "bar" }, { "Take", 24 } })
+
+ @await Html.PartialAsync("_AnalyticsCounts", r.Incidents.ByIncidentType, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByIncidentType"].Value } }) + @await Html.PartialAsync("_AnalyticsCounts", r.Incidents.ByCallType, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByCallType"].Value } }) +
+
+
+
+
+
@L["AnalyticsCrr"]
+ @if (!r.CrrEnabled || r.Crr == null) {

@L["AnalyticsModuleOff"]

} + else + { +
+
@L["AnalyticsActivities"]
@r.Crr.Activities
+
@L["AnalyticsAudience"]
@r.Crr.Audience
+
@L["SmokeAlarmsInstalled"]
@r.Crr.SmokeAlarmsInstalled
+
@L["AnalyticsHours"]
@RmsAnalyticsDisplay.Hours(r.Crr.Hours)
+
+ @await Html.PartialAsync("_AnalyticsCounts", r.Crr.ByKind, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByKind"].Value }, { "Chart", "doughnut" }, { "ShowHours", true } }) + @await Html.PartialAsync("_AnalyticsCounts", r.Crr.ByMonth, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByMonth"].Value }, { "ShowHours", true }, { "Take", 24 } }) + } +
+
+
+
@L["AnalyticsOccupancyRisk"]
+ @if (!r.OccupancyEnabled || r.Occupancies == null) {

@L["AnalyticsModuleOff"]

} + else + { + var o = r.Occupancies; +
+
@L["AnalyticsTotal"]
@o.Total
+
@L["AnalyticsHazmat"]
@o.HazmatOnSite
+
@L["AnalyticsNoSprinklers"]
@o.NoSprinklers
+
@L["AnalyticsNeedingAssistance"]
@o.OccupantsNeedingAssistance
+
@L["AnalyticsVacant"]
@o.Vacant
+
@L["AnalyticsReviewOverdue"]
@o.ReviewOverdue
+ @if (r.InspectionsEnabled) {
@L["AnalyticsOpenViolations"]
@o.OpenViolations
} +
+ @await Html.PartialAsync("_AnalyticsCounts", o.ByType, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByType"].Value }, { "Chart", "doughnut" } }) + @if (r.InspectionsEnabled) { @await Html.PartialAsync("_AnalyticsCounts", o.ViolationsBySeverity, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsOpenViolations"].Value + " - " + L["AnalyticsBySeverity"].Value } }) } + } +
+
+
+
@L["Hydrants"]
+ @if (!r.HydrantsEnabled || r.Hydrants == null) {

@L["AnalyticsModuleOff"]

} + else + { +
+
@L["AnalyticsTotal"]
@r.Hydrants.Total
+
@L["OutOfService"]
@r.Hydrants.OutOfService
+
@L["AnalyticsTestedWithin12Months"]
@RmsAnalyticsDisplay.Percent(r.Hydrants.TestedWithin12MonthsPercent)
+
+ @await Html.PartialAsync("_AnalyticsCounts", r.Hydrants.ByFlowClass, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByFlowClass"].Value }, { "Chart", "doughnut" } }) + } +
+
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml new file mode 100644 index 00000000..84bb5544 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Index.cshtml @@ -0,0 +1,71 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordsExecutiveView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["AnalyticsExecutive"]; + var s = Model.Summary; + string KpiLabel(string key) + { + switch (key) + { + case "finalized": return L["AnalyticsRecordsFinalized"].Value; + case "incidentReports": return L["AnalyticsIncidentReports"].Value; + case "nerisAccepted": return L["AnalyticsNerisAccepted"].Value; + case "nerisRejected": return L["AnalyticsNerisRejected"].Value; + case "unitResponses": return L["AnalyticsResponses"].Value; + case "personnelHours": return L["AnalyticsPersonnelHours"].Value; + case "trainingHours": return L["AnalyticsTrainingHours"].Value; + case "amended": return L["AnalyticsAmended"].Value; + case "voided": return L["AnalyticsVoided"].Value; + default: return key; + } + } +} +@await Html.PartialAsync("_AnalyticsShell", Model) +
+ @if (s != null) + { +
@L["AnalyticsHeadline"]
@L["AnalyticsPriorPeriod"]: @s.PriorStart.ToString("yyyy-MM-dd") - @s.PriorEnd.AddDays(-1).ToString("yyyy-MM-dd")
+
+ @foreach (var k in s.Kpis) + { +
+
@RmsAnalyticsDisplay.Number(k.Current)
+
@KpiLabel(k.Key)
+
@RmsAnalyticsDisplay.Delta(k.ChangePercent) (@RmsAnalyticsDisplay.Number(k.Prior))
+
+ } +
+
+
+
+
@L["AnalyticsLifecycle"]
+
+
@L["AnalyticsMedianHoursToFinalize"]
@RmsAnalyticsDisplay.Number(s.MedianHoursToFinalize)
+
@L["AnalyticsMedianReviewHours"]
@RmsAnalyticsDisplay.Number(s.MedianReviewHours)
+
@L["AnalyticsReturnRate"]
@RmsAnalyticsDisplay.Percent(s.ReturnRatePercent)
+
@L["AnalyticsNerisAcceptance"]
@RmsAnalyticsDisplay.Percent(s.NerisAcceptanceRatePercent)
+
@L["AnalyticsTurnout"] @L["AnalyticsP90"]
@RmsAnalyticsDisplay.Seconds(s.TurnoutP90Seconds)
+
@L["AnalyticsTotalResponse"] @L["AnalyticsP90"]
@RmsAnalyticsDisplay.Seconds(s.TotalResponseP90Seconds)
+
@L["AnalyticsFirstArrival"] @L["AnalyticsP90"]
@RmsAnalyticsDisplay.Seconds(s.FirstArrivalP90Seconds)
+
+
+
@L["AnalyticsQueues"]
+
+
@L["AnalyticsOpenDrafts"]
@s.OpenDrafts
+
@L["AnalyticsAwaitingReview"]
@s.AwaitingReview
+
@L["AnalyticsOverdueNow"]
@s.OverdueNow
+
@L["AnalyticsWentOverdue"]
@s.WentOverdue
+
+
+
+
+ @await Html.PartialAsync("_AnalyticsCounts", s.ByMonth, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByMonth"].Value }, { "Chart", "line" }, { "Take", 24 } }) +
+
+ @await Html.PartialAsync("_AnalyticsCounts", s.ByDefinition, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ByDefinition"].Value }, { "Chart", "doughnut" }, { "ShowHours", true } }) +
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/ResponsePerformance.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/ResponsePerformance.cshtml new file mode 100644 index 00000000..20829e01 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/ResponsePerformance.cshtml @@ -0,0 +1,46 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordsResponsePerformanceView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["AnalyticsResponse"]; + var p = Model.Performance; + var stats = p == null ? new (string, RecordsResponseTimeStat)[0] : new[] { (L["AnalyticsTurnout"].Value, p.Turnout), (L["AnalyticsTravel"].Value, p.Travel), (L["AnalyticsTotalResponse"].Value, p.TotalResponse), (L["AnalyticsFirstArrival"].Value, p.FirstArrival), (L["AnalyticsOnScene"].Value, p.OnScene) }; +} +@await Html.PartialAsync("_AnalyticsShell", Model) +
+ @if (p != null) + { +
@L["AnalyticsResponse"]
@L["AnalyticsResponses"]: @p.Responses · @L["AnalyticsRecordsWithResponses"]: @p.RecordsWithResponses
+ @if (p.Responses == 0) {

@L["AnalyticsNoData"]

} + else + { + + + + @foreach (var (label, stat) in stats) + { + + + + + + + } + +
@L["AnalyticsSamples"]@L["AnalyticsAverage"]@L["AnalyticsMedian"]@L["AnalyticsP90"]@L["AnalyticsMax"]@L["AnalyticsWithinTarget"]
@label @if (stat.TargetSeconds.HasValue) { (≤ @RmsAnalyticsDisplay.Seconds(stat.TargetSeconds)) }@stat.Samples@RmsAnalyticsDisplay.Seconds(stat.AverageSeconds)@RmsAnalyticsDisplay.Seconds(stat.MedianSeconds)@RmsAnalyticsDisplay.Seconds(stat.P90Seconds)@RmsAnalyticsDisplay.Seconds(stat.MaxSeconds)@(stat.TargetSeconds.HasValue ? RmsAnalyticsDisplay.Percent(stat.WithinTargetPercent) + " (" + stat.WithinTarget + ")" : "-")
+ } +
+
+
@await Html.PartialAsync("_AnalyticsTimeRows", p.ByMonth, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByMonth"].Value }, { "Chart", "line" } })
+
@await Html.PartialAsync("_AnalyticsTimeRows", p.ByHourOfDay, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByHour"].Value }, { "Chart", "bar" } })
+
+
+
@await Html.PartialAsync("_AnalyticsTimeRows", p.ByUnit, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ByUnit"].Value }, { "Chart", "none" } })
+
+ @await Html.PartialAsync("_AnalyticsTimeRows", p.ByStationGroup, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByStationGroup"].Value }, { "Chart", "none" } }) + @await Html.PartialAsync("_AnalyticsTimeRows", p.BySource, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsBySource"].Value }, { "Chart", "none" } }) +
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Workload.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Workload.cshtml new file mode 100644 index 00000000..6372db2e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Workload.cshtml @@ -0,0 +1,53 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordsWorkloadView +@inject IStringLocalizer L +@{ + ViewBag.Title = "Resgrid | " + L["AnalyticsHeader"] + " - " + L["AnalyticsWorkload"]; + var w = Model.Workload; + var max = w == null || w.ByWeekdayHour.Count == 0 ? 0 : w.ByWeekdayHour.Max(c => c.Count); + var days = System.Globalization.CultureInfo.CurrentUICulture.DateTimeFormat.AbbreviatedDayNames; +} +@await Html.PartialAsync("_AnalyticsShell", Model) +
+ @if (w != null) + { +
+
+
@w.RecordsFinalized
@L["AnalyticsRecordsFinalized"]
+
@w.IncidentReportsFinalized
@L["AnalyticsIncidentReports"]
+
@RmsAnalyticsDisplay.Hours(w.RecordHours)
@L["AnalyticsRecordHours"]
+
@RmsAnalyticsDisplay.Hours(w.PersonnelHours)
@L["AnalyticsPersonnelHours"]
+
@RmsAnalyticsDisplay.Hours(w.TrainingHours)
@L["AnalyticsTrainingHours"]
+
@w.UnitResponses
@L["AnalyticsResponses"]
@RmsAnalyticsDisplay.Hours(w.UnitOnSceneHours) @L["AnalyticsUnitOnSceneHours"]
+
+
+
+
@await Html.PartialAsync("_AnalyticsCounts", w.ByMonth, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByMonth"].Value }, { "Chart", "bar" }, { "ShowHours", true }, { "Take", 24 } })
+
@await Html.PartialAsync("_AnalyticsCounts", w.ByDefinition, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ByDefinition"].Value }, { "Chart", "doughnut" }, { "ShowHours", true } })
+
+
@L["AnalyticsHeatMap"]
+ @if (max == 0) {

@L["AnalyticsNoData"]

} + else + { + + @for (var h = 0; h < 24; h++) { } + + @for (var d = 0; d < 7; d++) + { + @for (var h = 0; h < 24; h++) { var cell = w.ByWeekdayHour.FirstOrDefault(c => c.Weekday == d && c.Hour == h); var n = cell?.Count ?? 0; } + } + +
@h
@days[d]@(n == 0 ? "" : n.ToString())
+ } +
+
+
@await Html.PartialAsync("_AnalyticsCounts", w.ByPerson, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByPerson"].Value }, { "ShowHours", true }, { "Take", 25 } })
+
@await Html.PartialAsync("_AnalyticsCounts", w.ByUnit, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ByUnit"].Value }, { "Chart", "bar" }, { "ShowHours", true }, { "Take", 25 } })
+
+ @await Html.PartialAsync("_AnalyticsCounts", w.ByAuthor, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["ByAuthor"].Value }, { "ShowHours", true }, { "Take", 25 } }) + @await Html.PartialAsync("_AnalyticsCounts", w.ByStationGroup, new Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary(ViewData) { { "Title", L["AnalyticsByStationGroup"].Value }, { "ShowHours", true } }) +
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml new file mode 100644 index 00000000..9161fb22 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml @@ -0,0 +1,34 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model List +@inject IStringLocalizer L +@{ + // ViewData: Title, ShowHours (bool), Chart (bar|line|doughnut|none), Take (int) + var title = ViewData["Title"] as string ?? ""; + var showHours = ViewData["ShowHours"] as bool? ?? false; + var chart = ViewData["Chart"] as string ?? "none"; + var take = ViewData["Take"] as int? ?? 15; + var id = "c" + Guid.NewGuid().ToString("N"); + var rows = (Model ?? new List()).Take(take).ToList(); + var showPercent = rows.Any(r => r.Percent.HasValue); +} +
@title
+ @if (rows.Count == 0) {

@L["AnalyticsNoData"]

} + else + { + if (chart != "none") + { +
+ + } + + @if (showHours) { }@if (showPercent) { } + + @foreach (var row in rows) + { + @if (showHours) { }@if (showPercent) { } + } + +
@L["AnalyticsCount"]@L["AnalyticsHours"]@L["AnalyticsShare"]
@row.Label@row.Count@RmsAnalyticsDisplay.Hours(row.Hours)@RmsAnalyticsDisplay.Percent(row.Percent)
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml new file mode 100644 index 00000000..d5691f50 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml @@ -0,0 +1,77 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model RecordsAnalyticsBaseView +@inject IStringLocalizer L +@{ + var tabs = new[] + { + ("Index", "fa-tachometer", L["AnalyticsExecutive"].Value), + ("ResponsePerformance", "fa-clock-o", L["AnalyticsResponse"].Value), + ("Workload", "fa-tasks", L["AnalyticsWorkload"].Value), + ("Accreditation", "fa-certificate", L["AnalyticsAccreditation"].Value), + ("CommunityRisk", "fa-shield", L["AnalyticsCommunityRisk"].Value) + }; + var result = Model.Result; +} + +
+
+

@L["AnalyticsHeader"]

+ +
+
+
+
+ +
+
+
+ + + + @if (Model.ShowDefinitionFilter) + { + + } + @if (Model.ShowTargets) + { + + + } + + @if (result != null) { @L["GeneratedOn"] @RmsEnumDisplay.Utc(result.GeneratedOn) · @L["AnalyticsRecordsRead"]: @result.RecordsRead } +
+

@L["AnalyticsIntro"]

+
+ @if (result != null) + { + if (result.GroupScoped) {
@L["AnalyticsGroupScoped"]
} + foreach (var w in result.Warnings) {
@w
} + } + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} +
+ diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml new file mode 100644 index 00000000..d6b803ec --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml @@ -0,0 +1,38 @@ +@using Resgrid.Model +@using Resgrid.Web.Areas.User.Models.Records +@model List +@inject IStringLocalizer L +@{ + // ViewData: Title, Chart (bar|line|none) - the chart plots total-response p90 per row + var title = ViewData["Title"] as string ?? ""; + var chart = ViewData["Chart"] as string ?? "none"; + var id = "t" + Guid.NewGuid().ToString("N"); + var rows = (Model ?? new List()).Where(r => r.Responses > 0).ToList(); +} +
@title
+ @if (rows.Count == 0) {

@L["AnalyticsNoData"]

} + else + { + if (chart != "none") + { +
+ + } +
+ + + + @foreach (var row in rows) + { + + + + + + + } + +
@L["AnalyticsResponses"]@L["AnalyticsTurnout"] @L["AnalyticsP90"]@L["AnalyticsWithinTarget"]@L["AnalyticsTravel"] @L["AnalyticsP90"]@L["AnalyticsWithinTarget"]@L["AnalyticsTotalResponse"] @L["AnalyticsP90"]@L["AnalyticsOnScene"] @L["AnalyticsMedian"]
@row.Label@row.Responses@RmsAnalyticsDisplay.Seconds(row.Turnout.P90Seconds)@RmsAnalyticsDisplay.Percent(row.Turnout.WithinTargetPercent)@RmsAnalyticsDisplay.Seconds(row.Travel.P90Seconds)@RmsAnalyticsDisplay.Percent(row.Travel.WithinTargetPercent)@RmsAnalyticsDisplay.Seconds(row.TotalResponse.P90Seconds)@RmsAnalyticsDisplay.Seconds(row.OnScene.MedianSeconds)
+
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index 97736190..74203a61 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -1,4 +1,4 @@ -@inject Resgrid.Model.Services.IFeatureToggleService featureToggleService +@inject Resgrid.Model.Services.IFeatureToggleService featureToggleService @inject Resgrid.Model.Services.IRecordsAuthorizationService recordsAuthorization @inject IStringLocalizer recordsLocalizer @{ @@ -93,6 +93,7 @@ var preventionPermitsOn = preventionOccupancyOn && await featureToggleService.IsEnabledAsync(FeatureFlagKeys.RecordsPreventionPermits, navDepartmentId); var preventionCrrOn = await featureToggleService.IsEnabledAsync(FeatureFlagKeys.RecordsPreventionCrr, navDepartmentId); var investigationsOn = ClaimsAuthorizationHelper.CanViewRestrictedRecords() && await featureToggleService.IsEnabledAsync(FeatureFlagKeys.RecordsInvestigations, navDepartmentId); + var analyticsOn = await featureToggleService.IsEnabledAsync(FeatureFlagKeys.RecordsAnalytics, navDepartmentId); @if (preventionOccupancyOn) {
  • @recordsLocalizer["Occupancies"]
  • @@ -117,6 +118,10 @@ {
  • @recordsLocalizer["Investigations"]
  • } + @if (analyticsOn) + { +
  • @recordsLocalizer["AnalyticsHeader"]
  • + } } else if (SettingsHelper.IsLogsEnabled()) { From 10278a4f05f01dc0c66e7f5d3cbc7951c6db74c8 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 8 Sep 2026 09:05:42 -0700 Subject: [PATCH 2/2] RG-T55 RMS Address PR #502 review findings --- .../Records/RecordsAnalyticsContracts.cs | 6 +++++- .../Repositories/IRmsRepositories.cs | 8 ++++++-- .../Records/RecordsAnalyticsService.cs | 10 ++++++---- .../Records/RecordsInvestigationsService.cs | 7 +++++++ .../RmsRepositories.cs | 4 ++-- .../Rms/RecordsAnalyticsServiceTests.cs | 4 ++-- .../Rms/RecordsInvestigationsServiceTests.cs | 18 ++++++++++++++++++ .../v4/RecordAnalyticsController.cs | 8 +++++++- .../User/Views/RecordHydrants/Index.cshtml | 2 +- .../RecordsAnalytics/_AnalyticsCounts.cshtml | 5 ++++- .../RecordsAnalytics/_AnalyticsShell.cshtml | 14 ++++++++------ .../RecordsAnalytics/_AnalyticsTimeRows.cshtml | 5 ++++- 12 files changed, 70 insertions(+), 21 deletions(-) diff --git a/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs b/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs index ef8f040f..88ea6bca 100644 --- a/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs @@ -17,7 +17,11 @@ public sealed class RecordsAnalyticsQuery /// Exclusive end (UTC). Defaults to now. public DateTime? End { get; set; } - /// Restrict to Records whose station group (or, for a unit response, the unit's group snapshot) is this group. + /// + /// Restrict to Records and incident reports whose own station group is this group. The filter is on the Record + /// header, not on the responding unit's group snapshot, so a dashboard filtered to a station reads that station's + /// incidents including any mutual-aid unit that ran them. + /// public int? StationGroupId { get; set; } /// Restrict operational Records to one definition key (e.g. system.run). diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index 2defd8a9..f7465995 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -251,8 +251,12 @@ public interface IRmsRecordDueStatesRepository : IRepository /// Count of obligations currently sitting overdue; the accountability view and dashboards read it. Task CountOverdueAsync(int departmentId); Task CountVisibleOverdueAsync(int departmentId, List visibleGroupIds, string userId); - /// Due-state rows modified in [start, end) - the due-state history the RMS-6 dashboards read - at most rows. - Task> GetChangedInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); + /// + /// Due-state rows whose last emission falls in [start, end) - the due-state history the RMS-6 dashboards read - at + /// most rows. The row is mutable, so the emission time and not the row's change time is what + /// dates the transition: a row that went overdue inside the window but was touched again after it still counts. + /// + Task> GetLastEmittedInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take); Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs index cb3c9977..ee9cbb4f 100644 --- a/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs +++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs @@ -357,7 +357,7 @@ public async Task GetResponsePerformanceAsync(int de { var c = await BeginAsync(departmentId, userId, query); var data = await LoadAsync(c, c.Start, c.End, true); - var result = new RecordsResponsePerformance(); + var result = new RecordsResponsePerformance { DepartmentId = c.DepartmentId }; await SectionAsync(result, "Response times", async () => { var labels = await LabelsAsync(departmentId, true, true, false); @@ -391,7 +391,7 @@ public async Task GetWorkloadAsync(int departmentId, string use { var c = await BeginAsync(departmentId, userId, query); var data = await LoadAsync(c, c.Start, c.End, true); - var result = new RecordsWorkload(); + var result = new RecordsWorkload { DepartmentId = c.DepartmentId }; await SectionAsync(result, "Workload", async () => { var labels = await LabelsAsync(departmentId, true, true, true); @@ -440,7 +440,7 @@ public async Task GetExecutiveSummaryAsync(int departme { var c = await BeginAsync(departmentId, userId, query); var data = await LoadAsync(c, c.Start, c.End, true); - var result = new RecordsExecutiveSummary { PriorStart = c.Start - c.Span, PriorEnd = c.Start }; + var result = new RecordsExecutiveSummary { DepartmentId = c.DepartmentId, PriorStart = c.Start - c.Span, PriorEnd = c.Start }; Dataset prior = null; await SectionAsync(result, "Prior period", async () => { prior = await LoadAsync(c, result.PriorStart, result.PriorEnd, true); }); await SectionAsync(result, "Headline figures", async () => @@ -484,7 +484,7 @@ await SectionAsync(result, "Queues and obligations", async () => private async Task WentOverdueAsync(Context c, Dataset data, DateTime start, DateTime end) { - var rows = ((await _dueStates.GetChangedInRangeAsync(c.DepartmentId, start, end, RecordsAnalyticsLimits.RowCap)) ?? Enumerable.Empty()) + var rows = ((await _dueStates.GetLastEmittedInRangeAsync(c.DepartmentId, start, end, RecordsAnalyticsLimits.RowCap)) ?? Enumerable.Empty()) .Where(d => d.OverdueCount > 0 && d.LastEmittedOn.HasValue && d.LastEmittedOn >= start && d.LastEmittedOn < end && (d.LastEmittedState == (int)RmsDueState.Overdue || d.LastEmittedState == (int)RmsDueState.Cleared)); if (!c.GroupScoped) return rows.Count(); // A group-scoped viewer only counts obligations on Records they can open. @@ -526,6 +526,7 @@ public async Task GetAccreditationAsync(int departmentId, var data = await LoadAsync(c, c.Start, c.End, true); var result = new RecordsAccreditation { + DepartmentId = c.DepartmentId, OccupancyEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Occupancy), InspectionsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections), HydrantsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Hydrants), @@ -687,6 +688,7 @@ public async Task GetCommunityRiskAsync(int departmentId, var data = await LoadAsync(c, c.Start, c.End, true); var result = new RecordsCommunityRisk { + DepartmentId = c.DepartmentId, CrrEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Crr), OccupancyEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Occupancy), InspectionsEnabled = await _gate.IsEnabledAsync(departmentId, RecordsPreventionModule.Inspections), diff --git a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs index 79582ad6..f6f540ea 100644 --- a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs +++ b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs @@ -165,6 +165,13 @@ await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Denied, AuditP if (!await _authorization.IsActiveMemberAsync(requestedLead, departmentId)) throw new ArgumentException("The lead investigator must be an active member of the department."); + // Every case operation goes through RequireMemberAsync, so a lead who is not on the case could not open + // the case they lead. Add them to the case first, then hand them the lead. + var onCase = ((await _members.GetForCaseAsync(departmentId, investigation.RmsInvestigationCaseId)) ?? Enumerable.Empty()) + .Any(m => m.UserId == requestedLead && m.IsActive); + if (!onCase) + throw new ArgumentException("The lead investigator must be an active member of the case."); + investigation.LeadInvestigatorUserId = requestedLead; } investigation.ModifiedOn = DateTime.UtcNow; investigation.RowVersion++; diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 282bed54..6d9617b5 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -1284,7 +1284,7 @@ public Task CountVisibleOverdueAsync(int departmentId, List visibleGro new { DepartmentId = departmentId, Overdue = (int)RmsDueState.Overdue, VisibleGroupIds = InListValue(visibleGroupIds), Viewer = userId }); } - public Task> GetChangedInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) + public Task> GetLastEmittedInRangeAsync(int departmentId, DateTime startUtc, DateTime endUtc, int take) { var parameters = new DynamicParameters(); parameters.Add("DepartmentId", departmentId); @@ -1293,7 +1293,7 @@ public Task> GetChangedInRangeAsync(int departmen parameters.Add("Skip", 0); parameters.Add("Take", take <= 0 ? 5000 : Math.Min(take, 200000)); return QueryAsync( - $"SELECT * FROM {Tbl("RmsRecordDueStates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ModifiedOn")} >= {P}Start AND {Col("ModifiedOn")} < {P}End ORDER BY {Col("ModifiedOn")}, {Col("RmsRecordDueStateId")} {Paging()}", + $"SELECT * FROM {Tbl("RmsRecordDueStates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("LastEmittedOn")} >= {P}Start AND {Col("LastEmittedOn")} < {P}End ORDER BY {Col("LastEmittedOn")}, {Col("RmsRecordDueStateId")} {Paging()}", parameters); } diff --git a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs index 8244591f..b29fc92c 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsAnalyticsServiceTests.cs @@ -64,7 +64,7 @@ public void SetUp() _reportUnits.Setup(u => u.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => { var set = ids.ToHashSet(); return _reportUnitRows.Where(u => set.Contains(u.RevisionId)).ToList(); }); _incidentTypes.Setup(t => t.GetForRevisionsAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => ids.SelectMany(id => new[] { new RmsIncidentType { RevisionId = id, TypeCode = "FIRE||STRUCTURE", IsPrimary = false, Ordinal = 0 }, new RmsIncidentType { RevisionId = id, TypeCode = "MEDICAL||CARDIAC", IsPrimary = true, Ordinal = 1 } }).ToList()); _revisions.Setup(r => r.GetTransitionsInRangeAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); - _dueStates.Setup(d => d.GetChangedInRangeAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _dueStates.Setup(d => d.GetLastEmittedInRangeAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); _dueStates.Setup(d => d.CountVisibleOverdueAsync(Dept, It.IsAny>(), It.IsAny())).ReturnsAsync(4); _unitsService.Setup(u => u.GetUnitsForDepartmentAsync(Dept)).ReturnsAsync(new List { new Unit { UnitId = 1, Name = "Engine 1" }, new Unit { UnitId = 2, Name = "Medic 2" } }); _groupsService.Setup(g => g.GetAllGroupsForDepartmentAsync(Dept)).ReturnsAsync(new List { new DepartmentGroup { DepartmentGroupId = 10, Name = "Station 10" } }); @@ -189,7 +189,7 @@ public async Task Executive_summary_compares_with_the_prior_window_and_reads_lif _reports.Add(new RmsIncidentReport { RmsIncidentReportId = "i1", DepartmentId = Dept, State = (int)RmsRecordState.Accepted, CurrentRevisionId = "rev-i1", CallCreatedOn = T0, FinalizedOn = T0.AddDays(1), AcceptedOn = T0.AddDays(2), CreatedOn = T0 }); _reports.Add(new RmsIncidentReport { RmsIncidentReportId = "i2", DepartmentId = Dept, State = (int)RmsRecordState.Rejected, CurrentRevisionId = "rev-i2", CallCreatedOn = T0, FinalizedOn = T0.AddDays(1), RejectedOn = T0.AddDays(2), CreatedOn = T0 }); _revisions.Setup(r => r.GetTransitionsInRangeAsync(Dept, Start, End, It.IsAny())).ReturnsAsync(new List { new RmsRevisionTransitionRow { RecordId = "r1", Transition = (int)RmsRevisionTransition.Amended }, new RmsRevisionTransitionRow { RecordId = "r2", Transition = (int)RmsRevisionTransition.Voided } }); - _dueStates.Setup(d => d.GetChangedInRangeAsync(Dept, Start, End, It.IsAny())).ReturnsAsync(new List + _dueStates.Setup(d => d.GetLastEmittedInRangeAsync(Dept, Start, End, It.IsAny())).ReturnsAsync(new List { new RmsRecordDueState { RecordId = "r1", OverdueCount = 1, LastEmittedState = (int)RmsDueState.Overdue, LastEmittedOn = T0 }, new RmsRecordDueState { RecordId = "r2", OverdueCount = 0, LastEmittedState = (int)RmsDueState.DueSoon, LastEmittedOn = T0 }, diff --git a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs index 65407b12..da74400c 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs @@ -183,6 +183,24 @@ await _h.InvestigationsService.UpdateAsync(Dept, Admin, investigation.LeadInvestigatorUserId.Should().Be(Investigator); } + [Test] + public async Task The_lead_investigator_must_already_be_a_member_of_the_case() + { + var investigation = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, null, "Summary"); + var caseId = investigation.RmsInvestigationCaseId; + + // Member is in the department but not on the case, so leading it would leave them unable to open it. + Func assign = () => _h.InvestigationsService.UpdateAsync(Dept, Admin, + new RmsInvestigationCase { RmsInvestigationCaseId = caseId, Title = "Warehouse fire", LeadInvestigatorUserId = Member }); + await assign.Should().ThrowAsync(); + investigation.LeadInvestigatorUserId.Should().Be(Admin); + + await _h.InvestigationsService.AddMemberAsync(Dept, Admin, caseId, Member, RmsInvestigationRole.Investigator); + await _h.InvestigationsService.UpdateAsync(Dept, Admin, + new RmsInvestigationCase { RmsInvestigationCaseId = caseId, Title = "Warehouse fire", LeadInvestigatorUserId = Member }); + investigation.LeadInvestigatorUserId.Should().Be(Member); + } + [Test] public async Task Investigation_files_are_restricted_and_readable_only_by_members() { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs index 59230501..f1b9f9dd 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordAnalyticsController.cs @@ -30,8 +30,14 @@ public RecordAnalyticsController(IRecordsAnalyticsService analytics, IRecordsCut _analytics = analytics; } + // The targets are clamped to the same bounds the web filter form enforces, so an out-of-range value from an API + // caller cannot skew the within-target percentages the dashboards report. private static RecordsAnalyticsQuery Query(DateTime? start, DateTime? end, int? stationGroupId, string definitionKey, int turnoutTargetSeconds, int travelTargetSeconds) - => new RecordsAnalyticsQuery { Start = start, End = end, StationGroupId = stationGroupId, DefinitionKey = definitionKey, TurnoutTargetSeconds = turnoutTargetSeconds, TravelTargetSeconds = travelTargetSeconds }; + => new RecordsAnalyticsQuery + { + Start = start, End = end, StationGroupId = stationGroupId, DefinitionKey = definitionKey, + TurnoutTargetSeconds = Math.Clamp(turnoutTargetSeconds, 0, 3600), TravelTargetSeconds = Math.Clamp(travelTargetSeconds, 0, 7200) + }; /// Turnout, travel, total response, time on scene and first arrival; by unit, station group, hour of day and month. [HttpGet("ResponsePerformance")] diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml index 159ad063..145f8ada 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Index.cshtml @@ -86,7 +86,7 @@ @section Scripts { " is written as characters instead of closing the block it sits in. + var json = new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml }; }
    @title
    @if (rows.Count == 0) {

    @L["AnalyticsNoData"]

    } @@ -19,7 +22,7 @@ if (chart != "none") {
    - + } @if (showHours) { }@if (showPercent) { } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml index d5691f50..c0204071 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsShell.cshtml @@ -3,13 +3,15 @@ @model RecordsAnalyticsBaseView @inject IStringLocalizer L @{ + // Action, icon, caption, and which of the filters that action accepts, so switching tabs carries the filter + // state across instead of silently resetting it to the defaults. var tabs = new[] { - ("Index", "fa-tachometer", L["AnalyticsExecutive"].Value), - ("ResponsePerformance", "fa-clock-o", L["AnalyticsResponse"].Value), - ("Workload", "fa-tasks", L["AnalyticsWorkload"].Value), - ("Accreditation", "fa-certificate", L["AnalyticsAccreditation"].Value), - ("CommunityRisk", "fa-shield", L["AnalyticsCommunityRisk"].Value) + ("Index", "fa-tachometer", L["AnalyticsExecutive"].Value, true, false), + ("ResponsePerformance", "fa-clock-o", L["AnalyticsResponse"].Value, true, true), + ("Workload", "fa-tasks", L["AnalyticsWorkload"].Value, true, false), + ("Accreditation", "fa-certificate", L["AnalyticsAccreditation"].Value, false, true), + ("CommunityRisk", "fa-shield", L["AnalyticsCommunityRisk"].Value, false, false) }; var result = Model.Result; } @@ -37,7 +39,7 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml index d6b803ec..3798845f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml @@ -8,6 +8,9 @@ var chart = ViewData["Chart"] as string ?? "none"; var id = "t" + Guid.NewGuid().ToString("N"); var rows = (Model ?? new List()).Where(r => r.Responses > 0).ToList(); + // Labels are department-entered unit and group names, so the chart JSON is escaped for HTML: a label holding + // "" is written as characters instead of closing the block it sits in. + var json = new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml }; }
    @title
    @if (rows.Count == 0) {

    @L["AnalyticsNoData"]

    } @@ -16,7 +19,7 @@ if (chart != "none") {
    - + }
    @L["AnalyticsCount"]@L["AnalyticsHours"]@L["AnalyticsShare"]