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..88ea6bca
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RecordsAnalyticsContracts.cs
@@ -0,0 +1,407 @@
+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 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).
+ 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..f7465995 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,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 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.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..ee9cbb4f
--- /dev/null
+++ b/Core/Resgrid.Services/Records/RecordsAnalyticsService.cs
@@ -0,0 +1,738 @@
+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 { DepartmentId = c.DepartmentId };
+ 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 { DepartmentId = c.DepartmentId };
+ 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 { 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 () =>
+ {
+ 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.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.
+ 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
+ {
+ DepartmentId = c.DepartmentId,
+ 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
+ {
+ DepartmentId = c.DepartmentId,
+ 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..f6f540ea 100644
--- a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs
+++ b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs
@@ -144,13 +144,36 @@ 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.");
+
+ // 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++;
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