diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
index 955b38307..2ea7b9c61 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
@@ -281,4 +281,22 @@
تقرير الحادث
+
+ مرتبط تلقائيًا
+
+
+ مستنتج
+
+
+ أُرسلت دون بلاغ. ربطها Resgrid بهذا البلاغ استنادًا إلى الحالة السابقة أو الإرسال.
+
+
+ حُدِّدت دون وجهة من وحدة أو شخص أُرسل إلى هذا البلاغ أثناء العمل عليه.
+
+
+ مرتبط تلقائيًا: أُرسلت دون بلاغ وربطها Resgrid استنادًا إلى الحالة السابقة أو الإرسال. مستنتج: حُدِّدت دون وجهة من وحدة أو شخص أُرسل إلى هذا البلاغ أثناء العمل عليه.
+
+
+ أوقات الوحدات
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
index 6b790dd37..fe5c9fab8 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
@@ -848,4 +848,22 @@
Einsatzbericht
+
+ automatisch verknüpft
+
+
+ abgeleitet
+
+
+ Ohne Einsatz gesendet. Resgrid hat den Status anhand des vorherigen Status oder der Alarmierung mit diesem Einsatz verknüpft.
+
+
+ Ohne Ziel gesetzt von einer Einheit oder Person, die zu diesem Einsatz alarmiert war, während sie ihn abarbeitete.
+
+
+ Automatisch verknüpft: ohne Einsatz gesendet; Resgrid hat den Status anhand des vorherigen Status oder der Alarmierung verknüpft. Abgeleitet: ohne Ziel gesetzt von einer Einheit oder Person, die zu diesem Einsatz alarmiert war, während sie ihn abarbeitete.
+
+
+ Einheitenzeiten
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
index 80a223f32..749eb98c1 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
@@ -751,4 +751,22 @@
Αναφορά συμβάντος
+
+ αυτόματη σύνδεση
+
+
+ συναγόμενο
+
+
+ Στάλθηκε χωρίς κλήση. Το Resgrid τη συνέδεσε με αυτή την κλήση βάσει της προηγούμενης κατάστασης ή της αποστολής.
+
+
+ Ορίστηκε χωρίς προορισμό από μονάδα ή άτομο που είχε σταλεί σε αυτή την κλήση ενώ την εξυπηρετούσε.
+
+
+ Αυτόματη σύνδεση: στάλθηκε χωρίς κλήση· το Resgrid τη συνέδεσε βάσει της προηγούμενης κατάστασης ή της αποστολής. Συναγόμενο: ορίστηκε χωρίς προορισμό από μονάδα ή άτομο που είχε σταλεί σε αυτή την κλήση ενώ την εξυπηρετούσε.
+
+
+ Χρόνοι μονάδων
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
index f634ef8db..da4adad7c 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
@@ -772,4 +772,22 @@
Site Files
+
+ auto-linked
+
+
+ inferred
+
+
+ Sent without a call. Resgrid linked it to this call from the previous status or the dispatch.
+
+
+ Set with no destination by a unit or person dispatched to this call while working it.
+
+
+ Auto-linked: sent without a call; Resgrid linked it from the previous status or the dispatch. Inferred: set with no destination by a unit or person dispatched to this call while working it.
+
+
+ Unit Times
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
index cadc7671a..9c93c184e 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
@@ -897,4 +897,22 @@
Informe de incidente
+
+ vinculado automáticamente
+
+
+ deducido
+
+
+ Enviado sin llamada. Resgrid lo vinculó a esta llamada a partir del estado anterior o del despacho.
+
+
+ Establecido sin destino por una unidad o persona despachada a esta llamada mientras la atendía.
+
+
+ Vinculado automáticamente: enviado sin llamada; Resgrid lo vinculó a partir del estado anterior o del despacho. Deducido: establecido sin destino por una unidad o persona despachada a esta llamada mientras la atendía.
+
+
+ Tiempos de unidades
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
index fa72b9a94..eb8ae276e 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
@@ -848,4 +848,22 @@
Rapport d'incident
+
+ lié automatiquement
+
+
+ déduit
+
+
+ Envoyé sans appel. Resgrid l'a lié à cet appel d'après le statut précédent ou l'envoi.
+
+
+ Défini sans destination par une unité ou une personne envoyée sur cet appel pendant l'intervention.
+
+
+ Lié automatiquement : envoyé sans appel ; Resgrid l'a lié d'après le statut précédent ou l'envoi. Déduit : défini sans destination par une unité ou une personne envoyée sur cet appel pendant l'intervention.
+
+
+ Horaires des unités
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
index 218c03235..34b17adcd 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
@@ -848,4 +848,22 @@
Rapporto di incidente
+
+ collegato automaticamente
+
+
+ dedotto
+
+
+ Inviato senza chiamata. Resgrid lo ha collegato a questa chiamata in base allo stato precedente o all'invio.
+
+
+ Impostato senza destinazione da un'unità o persona inviata a questa chiamata mentre vi operava.
+
+
+ Collegato automaticamente: inviato senza chiamata; Resgrid lo ha collegato in base allo stato precedente o all'invio. Dedotto: impostato senza destinazione da un'unità o persona inviata a questa chiamata mentre vi operava.
+
+
+ Tempi delle unità
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
index b9bcb4044..caa890943 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
@@ -848,4 +848,22 @@
Raport ze zdarzenia
+
+ powiązane automatycznie
+
+
+ wywnioskowane
+
+
+ Wysłano bez zgłoszenia. Resgrid powiązał go z tym zgłoszeniem na podstawie poprzedniego statusu lub zadysponowania.
+
+
+ Ustawiony bez celu przez jednostkę lub osobę zadysponowaną do tego zgłoszenia podczas jego obsługi.
+
+
+ Powiązane automatycznie: wysłano bez zgłoszenia; Resgrid powiązał go na podstawie poprzedniego statusu lub zadysponowania. Wywnioskowane: ustawione bez celu przez jednostkę lub osobę zadysponowaną do tego zgłoszenia podczas jego obsługi.
+
+
+ Czasy jednostek
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
index 0da08d123..840dfe4e2 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
@@ -848,4 +848,22 @@
Insatsrapport
+
+ automatiskt kopplad
+
+
+ härledd
+
+
+ Skickad utan larm. Resgrid kopplade den till detta larm utifrån föregående status eller utlarmningen.
+
+
+ Satt utan destination av en enhet eller person som larmats till detta larm medan den arbetade med det.
+
+
+ Automatiskt kopplad: skickad utan larm; Resgrid kopplade den utifrån föregående status eller utlarmningen. Härledd: satt utan destination av en enhet eller person som larmats till detta larm medan den arbetade med det.
+
+
+ Enhetstider
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
index 82a680b64..2316ba969 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
@@ -848,4 +848,22 @@
Звіт про інцидент
+
+ пов’язано автоматично
+
+
+ виведено
+
+
+ Надіслано без виклику. Resgrid пов’язав його з цим викликом за попереднім статусом або направленням.
+
+
+ Встановлено без пункту призначення підрозділом або особою, направленими на цей виклик, під час роботи на ньому.
+
+
+ Пов’язано автоматично: надіслано без виклику; Resgrid пов’язав за попереднім статусом або направленням. Виведено: встановлено без пункту призначення підрозділом або особою, направленими на цей виклик, під час роботи на ньому.
+
+
+ Час підрозділів
+
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx
index 0f39d0a2b..f5d6a1e0f 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.ar.resx
@@ -365,4 +365,12 @@
فتح الانتشار
جدول الأسعار الافتراضي
فوترة المقاول: يُستخدم لعروض هذا العميل وعمليات انتشاره عندما لا يحدد أي عقد جدولًا.
+ وقت الموقع مرتبط تلقائيًا
+ أُرسلت حالة «في الموقع» للوحدة (أو الحالة التي أنهتها) دون بلاغ، وربطها Resgrid بهذا البلاغ استنادًا إلى الحالة السابقة للوحدة أو إرسالها. تحقّق من الوقت قبل الإرسال.
+ وقت الموقع مستنتج
+ حدّدت الوحدة حالة «في الموقع» (أو الحالة التي أنهتها) دون وجهة أثناء إرسالها إلى هذا البلاغ، واستنتج Resgrid أنها تخص هذا البلاغ. تحقّق من الوقت قبل الإرسال.
+ لا توجد حالة في الموقع: مدة البلاغ
+ لم تُبلغ الوحدة عن حالة «في الموقع» في هذا البلاغ، لذا يحتسب البند الوقت من تسجيل البلاغ حتى إغلاقه. تحقّق من الكمية قبل الإرسال.
+ وقت الموقع من حالة الوحدة
+ ربطت الوحدة بنفسها حالة «في الموقع» والحالة التي أنهتها بهذا البلاغ.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx
index 303495230..b8206832e 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.de.resx
@@ -365,4 +365,12 @@
Einsatz öffnen
Standard-Tarifplan
Auftragnehmer-Abrechnung: gilt für Angebote und Einsätze dieses Kunden, wenn kein Vertrag einen Plan nennt.
+ Einsatzortzeit automatisch verknüpft
+ Der Status „Am Einsatzort“ der Einheit (oder der Status, der ihn beendete) wurde ohne Einsatz gesendet; Resgrid hat ihn anhand des vorherigen Status oder der Alarmierung der Einheit mit diesem Einsatz verknüpft. Prüfen Sie die Zeit vor dem Versand.
+ Einsatzortzeit abgeleitet
+ Die Einheit hat ihren Status „Am Einsatzort“ (oder den Status, der ihn beendete) ohne Ziel gesetzt, während sie zu diesem Einsatz alarmiert war; Resgrid hat abgeleitet, dass er zu diesem Einsatz gehört. Prüfen Sie die Zeit vor dem Versand.
+ kein Einsatzortstatus: Einsatzdauer
+ Die Einheit hat bei diesem Einsatz nie „Am Einsatzort“ gemeldet, daher berechnet die Position die Zeit von der Erfassung des Einsatzes bis zu seinem Abschluss. Prüfen Sie die Menge vor dem Versand.
+ Einsatzortzeit aus Einheitenstatus
+ Die Einheit hat ihren Status „Am Einsatzort“ und den Status, der ihn beendete, selbst mit diesem Einsatz verknüpft.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx
index 7257ce1d1..dfe232d39 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.el.resx
@@ -365,4 +365,12 @@
Άνοιγμα ανάπτυξης
Προεπιλεγμένος τιμοκατάλογος
Χρέωση εργολάβου: χρησιμοποιείται για προσφορές και αναπτύξεις του πελάτη όταν καμία σύμβαση δεν ορίζει κατάλογο.
+ χρόνος στο σημείο με αυτόματη σύνδεση
+ Η κατάσταση «Στο σημείο» της μονάδας (ή η κατάσταση που την τερμάτισε) στάλθηκε χωρίς κλήση· το Resgrid τη συνέδεσε με αυτή την κλήση βάσει της προηγούμενης κατάστασης της μονάδας ή της αποστολής της. Ελέγξτε τον χρόνο πριν από την αποστολή.
+ χρόνος στο σημείο συναγόμενος
+ Η μονάδα όρισε την κατάσταση «Στο σημείο» (ή την κατάσταση που την τερμάτισε) χωρίς προορισμό ενώ είχε σταλεί σε αυτή την κλήση· το Resgrid συνήγαγε ότι ανήκει σε αυτή την κλήση. Ελέγξτε τον χρόνο πριν από την αποστολή.
+ χωρίς κατάσταση στο σημείο: διάρκεια κλήσης
+ Η μονάδα δεν ανέφερε ποτέ «Στο σημείο» σε αυτή την κλήση, οπότε η γραμμή χρεώνει τον χρόνο από την καταγραφή της κλήσης έως το κλείσιμό της. Ελέγξτε την ποσότητα πριν από την αποστολή.
+ χρόνος στο σημείο από την κατάσταση μονάδας
+ Η μονάδα συνέδεσε η ίδια την κατάσταση «Στο σημείο» και την κατάσταση που την τερμάτισε με αυτή την κλήση.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx
index 8792b0c66..3f14c1b3d 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.en.resx
@@ -365,4 +365,12 @@
Open deployment
Default rate schedule
Contractor billing: used for this customer's bids and deployments when no contract names a schedule.
+ on-scene time auto-linked
+ The unit's On Scene status (or the status that ended it) was sent without a call; Resgrid linked it to this call from the unit's previous status or its dispatch. Check the time before sending.
+ on-scene time inferred
+ The unit set its On Scene status (or the status that ended it) with no destination while dispatched to this call; Resgrid inferred it belongs to this call. Check the time before sending.
+ no on-scene status: call duration
+ The unit never reported On Scene on this call, so the line bills the time from the call being logged to its close. Check the quantity before sending.
+ on-scene time from unit status
+ The unit linked its On Scene status, and the status that ended it, to this call itself.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx
index fc7908613..abff6b4c6 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.es.resx
@@ -365,4 +365,12 @@
Abrir despliegue
Tabla de tarifas predeterminada
Facturación de contratista: se usa en las ofertas y despliegues de este cliente cuando ningún contrato indica una tabla.
+ tiempo en escena vinculado automáticamente
+ El estado En escena de la unidad (o el estado que lo terminó) se envió sin llamada; Resgrid lo vinculó a esta llamada a partir del estado anterior de la unidad o de su despacho. Compruebe el tiempo antes de enviar.
+ tiempo en escena deducido
+ La unidad estableció su estado En escena (o el estado que lo terminó) sin destino mientras estaba despachada a esta llamada; Resgrid dedujo que pertenece a esta llamada. Compruebe el tiempo antes de enviar.
+ sin estado en escena: duración de la llamada
+ La unidad nunca informó En escena en esta llamada, por lo que la línea factura el tiempo desde el registro de la llamada hasta su cierre. Compruebe la cantidad antes de enviar.
+ tiempo en escena del estado de la unidad
+ La unidad vinculó ella misma su estado En escena, y el estado que lo terminó, a esta llamada.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx
index 874f19df0..871954e2e 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.fr.resx
@@ -365,4 +365,12 @@
Ouvrir le déploiement
Grille tarifaire par défaut
Facturation contractant : utilisée pour les offres et déploiements de ce client quand aucun contrat ne désigne de grille.
+ temps sur les lieux lié automatiquement
+ Le statut Sur les lieux de l'unité (ou le statut qui l'a terminé) a été envoyé sans appel ; Resgrid l'a lié à cet appel d'après le statut précédent de l'unité ou son envoi. Vérifiez le temps avant l'envoi.
+ temps sur les lieux déduit
+ L'unité a défini son statut Sur les lieux (ou le statut qui l'a terminé) sans destination alors qu'elle était envoyée sur cet appel ; Resgrid en a déduit qu'il appartient à cet appel. Vérifiez le temps avant l'envoi.
+ pas de statut sur les lieux : durée de l'appel
+ L'unité n'a jamais signalé Sur les lieux pour cet appel ; la ligne facture donc le temps entre l'enregistrement de l'appel et sa clôture. Vérifiez la quantité avant l'envoi.
+ temps sur les lieux d'après le statut de l'unité
+ L'unité a elle-même lié son statut Sur les lieux, et le statut qui l'a terminé, à cet appel.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx
index cc847fa10..49944e431 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.it.resx
@@ -365,4 +365,12 @@
Apri intervento
Listino predefinito
Fatturazione appaltatore: usato per offerte e interventi di questo cliente quando nessun contratto indica un listino.
+ tempo sul posto collegato automaticamente
+ Lo stato Sul posto dell'unità (o lo stato che lo ha concluso) è stato inviato senza chiamata; Resgrid lo ha collegato a questa chiamata in base allo stato precedente dell'unità o al suo invio. Verificare il tempo prima dell'invio.
+ tempo sul posto dedotto
+ L'unità ha impostato lo stato Sul posto (o lo stato che lo ha concluso) senza destinazione mentre era inviata a questa chiamata; Resgrid ha dedotto che appartiene a questa chiamata. Verificare il tempo prima dell'invio.
+ nessuno stato sul posto: durata della chiamata
+ L'unità non ha mai segnalato Sul posto in questa chiamata, quindi la riga fattura il tempo dalla registrazione della chiamata alla sua chiusura. Verificare la quantità prima dell'invio.
+ tempo sul posto dallo stato dell'unità
+ L'unità ha collegato essa stessa lo stato Sul posto, e lo stato che lo ha concluso, a questa chiamata.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx
index 25375ef7d..d24909640 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.pl.resx
@@ -365,4 +365,12 @@
Otwórz wyjazd
Domyślny cennik
Rozliczenia wykonawcy: używany dla ofert i wyjazdów tego klienta, gdy żadna umowa nie wskazuje cennika.
+ czas na miejscu powiązany automatycznie
+ Status „Na miejscu” jednostki (lub status, który go zakończył) wysłano bez zgłoszenia; Resgrid powiązał go z tym zgłoszeniem na podstawie poprzedniego statusu jednostki lub jej zadysponowania. Sprawdź czas przed wysłaniem.
+ czas na miejscu wywnioskowany
+ Jednostka ustawiła status „Na miejscu” (lub status, który go zakończył) bez celu, będąc zadysponowaną do tego zgłoszenia; Resgrid wywnioskował, że należy on do tego zgłoszenia. Sprawdź czas przed wysłaniem.
+ brak statusu na miejscu: czas trwania zgłoszenia
+ Jednostka nigdy nie zgłosiła statusu „Na miejscu” w tym zgłoszeniu, więc pozycja nalicza czas od zarejestrowania zgłoszenia do jego zamknięcia. Sprawdź ilość przed wysłaniem.
+ czas na miejscu ze statusu jednostki
+ Jednostka sama powiązała swój status „Na miejscu” oraz status, który go zakończył, z tym zgłoszeniem.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx
index 8792b0c66..1da1af195 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.resx
@@ -365,4 +365,12 @@
Open deployment
Default rate schedule
Contractor billing: used for this customer's bids and deployments when no contract names a schedule.
+ on-scene time from unit status
+ The unit linked its On Scene status, and the status that ended it, to this call itself.
+ on-scene time auto-linked
+ The unit's On Scene status (or the status that ended it) was sent without a call; Resgrid linked it to this call from the unit's previous status or its dispatch. Check the time before sending.
+ on-scene time inferred
+ The unit set its On Scene status (or the status that ended it) with no destination while dispatched to this call; Resgrid inferred it belongs to this call. Check the time before sending.
+ no on-scene status: call duration
+ The unit never reported On Scene on this call, so the line bills the time from the call being logged to its close. Check the quantity before sending.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx
index 31e7e5fa1..ccc8ab6a8 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.sv.resx
@@ -365,4 +365,12 @@
Öppna insats
Standardprislista
Entreprenörsfakturering: används för kundens anbud och insatser när inget avtal anger prislista.
+ tid på plats automatiskt kopplad
+ Enhetens status På plats (eller statusen som avslutade den) skickades utan larm; Resgrid kopplade den till detta larm utifrån enhetens föregående status eller dess utlarmning. Kontrollera tiden innan du skickar.
+ tid på plats härledd
+ Enheten satte sin status På plats (eller statusen som avslutade den) utan destination medan den var larmad till detta larm; Resgrid härledde att den hör till detta larm. Kontrollera tiden innan du skickar.
+ ingen status på plats: larmets längd
+ Enheten rapporterade aldrig På plats på detta larm, så raden fakturerar tiden från att larmet registrerades till att det stängdes. Kontrollera antalet innan du skickar.
+ tid på plats från enhetsstatus
+ Enheten kopplade själv sin status På plats, och statusen som avslutade den, till detta larm.
diff --git a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx
index ad4351edc..c0a87bd6c 100644
--- a/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Invoicing/Invoicing.uk.resx
@@ -365,4 +365,12 @@
Відкрити розгортання
Типовий тарифний план
Розрахунки підрядника: використовується для пропозицій і розгортань цього замовника, коли договір не вказує план.
+ час на місці пов’язано автоматично
+ Статус підрозділу «На місці» (або статус, що його завершив) надіслано без виклику; Resgrid пов’язав його з цим викликом за попереднім статусом підрозділу або його направленням. Перевірте час перед надсиланням.
+ час на місці виведено
+ Підрозділ встановив статус «На місці» (або статус, що його завершив) без пункту призначення, будучи направленим на цей виклик; Resgrid вивів, що він належить до цього виклику. Перевірте час перед надсиланням.
+ немає статусу «на місці»: тривалість виклику
+ Підрозділ жодного разу не повідомив «На місці» на цьому виклику, тому рядок нараховує час від реєстрації виклику до його закриття. Перевірте кількість перед надсиланням.
+ час на місці зі статусу підрозділу
+ Підрозділ сам пов’язав свій статус «На місці» та статус, що його завершив, із цим викликом.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
index aa82259f4..f66a130cd 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
@@ -2156,4 +2156,7 @@
فعّل JavaScript للبحث عن بلاغ واختياره.
أدخل نطاق تواريخ صالحًا بحيث لا تكون البداية بعد النهاية.
اختر بلاغًا متاحًا من القائمة أو امسح الاختيار.
+ مستنتج
+ مرتبط تلقائيًا
+ بعض أوقات الوحدات مأخوذة من حالات لم تربطها الوحدة بهذا البلاغ بنفسها: ربطها Resgrid تلقائيًا (من الحالة السابقة للوحدة أو إرسالها) أو استنتجها (حُدّدت دون وجهة أثناء إرسال الوحدة إلى هنا). تحقّق منها قبل الإنهاء.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
index 37b5ba37d..51a84fc27 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
@@ -2156,4 +2156,7 @@
Aktivieren Sie JavaScript, um einen Einsatz zu suchen und auszuwählen.
Geben Sie einen gültigen Zeitraum ein. Der Beginn darf nicht nach dem Ende liegen.
Wählen Sie einen verfügbaren Einsatz aus der Liste oder löschen Sie die Auswahl.
+ Erschlossen
+ Automatisch verknüpft
+ Einige Einheitenzeiten stammen aus Status, die die Einheit nicht selbst mit diesem Einsatz verknüpft hat: Resgrid hat sie automatisch verknüpft (aus dem vorherigen Status oder der Alarmierung der Einheit) oder abgeleitet (ohne Ziel gesetzt, während die Einheit hierher alarmiert war). Prüfen Sie sie vor dem Abschluss.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
index 7123488b7..c75c2b825 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
@@ -2156,4 +2156,7 @@
Ενεργοποιήστε τη JavaScript για αναζήτηση και επιλογή κλήσης.
Εισαγάγετε έγκυρο εύρος ημερομηνιών με την αρχή πριν ή κατά τη λήξη.
Επιλέξτε διαθέσιμη κλήση από τη λίστα ή διαγράψτε την επιλογή.
+ Συναγόμενο
+ Αυτόματη σύνδεση
+ Ορισμένοι χρόνοι μονάδων προέρχονται από καταστάσεις που η μονάδα δεν συνέδεσε η ίδια με αυτή την κλήση: το Resgrid τις συνέδεσε αυτόματα (από την προηγούμενη κατάσταση ή την αποστολή της μονάδας) ή τις συνήγαγε (ορίστηκαν χωρίς προορισμό ενώ η μονάδα είχε σταλεί εδώ). Ελέγξτε τις πριν από την οριστικοποίηση.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
index 40da1c5d2..7a78da112 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
@@ -2156,4 +2156,7 @@
Enable JavaScript to search and select a call.
Enter a valid date range with the start on or before the end.
Choose an available call from the list, or clear the selection.
+ Inferred
+ Auto-linked
+ Some unit times come from statuses the unit did not link to this call itself: Resgrid auto-linked them (from the unit's previous status or its dispatch) or inferred them (set with no destination while the unit was dispatched here). Check them before finalizing.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
index a582bcbfc..3bc380f90 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
@@ -2156,4 +2156,7 @@
Active JavaScript para buscar y seleccionar una llamada.
Introduzca un intervalo válido; el inicio debe ser anterior o igual al final.
Seleccione una llamada disponible de la lista o borre la selección.
+ Deducido
+ Vinculado automáticamente
+ Algunos tiempos de unidad provienen de estados que la unidad no vinculó a esta llamada por sí misma: Resgrid los vinculó automáticamente (a partir del estado anterior de la unidad o de su despacho) o los dedujo (establecidos sin destino mientras la unidad estaba despachada aquí). Compruébelos antes de finalizar.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
index f985487ea..39cad2845 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
@@ -2156,4 +2156,7 @@
Activez JavaScript pour rechercher et sélectionner une intervention.
Saisissez une période valide dont le début précède ou égale la fin.
Sélectionnez une intervention disponible dans la liste ou effacez la sélection.
+ Déduit
+ Lié automatiquement
+ Certains horaires d'unité proviennent de statuts que l'unité n'a pas liés elle-même à cet appel : Resgrid les a liés automatiquement (d'après le statut précédent de l'unité ou son envoi) ou déduits (définis sans destination alors que l'unité était envoyée ici). Vérifiez-les avant de finaliser.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
index 2cda4304b..451bbb6ea 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
@@ -2156,4 +2156,7 @@
Abilita JavaScript per cercare e selezionare un intervento.
Inserisci un intervallo valido con inizio precedente o uguale alla fine.
Seleziona un intervento disponibile dall’elenco o cancella la selezione.
+ Dedotto
+ Collegato automaticamente
+ Alcuni tempi delle unità provengono da stati che l'unità non ha collegato da sé a questa chiamata: Resgrid li ha collegati automaticamente (dallo stato precedente dell'unità o dal suo invio) o dedotti (impostati senza destinazione mentre l'unità era inviata qui). Verificarli prima di finalizzare.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
index 34cc38abf..801127df7 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
@@ -2156,4 +2156,7 @@
Włącz JavaScript, aby wyszukać i wybrać zgłoszenie.
Podaj prawidłowy zakres dat, w którym początek nie jest późniejszy niż koniec.
Wybierz dostępne zgłoszenie z listy lub wyczyść wybór.
+ Wywnioskowane
+ Powiązane automatycznie
+ Niektóre czasy jednostek pochodzą ze statusów, których jednostka sama nie powiązała z tym zgłoszeniem: Resgrid powiązał je automatycznie (na podstawie poprzedniego statusu jednostki lub jej zadysponowania) albo je wywnioskował (ustawione bez celu, gdy jednostka była tu zadysponowana). Sprawdź je przed zatwierdzeniem.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
index fb6348e11..a070ea3a7 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
@@ -2156,4 +2156,7 @@
Aktivera JavaScript för att söka och välja ett larm.
Ange ett giltigt datumintervall där början inte är efter slutet.
Välj ett tillgängligt larm i listan eller rensa valet.
+ Härledd
+ Automatiskt kopplad
+ Vissa enhetstider kommer från statusar som enheten inte själv kopplade till detta larm: Resgrid kopplade dem automatiskt (utifrån enhetens föregående status eller dess utlarmning) eller härledde dem (satta utan destination medan enheten var larmad hit). Kontrollera dem innan du slutför.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
index ba7e39766..bf37a95ac 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
@@ -2156,4 +2156,7 @@
Увімкніть JavaScript для пошуку та вибору виклику.
Укажіть правильний діапазон дат: початок має бути не пізніше за кінець.
Виберіть доступний виклик зі списку або очистьте вибір.
+ Виведено
+ Пов’язано автоматично
+ Деякі часи підрозділів узято зі статусів, які підрозділ сам не пов’язав із цим викликом: Resgrid пов’язав їх автоматично (за попереднім статусом підрозділу або його направленням) або вивів (встановлені без пункту призначення, коли підрозділ був направлений сюди). Перевірте їх перед завершенням.
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx
index ca04e7e24..55f49dc06 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx
@@ -309,4 +309,55 @@
الوقت الإجمالي
+
+ أوقات الوحدات لكل بلاغ
+
+
+ متى أُرسلت كل وحدة، وكانت في الطريق، وفي الموقع، وفي منطقة الانتظار، وأصبحت متاحة في كل بلاغ خلال فترة، من حالات الوحدات. قابل للتنزيل بصيغة CSV.
+
+
+ الوحدة
+
+
+ أُرسلت
+
+
+ في الطريق
+
+
+ في الموقع
+
+
+ الانتظار
+
+
+ أصبحت متاحة
+
+
+ الربط بواسطة
+
+
+ حالة الوحدة
+
+
+ مرتبط تلقائيًا
+
+
+ مستنتج
+
+
+ الإرسال فقط
+
+
+ تنزيل CSV
+
+
+ لا توجد بلاغات في هذه الفترة.
+
+
+ لم تُرسل أي وحدة إلى هذا البلاغ ولم تُبلغ أي وحدة عن حالة فيه.
+
+
+ مرتبط تلقائيًا: حالة أُرسلت دون بلاغ وربطها Resgrid استنادًا إلى الحالة السابقة للوحدة أو إرسالها. مستنتج: حالة حدّدتها الوحدة دون وجهة أثناء إرسالها إلى البلاغ. الإرسال فقط: أُرسلت الوحدة لكنها لم تُبلغ عن أي حالة في البلاغ.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx
index 3fbef0899..6e2494c0e 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx
@@ -309,4 +309,55 @@
Gesamtzeit
+
+ Einheitenzeiten je Einsatz
+
+
+ Wann jede Einheit bei jedem Einsatz eines Zeitraums alarmiert, auf Anfahrt, am Einsatzort, in Bereitstellung und wieder frei war, aus den Einheitenstatus. Als CSV herunterladbar.
+
+
+ Einheit
+
+
+ Alarmiert
+
+
+ Auf Anfahrt
+
+
+ Am Einsatzort
+
+
+ Bereitstellung
+
+
+ Frei
+
+
+ Verknüpft durch
+
+
+ Einheitenstatus
+
+
+ Automatisch verknüpft
+
+
+ Abgeleitet
+
+
+ Nur Alarmierung
+
+
+ CSV herunterladen
+
+
+ Keine Einsätze in diesem Zeitraum.
+
+
+ Keine Einheit wurde zu diesem Einsatz alarmiert oder hat einen Status dazu gemeldet.
+
+
+ Automatisch verknüpft: ein ohne Einsatz gesendeter Status, den Resgrid anhand des vorherigen Status der Einheit oder ihrer Alarmierung verknüpft hat. Abgeleitet: ein Status, den die Einheit ohne Ziel gesetzt hat, während sie zum Einsatz alarmiert war. Nur Alarmierung: die Einheit wurde alarmiert, hat aber keinen Status zum Einsatz gemeldet.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx
index 864d49b1a..b0fff4454 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx
@@ -740,4 +740,55 @@
Συνολικός Χρόνος
+
+ Χρόνοι μονάδων ανά κλήση
+
+
+ Πότε κάθε μονάδα στάλθηκε, ήταν καθ' οδόν, στο σημείο, σε αναμονή και ελευθερώθηκε σε κάθε κλήση μιας περιόδου, από τις καταστάσεις των μονάδων. Διαθέσιμο για λήψη ως CSV.
+
+
+ Μονάδα
+
+
+ Στάλθηκε
+
+
+ Καθ' οδόν
+
+
+ Στο σημείο
+
+
+ Σε αναμονή
+
+
+ Ελευθερώθηκε
+
+
+ Σύνδεση μέσω
+
+
+ Κατάσταση μονάδας
+
+
+ Αυτόματη σύνδεση
+
+
+ Συναγόμενο
+
+
+ Μόνο αποστολή
+
+
+ Λήψη CSV
+
+
+ Δεν υπάρχουν κλήσεις σε αυτή την περίοδο.
+
+
+ Καμία μονάδα δεν στάλθηκε σε αυτή την κλήση ούτε ανέφερε κατάσταση σε αυτή.
+
+
+ Αυτόματη σύνδεση: κατάσταση που στάλθηκε χωρίς κλήση και το Resgrid τη συνέδεσε βάσει της προηγούμενης κατάστασης της μονάδας ή της αποστολής της. Συναγόμενο: κατάσταση που όρισε η μονάδα χωρίς προορισμό ενώ είχε σταλεί στην κλήση. Μόνο αποστολή: η μονάδα στάλθηκε αλλά δεν ανέφερε καμία κατάσταση στην κλήση.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx
index 905083d8b..be0e04117 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx
@@ -740,4 +740,55 @@
Total Time
+
+ Call Unit Times
+
+
+ When each unit was dispatched, en route, on scene, staged and cleared on each call in a period, from unit statuses. Downloadable as CSV.
+
+
+ Unit
+
+
+ Dispatched
+
+
+ En Route
+
+
+ On Scene
+
+
+ Staging
+
+
+ Cleared
+
+
+ Linked By
+
+
+ Unit status
+
+
+ Auto-linked
+
+
+ Inferred
+
+
+ Dispatch only
+
+
+ Download CSV
+
+
+ No calls in this period.
+
+
+ No unit was dispatched to this call or reported a status on it.
+
+
+ Auto-linked: a status sent without a call that Resgrid linked from the unit's previous status or its dispatch. Inferred: a status the unit set with no destination while dispatched to the call. Dispatch only: the unit was dispatched but reported no status on the call.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx
index 08fef4717..9e91a452b 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx
@@ -309,4 +309,55 @@
Tiempo Total
+
+ Tiempos de unidades por llamada
+
+
+ Cuándo cada unidad fue despachada, estuvo en camino, en escena, en espera y quedó libre en cada llamada de un período, a partir de los estados de las unidades. Descargable como CSV.
+
+
+ Unidad
+
+
+ Despachada
+
+
+ En camino
+
+
+ En escena
+
+
+ En espera
+
+
+ Libre
+
+
+ Vinculado por
+
+
+ Estado de la unidad
+
+
+ Vinculado automáticamente
+
+
+ Deducido
+
+
+ Solo despacho
+
+
+ Descargar CSV
+
+
+ No hay llamadas en este período.
+
+
+ Ninguna unidad fue despachada a esta llamada ni informó un estado en ella.
+
+
+ Vinculado automáticamente: un estado enviado sin llamada que Resgrid vinculó a partir del estado anterior de la unidad o de su despacho. Deducido: un estado que la unidad estableció sin destino mientras estaba despachada a la llamada. Solo despacho: la unidad fue despachada pero no informó ningún estado en la llamada.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx
index 6c91627e4..517fe75af 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx
@@ -309,4 +309,55 @@
Temps total
+
+ Horaires des unités par appel
+
+
+ Quand chaque unité a été envoyée, en route, sur les lieux, en attente et libérée pour chaque appel d'une période, d'après les statuts des unités. Téléchargeable en CSV.
+
+
+ Unité
+
+
+ Envoyée
+
+
+ En route
+
+
+ Sur les lieux
+
+
+ En attente
+
+
+ Libérée
+
+
+ Lié par
+
+
+ Statut de l'unité
+
+
+ Lié automatiquement
+
+
+ Déduit
+
+
+ Envoi seulement
+
+
+ Télécharger CSV
+
+
+ Aucun appel sur cette période.
+
+
+ Aucune unité n'a été envoyée sur cet appel ni n'y a signalé de statut.
+
+
+ Lié automatiquement : un statut envoyé sans appel que Resgrid a lié d'après le statut précédent de l'unité ou son envoi. Déduit : un statut défini sans destination par l'unité alors qu'elle était envoyée sur l'appel. Envoi seulement : l'unité a été envoyée mais n'a signalé aucun statut sur l'appel.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx
index 45f79b02a..259c05470 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx
@@ -309,4 +309,55 @@
Tempo Totale
+
+ Tempi delle unità per chiamata
+
+
+ Quando ogni unità è stata inviata, in viaggio, sul posto, in attesa e libera per ogni chiamata di un periodo, dagli stati delle unità. Scaricabile in CSV.
+
+
+ Unità
+
+
+ Inviata
+
+
+ In viaggio
+
+
+ Sul posto
+
+
+ In attesa
+
+
+ Libera
+
+
+ Collegato da
+
+
+ Stato dell'unità
+
+
+ Collegato automaticamente
+
+
+ Dedotto
+
+
+ Solo invio
+
+
+ Scarica CSV
+
+
+ Nessuna chiamata in questo periodo.
+
+
+ Nessuna unità è stata inviata a questa chiamata né vi ha segnalato uno stato.
+
+
+ Collegato automaticamente: uno stato inviato senza chiamata che Resgrid ha collegato in base allo stato precedente dell'unità o al suo invio. Dedotto: uno stato impostato dall'unità senza destinazione mentre era inviata alla chiamata. Solo invio: l'unità è stata inviata ma non ha segnalato alcuno stato sulla chiamata.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx
index 9ea018497..f832d7fe9 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx
@@ -309,4 +309,55 @@
Łączny czas
+
+ Czasy jednostek w zgłoszeniach
+
+
+ Kiedy każda jednostka została zadysponowana, była w drodze, na miejscu, w oczekiwaniu i zwolniona w każdym zgłoszeniu z okresu, na podstawie statusów jednostek. Do pobrania jako CSV.
+
+
+ Jednostka
+
+
+ Zadysponowana
+
+
+ W drodze
+
+
+ Na miejscu
+
+
+ Oczekiwanie
+
+
+ Zwolniona
+
+
+ Powiązane przez
+
+
+ Status jednostki
+
+
+ Powiązane automatycznie
+
+
+ Wywnioskowane
+
+
+ Tylko zadysponowanie
+
+
+ Pobierz CSV
+
+
+ Brak zgłoszeń w tym okresie.
+
+
+ Żadna jednostka nie została zadysponowana do tego zgłoszenia ani nie zgłosiła w nim statusu.
+
+
+ Powiązane automatycznie: status wysłany bez zgłoszenia, który Resgrid powiązał na podstawie poprzedniego statusu jednostki lub jej zadysponowania. Wywnioskowane: status ustawiony przez jednostkę bez celu, gdy była zadysponowana do zgłoszenia. Tylko zadysponowanie: jednostka została zadysponowana, ale nie zgłosiła żadnego statusu w zgłoszeniu.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx
index bd0b657b4..13fa926f0 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx
@@ -309,4 +309,55 @@
Total tid
+
+ Enhetstider per larm
+
+
+ När varje enhet larmades, var på väg, på plats, i uppställning och klar vid varje larm under en period, utifrån enhetsstatus. Kan laddas ner som CSV.
+
+
+ Enhet
+
+
+ Larmad
+
+
+ På väg
+
+
+ På plats
+
+
+ Uppställning
+
+
+ Klar
+
+
+ Kopplad via
+
+
+ Enhetsstatus
+
+
+ Automatiskt kopplad
+
+
+ Härledd
+
+
+ Endast larm
+
+
+ Ladda ner CSV
+
+
+ Inga larm under denna period.
+
+
+ Ingen enhet larmades till detta larm eller rapporterade status på det.
+
+
+ Automatiskt kopplad: en status skickad utan larm som Resgrid kopplade utifrån enhetens föregående status eller dess utlarmning. Härledd: en status som enheten satte utan destination medan den var larmad till larmet. Endast larm: enheten larmades men rapporterade ingen status på larmet.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx
index 41555cedb..0c36d1ca0 100644
--- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx
@@ -309,4 +309,55 @@
Загальний час
+
+ Час підрозділів за викликами
+
+
+ Коли кожен підрозділ було направлено, коли він був у дорозі, на місці, в резерві та звільнився на кожному виклику за період, за статусами підрозділів. Можна завантажити як CSV.
+
+
+ Підрозділ
+
+
+ Направлено
+
+
+ У дорозі
+
+
+ На місці
+
+
+ Резерв
+
+
+ Звільнено
+
+
+ Пов’язано через
+
+
+ Статус підрозділу
+
+
+ Пов’язано автоматично
+
+
+ Виведено
+
+
+ Лише направлення
+
+
+ Завантажити CSV
+
+
+ Немає викликів за цей період.
+
+
+ Жоден підрозділ не було направлено на цей виклик, і жоден не повідомив на ньому статус.
+
+
+ Пов’язано автоматично: статус, надісланий без виклику, який Resgrid пов’язав за попереднім статусом підрозділу або його направленням. Виведено: статус, який підрозділ встановив без пункту призначення, будучи направленим на виклик. Лише направлення: підрозділ було направлено, але він не повідомив жодного статусу на виклику.
+
diff --git a/Core/Resgrid.Model/ActionLog.cs b/Core/Resgrid.Model/ActionLog.cs
index 1dd998cd3..e19150719 100644
--- a/Core/Resgrid.Model/ActionLog.cs
+++ b/Core/Resgrid.Model/ActionLog.cs
@@ -57,6 +57,10 @@ public class ActionLog : IEntity
[ProtoMember(13)]
public string UnitName { get; set; }
+ /// How the destination was decided (); null on rows before M0228.
+ [ProtoMember(14)]
+ public int? DestinationSource { get; set; }
+
[NotMapped]
[ProtoMember(10)]
public double Eta { get; set; }
diff --git a/Core/Resgrid.Model/CallDispatchWindow.cs b/Core/Resgrid.Model/CallDispatchWindow.cs
new file mode 100644
index 000000000..ce191f87b
--- /dev/null
+++ b/Core/Resgrid.Model/CallDispatchWindow.cs
@@ -0,0 +1,33 @@
+using System;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A unit's or person's dispatch to a call together with the call's open span, read to tell when a status falls while the
+ /// unit or person was dispatched to more than one open call (see ). A read model, not an
+ /// entity: is set for unit dispatches and for personnel dispatches (direct, or
+ /// through a paged group or role).
+ ///
+ public class CallDispatchWindow
+ {
+ public int CallId { get; set; }
+
+ public int UnitId { get; set; }
+
+ public string UserId { get; set; }
+
+ public DateTime DispatchedOn { get; set; }
+
+ public DateTime LoggedOn { get; set; }
+
+ public DateTime? ClosedOn { get; set; }
+
+ /// True when the person was reached through a paged group or role rather than dispatched directly.
+ public bool Paged { get; set; }
+ }
+
+ ///
+ /// A unit's or person's time on one call as that call's read-time walk sees it ().
+ ///
+ public readonly record struct CallDispatchSpan(int CallId, DateTime Start, DateTime End, bool RequireEngagement);
+}
diff --git a/Core/Resgrid.Model/CallStatusAttribution.cs b/Core/Resgrid.Model/CallStatusAttribution.cs
new file mode 100644
index 000000000..f24903295
--- /dev/null
+++ b/Core/Resgrid.Model/CallStatusAttribution.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Rules that tie statuses to a call when the client didn't say which call they were for.
+ ///
+ /// On write (the status is saved with the result, see ):
+ /// 1. a destination the client sent is kept as is;
+ /// 2. otherwise the call of the unit's/person's previous status is kept when that call is still open and the
+ /// previous status wasn't a clearing one (the new status may itself be clearing: that records the clear time);
+ /// 3. otherwise, for a non-clearing status, the one open call the unit/person is dispatched to (other than a call
+ /// their previous, clearing status already cleared); more than one candidate leaves the status unlinked.
+ /// Rules 2 and 3 read the unit's/person's state now, so they only run for a status set now ();
+ /// an offline status replayed later is left for the read-time walk, which places it by its own time.
+ ///
+ /// On read, a call's record also shows the statuses without any destination that a unit or person dispatched to the
+ /// call set while working it (, never stored). The walk starts at the
+ /// dispatch, stops at the first status that points anywhere else, stops after the first clearing status, and ends at
+ /// the call's close. Personnel paged as a group or role only count from their first engaging status, so a station's
+ /// members who never turned out don't appear. When the unit/person was also dispatched to another call around the same
+ /// time, a status that other call's walk would take as well could be for either call: like rule 3 it is only kept when
+ /// it follows a status already on this call's record (rule 2); otherwise it is skipped, and such a clearing status ends
+ /// the walk. Dispatches up to apart are checked this way, so a status is not inferred onto
+ /// both calls.
+ ///
+ public static class CallStatusAttribution
+ {
+ private static readonly MethodInfo MemberwiseCloneMethod =
+ typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ ///
+ /// How old a status may be and still count as set now for the write-time rules; anything older is an offline status
+ /// replayed after the fact.
+ ///
+ public static readonly TimeSpan MaxLiveStatusAge = TimeSpan.FromMinutes(5);
+
+ ///
+ /// How long before a unit's/person's dispatch to a call their dispatch to another call is still checked for overlap. An
+ /// older dispatch whose call was never closed is treated as long cleared.
+ ///
+ public static readonly TimeSpan OverlapLookback = TimeSpan.FromHours(24);
+
+ ///
+ /// How long before such an overlapping dispatch its call may have been logged (the calls are read by logged time).
+ ///
+ public static readonly TimeSpan MaxOverlapCallAge = TimeSpan.FromDays(30);
+
+ ///
+ /// True when a status was set now rather than replayed from an offline queue: it is no older than
+ /// . Device clocks may run ahead, so a time in the future still counts as now.
+ ///
+ public static bool IsLiveStatus(DateTime timestampUtc, DateTime nowUtc)
+ {
+ return timestampUtc >= nowUtc.Subtract(MaxLiveStatusAge);
+ }
+
+ ///
+ /// One unit's/person's dispatches, one span per call, as that call's own walk sees them: from the earliest dispatch (the
+ /// call's logging when the dispatch has no time) to the call's close, or now while it is open; engagement is required
+ /// only when every dispatch to the call was a group or role page.
+ ///
+ public static List DispatchSpans(IEnumerable windows, DateTime nowUtc)
+ {
+ return (windows ?? Enumerable.Empty())
+ .Where(x => x != null)
+ .GroupBy(x => x.CallId)
+ .Select(g => new CallDispatchSpan(g.Key,
+ g.Min(x => x.DispatchedOn == default(DateTime) ? x.LoggedOn : x.DispatchedOn),
+ g.First().ClosedOn ?? nowUtc,
+ g.All(x => x.Paged)))
+ .ToList();
+ }
+
+ ///
+ /// Rule 2: the previous status's call is kept when it is linked, still open and the previous status wasn't clearing.
+ ///
+ public static bool CanCarryForward(int? previousCallId, bool previousIsClearing, bool previousCallOpen)
+ {
+ return previousCallId.HasValue && previousCallOpen && !previousIsClearing;
+ }
+
+ ///
+ /// Rule 3: the one open call the unit/person is dispatched to, ignoring the call their previous (clearing) status
+ /// already cleared. Null when there is none or more than one.
+ ///
+ public static int? PickDispatchCall(IEnumerable openDispatchedCallIds, int? clearedCallId)
+ {
+ if (openDispatchedCallIds == null)
+ return null;
+
+ var candidates = openDispatchedCallIds.Where(x => x > 0 && (!clearedCallId.HasValue || x != clearedCallId.Value)).Distinct().Take(2).ToList();
+
+ return candidates.Count == 1 ? candidates[0] : (int?)null;
+ }
+
+ ///
+ /// The inferred statuses one dispatched unit or person contributes to a call's record.
+ ///
+ /// The call.
+ /// When the unit/person was dispatched to the call.
+ /// When the call closed, or now while it is open.
+ /// True for group/role paging: nothing counts before the first non-clearing status.
+ /// The unit's/person's statuses (any order); rows outside [start, end] are ignored.
+ /// For a row whose destination id is this call: whether it really is on this call's record
+ /// (an untyped legacy row may name a station or POI whose id equals the call id); null accepts it.
+ /// The unit's/person's dispatches to other calls (); null or
+ /// empty when there were none. must then also cover them from their start.
+ public static List InferForCall(int callId, DateTime start, DateTime end, bool requireEngagement, IEnumerable rows,
+ Func timestamp, Func id, Func destinationId, Func destinationType,
+ Func isClearing, Action markInferred, Func belongsToCall = null,
+ IReadOnlyCollection otherDispatches = null) where T : class
+ {
+ var inferred = new List();
+ if (rows == null)
+ return inferred;
+
+ var all = rows.Where(x => x != null).ToList();
+
+ // The statuses another call's walk would take too (that walk run on its own, without this rule).
+ HashSet takenElsewhere = null;
+ foreach (var other in otherDispatches ?? Array.Empty())
+ {
+ if (other.CallId == callId)
+ continue;
+
+ takenElsewhere ??= new HashSet();
+ foreach (var row in Walk(other.CallId, other.Start, other.End, other.RequireEngagement, all, timestamp, id, destinationId, destinationType, isClearing, belongsToCall, null))
+ takenElsewhere.Add(id(row));
+ }
+
+ foreach (var row in Walk(callId, start, end, requireEngagement, all, timestamp, id, destinationId, destinationType, isClearing, belongsToCall, takenElsewhere))
+ {
+ var copy = (T)MemberwiseCloneMethod.Invoke(row, null);
+ markInferred(copy);
+ inferred.Add(copy);
+ }
+
+ return inferred;
+ }
+
+ /// The rows (originals) the walk for one call takes; see .
+ private static List Walk(int callId, DateTime start, DateTime end, bool requireEngagement, IEnumerable rows,
+ Func timestamp, Func id, Func destinationId, Func destinationType,
+ Func isClearing, Func belongsToCall, ISet takenElsewhere) where T : class
+ {
+ var taken = new List();
+ var engaged = !requireEngagement;
+ // True while the previous status in the walk is on this call's record (sent, linked or taken): the write-time
+ // carry-forward rule would have kept the next status on this call whatever else was open.
+ var followsThisCall = false;
+ foreach (var row in rows.Where(x => timestamp(x) >= start && timestamp(x) <= end).OrderBy(timestamp).ThenBy(id))
+ {
+ if (destinationId(row).HasValue && destinationId(row).Value > 0)
+ {
+ // Already on this call's record, or pointing at another call, station or POI: the unit/person has moved on.
+ if (CallStatusLinkage.LinkedCallId(destinationId(row), destinationType(row)) != callId || (belongsToCall != null && !belongsToCall(row)))
+ break;
+
+ engaged = true;
+ if (isClearing(row))
+ break;
+
+ followsThisCall = true;
+ continue;
+ }
+
+ var clearing = isClearing(row);
+
+ // Another call's walk takes this status too: it could be for either call.
+ if (!followsThisCall && takenElsewhere != null && takenElsewhere.Contains(id(row)))
+ {
+ if (clearing)
+ break;
+
+ // It still shows the unit/person turned out, so the walk never takes more than the other call's would.
+ engaged = true;
+ continue;
+ }
+
+ if (!engaged)
+ {
+ if (clearing)
+ continue;
+
+ engaged = true;
+ }
+
+ taken.Add(row);
+ followsThisCall = true;
+
+ if (clearing)
+ break;
+ }
+
+ return taken;
+ }
+
+ public static List InferUnitStates(int callId, DateTime start, DateTime end, IEnumerable states, Func isClearing,
+ IReadOnlyDictionary statusLookup = null, IReadOnlyCollection otherDispatches = null)
+ {
+ return InferForCall(callId, start, end, false, states, x => x.Timestamp, x => x.UnitStateId, x => x.DestinationId, x => x.DestinationType, isClearing,
+ x =>
+ {
+ x.DestinationId = callId;
+ x.DestinationType = (int)DestinationEntityTypes.Call;
+ x.DestinationSource = (int)StatusDestinationSources.Inferred;
+ },
+ x => x.BelongsToCall(statusLookup), otherDispatches);
+ }
+
+ public static List InferActionLogs(int callId, DateTime start, DateTime end, bool requireEngagement, IEnumerable logs, Func isClearing,
+ IReadOnlyDictionary statusLookup = null, IReadOnlyCollection otherDispatches = null)
+ {
+ return InferForCall(callId, start, end, requireEngagement, logs, x => x.Timestamp, x => x.ActionLogId, x => x.DestinationId, x => x.DestinationType, isClearing,
+ x =>
+ {
+ x.DestinationId = callId;
+ x.DestinationType = (int)DestinationEntityTypes.Call;
+ x.DestinationSource = (int)StatusDestinationSources.Inferred;
+ },
+ x => x.BelongsToCall(statusLookup), otherDispatches);
+ }
+
+ /// True when a status row reached the call's record without the client naming the call.
+ public static bool IsAutoLinked(int? destinationSource)
+ {
+ return destinationSource == (int)StatusDestinationSources.CarryForward || destinationSource == (int)StatusDestinationSources.Dispatch
+ || destinationSource == (int)StatusDestinationSources.Unit;
+ }
+
+ public static bool IsInferred(int? destinationSource)
+ {
+ return destinationSource == (int)StatusDestinationSources.Inferred;
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/CallStatusLinkage.cs b/Core/Resgrid.Model/CallStatusLinkage.cs
new file mode 100644
index 000000000..874ec2fef
--- /dev/null
+++ b/Core/Resgrid.Model/CallStatusLinkage.cs
@@ -0,0 +1,228 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Decides which unit states and personnel action logs that name a call as their destination belong on
+ /// that call's record (call view, call export, call history/timeline, incident and NFIRS reports).
+ ///
+ /// A row explicitly typed as a call destination always belongs to the call, whatever the status is and
+ /// however that status is configured today: editing a custom status later must never hide history.
+ /// Rows with no destination type are legacy (written before destination types existed, or by paths that
+ /// never set one), and their destination id may be a station id that merely equals the call id. Those
+ /// are kept unless the status only ever targets stations or POIs.
+ ///
+ public static class CallStatusLinkage
+ {
+ ///
+ /// Raw status values at or below this are built-in / ;
+ /// anything above is a . Matches the cut-off
+ /// CustomStateService uses when resolving status labels.
+ ///
+ public const int MaxBuiltInStatusId = 25;
+
+ ///
+ /// Builds a raw-status to status-detail lookup for one department: the built-in statuses (keyed by their
+ /// enum value) plus every custom status detail of the given custom state type, deleted ones included so
+ /// historical rows still resolve.
+ ///
+ public static Dictionary BuildStatusLookup(IEnumerable builtInStatuses,
+ IEnumerable customStates, CustomStateTypes customStateType)
+ {
+ var lookup = new Dictionary();
+
+ if (builtInStatuses != null)
+ {
+ foreach (var detail in builtInStatuses.Where(x => x != null))
+ lookup[detail.CustomStateDetailId] = detail;
+ }
+
+ if (customStates != null)
+ {
+ foreach (var state in customStates.Where(x => x != null && x.Type == (int)customStateType && x.Details != null))
+ {
+ foreach (var detail in state.Details.Where(x => x != null && x.CustomStateDetailId > MaxBuiltInStatusId))
+ lookup[detail.CustomStateDetailId] = detail;
+ }
+ }
+
+ return lookup;
+ }
+
+ ///
+ /// True when a status row whose destination id equals the call id belongs on that call.
+ ///
+ /// The row's stored destination type (null for legacy rows).
+ /// The row's status detail, or null when it can't be resolved.
+ public static bool BelongsToCall(int? destinationType, CustomStateDetail status)
+ {
+ if (destinationType.HasValue)
+ return destinationType.Value == (int)DestinationEntityTypes.Call;
+
+ if (status == null)
+ return true;
+
+ var detailType = status.DetailType;
+ if (detailType.SupportsCalls())
+ return true;
+
+ return !detailType.SupportsStations() && !detailType.SupportsPois();
+ }
+
+ public static bool BelongsToCall(this UnitState unitState, IReadOnlyDictionary statusLookup)
+ {
+ if (unitState == null)
+ return false;
+
+ CustomStateDetail status = null;
+ statusLookup?.TryGetValue(unitState.State, out status);
+
+ return BelongsToCall(unitState.DestinationType, status);
+ }
+
+ public static bool BelongsToCall(this ActionLog actionLog, IReadOnlyDictionary statusLookup)
+ {
+ if (actionLog == null)
+ return false;
+
+ CustomStateDetail status = null;
+ statusLookup?.TryGetValue(actionLog.ActionTypeId, out status);
+
+ return BelongsToCall(actionLog.DestinationType, status);
+ }
+
+ ///
+ /// Maps a raw unit state (built-in value or custom status detail id) to the built-in
+ /// that call-time reports (incident, NFIRS, invoicing) key on. Custom statuses
+ /// resolve through their ; a custom status with no operational
+ /// base type (or one with no built-in equivalent) returns null.
+ ///
+ public static UnitStateTypes? ResolveUnitStateKind(int rawState, IReadOnlyDictionary customBaseTypes)
+ {
+ if (rawState <= MaxBuiltInStatusId)
+ {
+ if (System.Enum.IsDefined(typeof(UnitStateTypes), rawState))
+ return (UnitStateTypes)rawState;
+
+ return null;
+ }
+
+ if (customBaseTypes == null || !customBaseTypes.TryGetValue(rawState, out var baseType))
+ return null;
+
+ switch ((ActionBaseTypes)baseType)
+ {
+ case ActionBaseTypes.Available:
+ return UnitStateTypes.Available;
+ case ActionBaseTypes.Unavailable:
+ case ActionBaseTypes.NotResponding:
+ return UnitStateTypes.Unavailable;
+ case ActionBaseTypes.Maintenance:
+ return UnitStateTypes.OutOfService;
+ case ActionBaseTypes.Dispatched:
+ return UnitStateTypes.Committed;
+ case ActionBaseTypes.Responding:
+ return UnitStateTypes.Responding;
+ case ActionBaseTypes.Enroute:
+ return UnitStateTypes.Enroute;
+ case ActionBaseTypes.OnScene:
+ case ActionBaseTypes.MadeContact:
+ case ActionBaseTypes.Investigating:
+ case ActionBaseTypes.AtPatient:
+ case ActionBaseTypes.Searching:
+ return UnitStateTypes.OnScene;
+ case ActionBaseTypes.Staging:
+ return UnitStateTypes.Staging;
+ case ActionBaseTypes.Cleared:
+ return UnitStateTypes.Released;
+ case ActionBaseTypes.Returning:
+ return UnitStateTypes.Returning;
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// Builds the custom status detail id to map used by
+ /// , deleted details included so historical rows still resolve.
+ ///
+ public static Dictionary BuildUnitBaseTypeMap(IEnumerable customStates)
+ {
+ return BuildBaseTypeMap(customStates, CustomStateTypes.Unit);
+ }
+
+ ///
+ /// Builds the custom status detail id to map for one custom state type,
+ /// deleted details included so historical rows still resolve.
+ ///
+ public static Dictionary BuildBaseTypeMap(IEnumerable customStates, CustomStateTypes customStateType)
+ {
+ var map = new Dictionary();
+
+ if (customStates == null)
+ return map;
+
+ foreach (var state in customStates.Where(x => x != null && x.Type == (int)customStateType && x.Details != null))
+ {
+ foreach (var detail in state.Details.Where(x => x != null && x.CustomStateDetailId > MaxBuiltInStatusId))
+ map[detail.CustomStateDetailId] = detail.BaseType;
+ }
+
+ return map;
+ }
+
+ ///
+ /// The call a status row points at: its destination when that is typed as a call, or untyped (legacy rows).
+ ///
+ public static int? LinkedCallId(int? destinationId, int? destinationType)
+ {
+ if (!destinationId.HasValue || destinationId.Value <= 0)
+ return null;
+
+ if (destinationType.HasValue && destinationType.Value != (int)DestinationEntityTypes.Call)
+ return null;
+
+ return destinationId.Value;
+ }
+
+ ///
+ /// True when a unit state ends the unit's involvement in whatever it was working: back in service, out of
+ /// service, returning, released or cancelled. Custom statuses resolve through their base type.
+ ///
+ public static bool IsClearingUnitState(int rawState, IReadOnlyDictionary customBaseTypes)
+ {
+ var kind = ResolveUnitStateKind(rawState, customBaseTypes);
+
+ return kind is UnitStateTypes.Available or UnitStateTypes.Unavailable or UnitStateTypes.OutOfService
+ or UnitStateTypes.Returning or UnitStateTypes.Cancelled or UnitStateTypes.Released;
+ }
+
+ ///
+ /// True when a personnel status ends the person's involvement: available / standing by, not responding, available
+ /// at a station, or a custom status whose base type means cleared, returning, unavailable or out of service.
+ /// Responding to a station is not clearing: volunteers respond to the station to pick up the apparatus.
+ ///
+ public static bool IsClearingPersonnelStatus(int rawStatus, IReadOnlyDictionary customBaseTypes)
+ {
+ if (rawStatus <= MaxBuiltInStatusId)
+ return rawStatus == (int)ActionTypes.StandingBy || rawStatus == (int)ActionTypes.NotResponding || rawStatus == (int)ActionTypes.AvailableStation;
+
+ if (customBaseTypes == null || !customBaseTypes.TryGetValue(rawStatus, out var baseType))
+ return false;
+
+ switch ((ActionBaseTypes)baseType)
+ {
+ case ActionBaseTypes.Available:
+ case ActionBaseTypes.NotResponding:
+ case ActionBaseTypes.Cleared:
+ case ActionBaseTypes.Returning:
+ case ActionBaseTypes.Unavailable:
+ case ActionBaseTypes.Maintenance:
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Helpers/StatusTimestampHelper.cs b/Core/Resgrid.Model/Helpers/StatusTimestampHelper.cs
new file mode 100644
index 000000000..1696a554f
--- /dev/null
+++ b/Core/Resgrid.Model/Helpers/StatusTimestampHelper.cs
@@ -0,0 +1,55 @@
+using System;
+
+namespace Resgrid.Model.Helpers
+{
+ ///
+ /// Picks the time a status change actually happened from what a client sent. Apps queue statuses while
+ /// offline and replay them later; stamping the replay with the server's receive time moves "on scene at"
+ /// to whenever the phone found signal again.
+ ///
+ public static class StatusTimestampHelper
+ {
+ /// How far in the past a client-supplied status time is still accepted (long offline stretches).
+ public static readonly TimeSpan MaxClientAge = TimeSpan.FromDays(7);
+
+ /// Allowance for device clock skew when a client-supplied status time is ahead of the server.
+ public static readonly TimeSpan MaxClientSkew = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Returns the UTC time of a status change: the client's UTC timestamp when it is present, zone-aware and
+ /// plausible, otherwise . is documented as UTC,
+ /// so an unzoned value there is read as UTC. is device-local and is only used
+ /// when it carries its zone (an ISO-8601 value ending in Z or an offset), since an unzoned local time
+ /// can't be placed on the timeline.
+ ///
+ public static DateTime ResolveStatusTimeUtc(DateTime? timestampUtc, DateTime? timestamp, DateTime nowUtc)
+ {
+ var candidate = ToUtc(timestampUtc, unspecifiedIsUtc: true) ?? ToUtc(timestamp, unspecifiedIsUtc: false);
+
+ if (!candidate.HasValue)
+ return nowUtc;
+
+ if (candidate.Value > nowUtc.Add(MaxClientSkew) || candidate.Value < nowUtc.Subtract(MaxClientAge))
+ return nowUtc;
+
+ // Skew inside the allowance still must not put a status in the future.
+ return candidate.Value > nowUtc ? nowUtc : candidate.Value;
+ }
+
+ private static DateTime? ToUtc(DateTime? value, bool unspecifiedIsUtc)
+ {
+ if (!value.HasValue || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue)
+ return null;
+
+ switch (value.Value.Kind)
+ {
+ case DateTimeKind.Utc:
+ return value.Value;
+ case DateTimeKind.Local:
+ return value.Value.ToUniversalTime();
+ default:
+ return unspecifiedIsUtc ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : (DateTime?)null;
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs
index 93e7b3623..d994bf4c1 100644
--- a/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs
+++ b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs
@@ -28,6 +28,12 @@ public class InvoiceLineItem : IEntity
/// Phase C provenance (M0219): the approved daily time report this line was generated from.
public string DeploymentTimeReportId { get; set; }
+
+ ///
+ /// M0230: where a generated hourly unit line's on-scene time came from (), so the
+ /// clerk can see when the billed time rests on a status Resgrid linked or inferred. Null for other lines.
+ ///
+ public int? TimeSource { get; set; }
public int SortOrder { get; set; }
[NotMapped]
diff --git a/Core/Resgrid.Model/Invoicing/InvoiceLineTimeSources.cs b/Core/Resgrid.Model/Invoicing/InvoiceLineTimeSources.cs
new file mode 100644
index 000000000..34efc637f
--- /dev/null
+++ b/Core/Resgrid.Model/Invoicing/InvoiceLineTimeSources.cs
@@ -0,0 +1,20 @@
+namespace Resgrid.Model.Invoicing
+{
+ /// Where a generated hourly unit line's on-scene time came from (InvoiceLineItem.TimeSource).
+ public enum InvoiceLineTimeSources
+ {
+ /// The unit's own On Scene status (and the status that ended it), linked to the call by the sender.
+ UnitStatus = 1,
+
+ /// On Scene (or the status that ended it) was sent without a call and Resgrid linked it: carried forward
+ /// from the previous status, the unit's one open dispatch, or the unit a person rode.
+ AutoLinkedStatus = 2,
+
+ /// On Scene (or the status that ended it) was set with no destination while the unit was dispatched to
+ /// the call, and is inferred to belong to it.
+ InferredStatus = 3,
+
+ /// The unit never reported On Scene on the call; the line bills the call's logged-to-closed window.
+ CallWindow = 4
+ }
+}
diff --git a/Core/Resgrid.Model/Reporting/CallUnitTimesCalculator.cs b/Core/Resgrid.Model/Reporting/CallUnitTimesCalculator.cs
new file mode 100644
index 000000000..2bae54285
--- /dev/null
+++ b/Core/Resgrid.Model/Reporting/CallUnitTimesCalculator.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model.Reporting
+{
+ /// How the times on a call unit times row were established, most trustworthy first.
+ public enum CallUnitTimesSources
+ {
+ /// Every time came from statuses the sender linked to the call.
+ Explicit = 0,
+
+ /// At least one time came from a status Resgrid linked to the call on save (carried forward, dispatch, unit).
+ AutoLinked = 1,
+
+ /// At least one time came from an inferred status (dispatched unit, no destination, while working the call).
+ Inferred = 2,
+
+ /// The unit was dispatched but has no status on the call.
+ DispatchOnly = 3
+ }
+
+ /// One unit's key times on one call.
+ public class CallUnitTimesRow
+ {
+ public int UnitId { get; set; }
+ public DateTime? DispatchedOn { get; set; }
+ public DateTime? EnrouteOn { get; set; }
+ public DateTime? OnSceneOn { get; set; }
+ public DateTime? StagingOn { get; set; }
+ public DateTime? ClearedOn { get; set; }
+ public CallUnitTimesSources Source { get; set; }
+ }
+
+ ///
+ /// Per-unit dispatched / en route / on scene / staging / cleared times for a call from its unit dispatches and the unit
+ /// states on the call's record (linked and inferred). Custom statuses count through their base type. Cleared is the
+ /// first clearing status (available, returning, released, cancelled, out of service) at or after the unit engaged (or
+ /// after dispatch when it never did).
+ ///
+ public static class CallUnitTimesCalculator
+ {
+ public static List Compute(IEnumerable dispatches, IEnumerable states, IReadOnlyDictionary customBaseTypes)
+ {
+ var dispatchList = (dispatches ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ var stateList = (states ?? Enumerable.Empty()).Where(x => x != null).ToList();
+
+ var unitIds = dispatchList.Select(x => x.UnitId).Concat(stateList.Select(x => x.UnitId)).Distinct();
+ var rows = new List();
+
+ foreach (var unitId in unitIds)
+ {
+ var unitDispatches = dispatchList.Where(x => x.UnitId == unitId).ToList();
+ var ordered = stateList.Where(x => x.UnitId == unitId).OrderBy(x => x.Timestamp).ThenBy(x => x.UnitStateId).ToList();
+
+ UnitStateTypes? KindOf(UnitState s) => CallStatusLinkage.ResolveUnitStateKind(s.State, customBaseTypes);
+ UnitState First(params UnitStateTypes[] kinds) => ordered.FirstOrDefault(s => KindOf(s) is UnitStateTypes kind && kinds.Contains(kind));
+
+ var row = new CallUnitTimesRow { UnitId = unitId };
+ row.DispatchedOn = unitDispatches.Count > 0 ? unitDispatches.Min(x => x.DispatchedOn) : (DateTime?)null;
+
+ var enroute = First(UnitStateTypes.Responding, UnitStateTypes.Enroute);
+ var onScene = First(UnitStateTypes.OnScene);
+ var staging = First(UnitStateTypes.Staging);
+
+ var engagedAt = new[] { enroute?.Timestamp, onScene?.Timestamp, staging?.Timestamp }.Where(x => x.HasValue).Select(x => x.Value).DefaultIfEmpty(DateTime.MinValue).Min();
+ var clearFrom = engagedAt != DateTime.MinValue ? engagedAt : (row.DispatchedOn ?? DateTime.MinValue);
+ var cleared = ordered.FirstOrDefault(s => s.Timestamp >= clearFrom && CallStatusLinkage.IsClearingUnitState(s.State, customBaseTypes));
+
+ row.EnrouteOn = enroute?.Timestamp;
+ row.OnSceneOn = onScene?.Timestamp;
+ row.StagingOn = staging?.Timestamp;
+ row.ClearedOn = cleared?.Timestamp;
+
+ var used = new[] { enroute, onScene, staging, cleared }.Where(x => x != null).ToList();
+ if (used.Count == 0)
+ row.Source = CallUnitTimesSources.DispatchOnly;
+ else if (used.Any(x => CallStatusAttribution.IsInferred(x.DestinationSource)))
+ row.Source = CallUnitTimesSources.Inferred;
+ else if (used.Any(x => CallStatusAttribution.IsAutoLinked(x.DestinationSource)))
+ row.Source = CallUnitTimesSources.AutoLinked;
+ else
+ row.Source = CallUnitTimesSources.Explicit;
+
+ rows.Add(row);
+ }
+
+ return rows;
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs b/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs
index 3111288f3..d545c1bc5 100644
--- a/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IActionLogsRepository.cs
@@ -62,11 +62,14 @@ public interface IActionLogsRepository: IRepository
Task GetLastActionLogsForUserAsync(string userId, bool disableAutoAvailable, DateTime timeStamp);
///
- /// Gets the last action logs for user asynchronous.
+ /// Gets every action log in the department whose destination is the call: rows explicitly typed as a
+ /// call destination plus legacy rows with no destination type. The caller decides how to treat the
+ /// untyped rows.
///
+ /// The department that owns the call.
/// The call identifier.
- /// Task<ActionLog>.
- Task> GetActionLogsForCallAsync(int callId);
+ /// Task<IEnumerable<ActionLog>>.
+ Task> GetActionLogsForCallAsync(int departmentId, int callId);
///
/// Gets the previous action log asynchronous.
@@ -83,14 +86,6 @@ public interface IActionLogsRepository: IRepository
/// Task<ActionLog>.
Task GetLastActionLogForUserAsync(string userId);
- ///
- /// Gets the action logs for call and types asynchronous.
- ///
- /// The destination identifier.
- /// The types.
- /// Task<IEnumerable<ActionLog>>.
- Task> GetActionLogsForCallAndTypesAsync(int destinationId, List types);
-
Task> GetAllActionLogsInDateRangeAsync(int departmentId, DateTime startDate, DateTime endDate);
}
}
diff --git a/Core/Resgrid.Model/Repositories/ICallDispatchGroupRepository.cs b/Core/Resgrid.Model/Repositories/ICallDispatchGroupRepository.cs
index 4b1d8da48..3be5d504b 100644
--- a/Core/Resgrid.Model/Repositories/ICallDispatchGroupRepository.cs
+++ b/Core/Resgrid.Model/Repositories/ICallDispatchGroupRepository.cs
@@ -23,5 +23,10 @@ public interface ICallDispatchGroupRepository: IRepository
/// The call identifier.
/// Task<IEnumerable<CallDispatchGroup>>.
Task> GetAllCallDispatchGroupByCallIdAsync(int callId);
+
+ ///
+ /// Gets the group dispatches of every department call logged in the range (UTC, inclusive).
+ ///
+ Task> GetCallDispatchGroupsForCallsInRangeAsync(int departmentId, System.DateTime startDate, System.DateTime endDate);
}
}
diff --git a/Core/Resgrid.Model/Repositories/ICallDispatchRoleRepository.cs b/Core/Resgrid.Model/Repositories/ICallDispatchRoleRepository.cs
index 3360d628c..21365c45c 100644
--- a/Core/Resgrid.Model/Repositories/ICallDispatchRoleRepository.cs
+++ b/Core/Resgrid.Model/Repositories/ICallDispatchRoleRepository.cs
@@ -16,5 +16,10 @@ public interface ICallDispatchRoleRepository: IRepository
/// The call identifier.
/// Task<IEnumerable<CallDispatchRole>>.
Task> GetCallRoleDispatchesByCallIdAsync(int callId);
+
+ ///
+ /// Gets the role dispatches of every department call logged in the range (UTC, inclusive).
+ ///
+ Task> GetCallDispatchRolesForCallsInRangeAsync(int departmentId, System.DateTime startDate, System.DateTime endDate);
}
}
diff --git a/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs b/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs
index e60663f57..e60bc4d20 100644
--- a/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs
+++ b/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Threading.Tasks;
namespace Resgrid.Model.Repositories
@@ -16,5 +17,21 @@ public interface ICallDispatchUnitRepository: IRepository
/// The call identifier.
/// Task<IEnumerable<CallDispatchUnit>>.
Task> GetCallUnitDispatchesByCallIdAsync(int callId);
+
+ ///
+ /// Gets the ids of the department's open (active, not deleted) calls the unit is dispatched to.
+ ///
+ Task> GetOpenCallIdsForUnitAsync(int departmentId, int unitId);
+
+ ///
+ /// Gets the unit dispatches of every department call logged in the range (UTC, inclusive).
+ ///
+ Task> GetCallUnitDispatchesForCallsInRangeAsync(int departmentId, DateTime startDate, DateTime endDate);
+
+ ///
+ /// Gets the unit dispatches made in the range (UTC, inclusive), with each call's logged and closed times, on department
+ /// calls logged from .
+ ///
+ Task> GetUnitDispatchWindowsAsync(int departmentId, DateTime startDate, DateTime endDate, DateTime loggedFrom);
}
}
diff --git a/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs b/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs
index 41e8401e8..c0edae8f3 100644
--- a/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs
+++ b/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs
@@ -25,5 +25,23 @@ public interface ICallDispatchesRepository: IRepository
/// The call identifier.
/// Task<IEnumerable<CallDispatch>>.
Task> GetCallDispatchesByCallIdAsync(int callId);
+
+ ///
+ /// Gets the ids of the department's open (active, not deleted) calls the user is dispatched to, directly or through a
+ /// dispatched group they belong to or a dispatched role they hold.
+ ///
+ Task> GetOpenCallIdsForUserAsync(int departmentId, string userId);
+
+ ///
+ /// Gets the personnel dispatches of every department call logged in the range (UTC, inclusive).
+ ///
+ Task> GetCallDispatchesForCallsInRangeAsync(int departmentId, DateTime startDate, DateTime endDate);
+
+ ///
+ /// Gets the personnel dispatches made in the range (UTC, inclusive) — direct, or through a dispatched group the user
+ /// belongs to or a dispatched role they hold — with each call's logged and closed times, on department calls logged
+ /// from .
+ ///
+ Task> GetPersonnelDispatchWindowsAsync(int departmentId, DateTime startDate, DateTime endDate, DateTime loggedFrom);
}
}
diff --git a/Core/Resgrid.Model/Repositories/IUnitStatesRepository.cs b/Core/Resgrid.Model/Repositories/IUnitStatesRepository.cs
index ad0a00a38..1858b4071 100644
--- a/Core/Resgrid.Model/Repositories/IUnitStatesRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IUnitStatesRepository.cs
@@ -34,11 +34,14 @@ public interface IUnitStatesRepository: IRepository
Task GetLastUnitStateBeforeIdAsync(int unitId, int unitStateId);
///
- /// Gets all states by call identifier asynchronous.
+ /// Gets every state of the department's units whose destination is the call: rows explicitly typed as a
+ /// call destination plus legacy rows with no destination type. The caller decides how to treat the
+ /// untyped rows.
///
+ /// The department that owns the call.
/// The call identifier.
/// Task<IEnumerable<UnitState>>.
- Task> GetAllStatesByCallIdAsync(int callId);
+ Task> GetAllStatesByCallIdAsync(int departmentId, int callId);
///
/// Gets the latest unit states for department asynchronous.
diff --git a/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs b/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs
new file mode 100644
index 000000000..a1125cacd
--- /dev/null
+++ b/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs
@@ -0,0 +1,57 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Ties unit states and personnel statuses to calls when the client didn't say which call they were for, and finds the
+ /// statuses a call's dispatched units and personnel set while working it. See for
+ /// the rules.
+ ///
+ public interface ICallStatusAttributionService
+ {
+ ///
+ /// Before a unit state is saved: marks a sent destination as explicit, or links the state to the previous state's
+ /// still-open call or to the one open call the unit is dispatched to; a state replayed from an offline queue is left
+ /// unlinked for the read-time walk. Never throws; a failure saves the state as sent.
+ ///
+ Task AttributeUnitStateAsync(UnitState state, UnitState previousState, int departmentId);
+
+ ///
+ /// Before a personnel status is saved: marks a sent destination as explicit, or links the status to the call of the
+ /// unit state that placed the person on a unit, the previous status's still-open call, or the one open call the
+ /// person is dispatched to; a status replayed from an offline queue is only linked through its unit state. Never
+ /// throws; a failure saves the status as sent.
+ ///
+ Task AttributeActionLogAsync(ActionLog actionLog, ActionLog previousActionLog);
+
+ ///
+ /// Statuses with no destination that the call's dispatched units set while working it, as inferred copies linked
+ /// to the call ().
+ ///
+ Task> GetInferredUnitStatesForCallAsync(int departmentId, int callId);
+
+ ///
+ /// Statuses with no destination that the call's dispatched personnel (directly, or through a dispatched group or
+ /// role once they engaged) set while working it, as inferred copies linked to the call.
+ ///
+ Task> GetInferredActionLogsForCallAsync(int departmentId, int callId);
+
+ ///
+ /// For reports over many calls: each call's unit states, linked and inferred, from one read of each department
+ /// unit's history instead of per-call queries. Units are populated on every row.
+ ///
+ Task>> GetUnitStatesForCallsAsync(int departmentId, IReadOnlyCollection calls);
+
+ ///
+ /// For reports over many calls: each call's personnel statuses, linked and inferred, from one read of the
+ /// department's statuses and dispatches for the period instead of per-call queries.
+ ///
+ Task>> GetActionLogsForCallsAsync(int departmentId, IReadOnlyCollection calls);
+
+ ///
+ /// The unit dispatches of the given calls (all logged in one department), keyed by call.
+ ///
+ Task>> GetUnitDispatchesForCallsAsync(int departmentId, IReadOnlyCollection calls);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitsService.cs b/Core/Resgrid.Model/Services/IUnitsService.cs
index e9c7789b1..618bc0eae 100644
--- a/Core/Resgrid.Model/Services/IUnitsService.cs
+++ b/Core/Resgrid.Model/Services/IUnitsService.cs
@@ -300,6 +300,14 @@ Task DeleteStatesForUnitAsync(int unitId,
/// Task<List<UnitState>>.
Task> GetUnitStatesForCallAsync(int departmentId, int callId);
+ ///
+ /// Gets the department's custom unit status detail id to map, deleted
+ /// details included, for resolving raw unit states with .
+ ///
+ /// The department identifier.
+ /// Task<Dictionary<System.Int32, System.Int32>>.
+ Task> GetCustomUnitStateBaseTypesAsync(int departmentId);
+
///
/// Adds the unit location asynchronous.
///
diff --git a/Core/Resgrid.Model/StatusDestinationSources.cs b/Core/Resgrid.Model/StatusDestinationSources.cs
new file mode 100644
index 000000000..6afc2f735
--- /dev/null
+++ b/Core/Resgrid.Model/StatusDestinationSources.cs
@@ -0,0 +1,28 @@
+namespace Resgrid.Model
+{
+ ///
+ /// How a unit state's or personnel status's destination was decided. Stored on UnitStates.DestinationSource and
+ /// ActionLogs.DestinationSource (null on rows written before M0228).
+ ///
+ public enum StatusDestinationSources
+ {
+ /// The member, dispatcher or system process that set the status chose the destination.
+ Explicit = 1,
+
+ /// No destination was sent; the server kept the call of the unit's/person's previous status, which was
+ /// still open and was not a clearing status.
+ CarryForward = 2,
+
+ /// No destination was sent; the server used the one open call the unit or person is dispatched to.
+ Dispatch = 3,
+
+ /// A person riding a unit took the call of the unit status that placed them on it.
+ Unit = 4,
+
+ ///
+ /// Read-only, never stored: a status with no destination that a unit or person dispatched to the call set while
+ /// working it, shown on the call's record as inferred.
+ ///
+ Inferred = 5
+ }
+}
diff --git a/Core/Resgrid.Model/UnitState.cs b/Core/Resgrid.Model/UnitState.cs
index d60766057..406cacb79 100644
--- a/Core/Resgrid.Model/UnitState.cs
+++ b/Core/Resgrid.Model/UnitState.cs
@@ -32,6 +32,9 @@ public class UnitState : IEntity
public int? DestinationType { get; set; }
+ /// How the destination was decided (); null on rows before M0228.
+ public int? DestinationSource { get; set; }
+
public DateTime? LocalTimestamp { get; set; }
public string Note { get; set; }
diff --git a/Core/Resgrid.Services/ActionLogsService.cs b/Core/Resgrid.Services/ActionLogsService.cs
index 39be2fa06..857fa79be 100644
--- a/Core/Resgrid.Services/ActionLogsService.cs
+++ b/Core/Resgrid.Services/ActionLogsService.cs
@@ -28,11 +28,13 @@ public class ActionLogsService : IActionLogsService
private readonly IGeoService _geoService;
private readonly ICustomStateService _customStateService;
private readonly ICacheProvider _cacheProvider;
+ private readonly ICallStatusAttributionService _callStatusAttributionService;
public ActionLogsService(IActionLogsRepository actionLogsRepository, IUsersService usersService,
IDepartmentMembersRepository departmentMembersRepository, IDepartmentGroupsService departmentGroupsService,
IDepartmentsService departmentsService, IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator,
- IGeoService geoService, ICustomStateService customStateService, ICacheProvider cacheProvider)
+ IGeoService geoService, ICustomStateService customStateService, ICacheProvider cacheProvider,
+ ICallStatusAttributionService callStatusAttributionService)
{
_actionLogsRepository = actionLogsRepository;
_usersService = usersService;
@@ -44,6 +46,7 @@ public ActionLogsService(IActionLogsRepository actionLogsRepository, IUsersServi
_geoService = geoService;
_customStateService = customStateService;
_cacheProvider = cacheProvider;
+ _callStatusAttributionService = callStatusAttributionService;
}
#endregion Private Members and Constructors
@@ -185,6 +188,15 @@ public async Task GetPreviousActionLogAsync(string userId, int action
{
actionLog.Timestamp = actionLog.Timestamp.ToUniversalTime();
+ // Every single-status path lands here: a status sent without a destination is linked to the call the person is
+ // working (the unit they were placed on, the previous status's open call, or their one open dispatch), and a
+ // sent destination is marked explicit. Bulk resets (SaveAllActionLogsAsync) are left as sent.
+ if (actionLog.ActionLogId == 0)
+ {
+ var previous = !string.IsNullOrWhiteSpace(actionLog.UserId) ? await _actionLogsRepository.GetLastActionLogForUserAsync(actionLog.UserId) : null;
+ await _callStatusAttributionService.AttributeActionLogAsync(actionLog, previous);
+ }
+
var saved = await _actionLogsRepository.SaveOrUpdateAsync(actionLog, cancellationToken, true);
//InvalidateActionLogs(actionLog.DepartmentId);
@@ -417,20 +429,19 @@ public async Task GetActionLogByIdAsync(int actionLogId)
public async Task> GetActionLogsForCallAsync(int departmentId, int callId)
{
- List callEnabledStates = new List();
- var states = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId);
-
- callEnabledStates.Add((int)ActionTypes.OnScene);
- callEnabledStates.Add((int)ActionTypes.RespondingToScene);
+ // Every status that names the call counts (Responding, On Unit, a release back to Standing By, a
+ // custom status whose destination setting was later changed, ...). Only untyped legacy rows whose
+ // status targets stations are dropped, see CallStatusLinkage.
+ var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId);
+ var statusLookup = CallStatusLinkage.BuildStatusLookup(_customStateService.GetDefaultPersonStatuses(), customStates, CustomStateTypes.Personnel);
- var nonNullStates = from state in states
- where state.Details != null
- select state;
+ var items = await _actionLogsRepository.GetActionLogsForCallAsync(departmentId, callId) ?? Enumerable.Empty();
+ var logs = items.Where(x => x.BelongsToCall(statusLookup)).ToList();
- callEnabledStates.AddRange(from state in nonNullStates from detail in state.Details where detail.DetailType.SupportsCalls() select detail.CustomStateDetailId);
+ // Plus what the call's dispatched personnel did while working it without naming the call (flagged Inferred).
+ logs.AddRange(await _callStatusAttributionService.GetInferredActionLogsForCallAsync(departmentId, callId) ?? new List());
- var items = await _actionLogsRepository.GetActionLogsForCallAndTypesAsync(callId, callEnabledStates);
- return items.ToList();
+ return logs;
}
public async Task CreateUnitLinkedStatus(string userId, int departmentId, int unitStateId, string unitName, CancellationToken cancellationToken = default(CancellationToken))
diff --git a/Core/Resgrid.Services/CallDispatchStatusService.cs b/Core/Resgrid.Services/CallDispatchStatusService.cs
index 9d6c40f3a..680218ec3 100644
--- a/Core/Resgrid.Services/CallDispatchStatusService.cs
+++ b/Core/Resgrid.Services/CallDispatchStatusService.cs
@@ -86,7 +86,7 @@ private async Task ApplyPersonnelStatusesAsync(Call call, Department department,
foreach (var userId in shiftUserIds)
{
- await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, statusToSet, null, call.CallId, cancellationToken);
+ await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, statusToSet, null, call.CallId, (int)DestinationEntityTypes.Call, cancellationToken);
}
}
diff --git a/Core/Resgrid.Services/CallStatusAttributionService.cs b/Core/Resgrid.Services/CallStatusAttributionService.cs
new file mode 100644
index 000000000..36f310454
--- /dev/null
+++ b/Core/Resgrid.Services/CallStatusAttributionService.cs
@@ -0,0 +1,581 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Server-side call attribution for statuses (write) and call-record inference (read); rules in
+ /// . Depends on repositories and the custom state service only, so the units and
+ /// action log services can take it without a construction cycle.
+ ///
+ public class CallStatusAttributionService : ICallStatusAttributionService
+ {
+ private readonly ICallsRepository _callsRepository;
+ private readonly ICallDispatchUnitRepository _callDispatchUnitRepository;
+ private readonly ICallDispatchesRepository _callDispatchesRepository;
+ private readonly ICallDispatchGroupRepository _callDispatchGroupRepository;
+ private readonly ICallDispatchRoleRepository _callDispatchRoleRepository;
+ private readonly IDepartmentGroupMembersRepository _departmentGroupMembersRepository;
+ private readonly IPersonnelRoleUsersRepository _personnelRoleUsersRepository;
+ private readonly IUnitStatesRepository _unitStatesRepository;
+ private readonly IActionLogsRepository _actionLogsRepository;
+ private readonly IUnitsRepository _unitsRepository;
+ private readonly ICustomStateService _customStateService;
+
+ public CallStatusAttributionService(ICallsRepository callsRepository, ICallDispatchUnitRepository callDispatchUnitRepository,
+ ICallDispatchesRepository callDispatchesRepository, ICallDispatchGroupRepository callDispatchGroupRepository,
+ ICallDispatchRoleRepository callDispatchRoleRepository, IDepartmentGroupMembersRepository departmentGroupMembersRepository,
+ IPersonnelRoleUsersRepository personnelRoleUsersRepository, IUnitStatesRepository unitStatesRepository,
+ IActionLogsRepository actionLogsRepository, IUnitsRepository unitsRepository, ICustomStateService customStateService)
+ {
+ _callsRepository = callsRepository;
+ _callDispatchUnitRepository = callDispatchUnitRepository;
+ _callDispatchesRepository = callDispatchesRepository;
+ _callDispatchGroupRepository = callDispatchGroupRepository;
+ _callDispatchRoleRepository = callDispatchRoleRepository;
+ _departmentGroupMembersRepository = departmentGroupMembersRepository;
+ _personnelRoleUsersRepository = personnelRoleUsersRepository;
+ _unitStatesRepository = unitStatesRepository;
+ _actionLogsRepository = actionLogsRepository;
+ _unitsRepository = unitsRepository;
+ _customStateService = customStateService;
+ }
+
+ #region Write-time attribution
+ public async Task AttributeUnitStateAsync(UnitState state, UnitState previousState, int departmentId)
+ {
+ if (state == null)
+ return;
+
+ if (state.DestinationId.HasValue && state.DestinationId.Value > 0)
+ {
+ state.DestinationSource ??= (int)StatusDestinationSources.Explicit;
+ return;
+ }
+
+ try
+ {
+ // Carry-forward and dispatch read the unit's state now; a replayed offline state is placed by the read-time walk.
+ if (!CallStatusAttribution.IsLiveStatus(state.Timestamp, DateTime.UtcNow))
+ return;
+
+ var baseTypes = await GetBaseTypesAsync(departmentId, CustomStateTypes.Unit);
+ var previous = previousState != null && previousState.UnitId == state.UnitId ? previousState : null;
+ var previousCallId = previous != null ? CallStatusLinkage.LinkedCallId(previous.DestinationId, previous.DestinationType) : null;
+ var previousIsClearing = previous == null || CallStatusLinkage.IsClearingUnitState(previous.State, baseTypes);
+
+ if (previousCallId.HasValue && !previousIsClearing &&
+ CallStatusAttribution.CanCarryForward(previousCallId, previousIsClearing, await IsOpenCallAsync(departmentId, previousCallId.Value)))
+ {
+ Link(state, previousCallId.Value, StatusDestinationSources.CarryForward);
+ return;
+ }
+
+ if (CallStatusLinkage.IsClearingUnitState(state.State, baseTypes))
+ return;
+
+ var openCalls = await _callDispatchUnitRepository.GetOpenCallIdsForUnitAsync(departmentId, state.UnitId);
+ var callId = CallStatusAttribution.PickDispatchCall(openCalls, previousIsClearing ? previousCallId : null);
+
+ if (callId.HasValue)
+ Link(state, callId.Value, StatusDestinationSources.Dispatch);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"Call attribution failed for a state of unit {state.UnitId}; it is saved without a destination.");
+ }
+ }
+
+ public async Task AttributeActionLogAsync(ActionLog actionLog, ActionLog previousActionLog)
+ {
+ if (actionLog == null)
+ return;
+
+ if (actionLog.DestinationId.HasValue && actionLog.DestinationId.Value > 0)
+ {
+ actionLog.DestinationSource ??= (int)StatusDestinationSources.Explicit;
+ return;
+ }
+
+ try
+ {
+ // A person placed on a unit by a unit status goes wherever that unit status went.
+ if (actionLog.UnitStateId.HasValue && actionLog.UnitStateId.Value > 0)
+ {
+ var unitState = await _unitStatesRepository.GetUnitStateByUnitStateIdAsync(actionLog.UnitStateId.Value);
+ var unitCallId = unitState != null ? CallStatusLinkage.LinkedCallId(unitState.DestinationId, unitState.DestinationType) : null;
+
+ if (unitCallId.HasValue)
+ {
+ Link(actionLog, unitCallId.Value, StatusDestinationSources.Unit);
+ return;
+ }
+ }
+
+ // Carry-forward and dispatch read the person's state now; a replayed offline status is placed by the read-time walk.
+ if (!CallStatusAttribution.IsLiveStatus(actionLog.Timestamp, DateTime.UtcNow))
+ return;
+
+ var departmentId = actionLog.DepartmentId;
+ var baseTypes = await GetBaseTypesAsync(departmentId, CustomStateTypes.Personnel);
+ var previous = previousActionLog != null && previousActionLog.UserId == actionLog.UserId && previousActionLog.DepartmentId == departmentId
+ ? previousActionLog : null;
+ var previousCallId = previous != null ? CallStatusLinkage.LinkedCallId(previous.DestinationId, previous.DestinationType) : null;
+ var previousIsClearing = previous == null || CallStatusLinkage.IsClearingPersonnelStatus(previous.ActionTypeId, baseTypes);
+
+ if (previousCallId.HasValue && !previousIsClearing &&
+ CallStatusAttribution.CanCarryForward(previousCallId, previousIsClearing, await IsOpenCallAsync(departmentId, previousCallId.Value)))
+ {
+ Link(actionLog, previousCallId.Value, StatusDestinationSources.CarryForward);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(actionLog.UserId) || CallStatusLinkage.IsClearingPersonnelStatus(actionLog.ActionTypeId, baseTypes))
+ return;
+
+ var openCalls = await _callDispatchesRepository.GetOpenCallIdsForUserAsync(departmentId, actionLog.UserId);
+ var callId = CallStatusAttribution.PickDispatchCall(openCalls, previousIsClearing ? previousCallId : null);
+
+ if (callId.HasValue)
+ Link(actionLog, callId.Value, StatusDestinationSources.Dispatch);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"Call attribution failed for a status of user {actionLog.UserId}; it is saved without a destination.");
+ }
+ }
+ #endregion Write-time attribution
+
+ #region Read-time inference
+ public async Task> GetInferredUnitStatesForCallAsync(int departmentId, int callId)
+ {
+ var inferred = new List();
+
+ var call = await GetDepartmentCallAsync(departmentId, callId);
+ if (call == null)
+ return inferred;
+
+ var dispatches = (await _callDispatchUnitRepository.GetCallUnitDispatchesByCallIdAsync(callId))?.ToList() ?? new List();
+ if (dispatches.Count == 0)
+ return inferred;
+
+ var end = CallEnd(call);
+ var subjects = EarliestPerUnit(dispatches).Select(x => (x.UnitId, Start: DispatchStart(x.DispatchedOn, call))).Where(x => x.Start <= end).ToList();
+ if (subjects.Count == 0)
+ return inferred;
+
+ var (statusLookup, baseTypes) = await GetStatusRulesAsync(departmentId, CustomStateTypes.Unit);
+ var windowsByUnit = await GetUnitDispatchWindowsAsync(departmentId, subjects.Min(x => x.Start), end);
+
+ foreach (var subject in subjects)
+ {
+ // Another dispatch that overlaps this one is walked from its own start, so the states are read from there.
+ var others = OtherDispatches(windowsByUnit, subject.UnitId, call.CallId, subject.Start, end);
+ var from = others != null ? Earliest(subject.Start, others.Min(x => x.Start)) : subject.Start;
+
+ var states = await _unitStatesRepository.GetAllUnitStatesForUnitInDateRangeAsync(subject.UnitId, from, end);
+ inferred.AddRange(CallStatusAttribution.InferUnitStates(call.CallId, subject.Start, end, states,
+ s => CallStatusLinkage.IsClearingUnitState(s.State, baseTypes), statusLookup, others));
+ }
+
+ await PopulateUnitsAsync(departmentId, inferred);
+
+ return inferred;
+ }
+
+ public async Task> GetInferredActionLogsForCallAsync(int departmentId, int callId)
+ {
+ var call = await GetDepartmentCallAsync(departmentId, callId);
+ if (call == null)
+ return new List();
+
+ var groupDispatches = (await _callDispatchGroupRepository.GetAllCallDispatchGroupByCallIdAsync(callId))?.ToList() ?? new List();
+ var roleDispatches = (await _callDispatchRoleRepository.GetCallRoleDispatchesByCallIdAsync(callId))?.ToList() ?? new List();
+
+ var groupMembers = await GetGroupMembersAsync(departmentId, groupDispatches.Select(x => x.DepartmentGroupId));
+ var roleMembers = new Dictionary>();
+ foreach (var roleId in roleDispatches.Select(x => x.RoleId).Distinct())
+ roleMembers[roleId] = ((await _personnelRoleUsersRepository.GetAllMembersOfRoleAsync(roleId)) ?? Enumerable.Empty())
+ .Where(x => x.DepartmentId == departmentId).Select(x => x.UserId).ToList();
+
+ var subjects = BuildPersonnelSubjects(call, await _callDispatchesRepository.GetCallDispatchesByCallIdAsync(callId), groupDispatches, roleDispatches, groupMembers, roleMembers);
+ if (subjects.Count == 0)
+ return new List();
+
+ var end = CallEnd(call);
+ var windowStart = subjects.Values.Min(x => x.Start);
+ if (windowStart > end)
+ return new List();
+
+ var (statusLookup, baseTypes) = await GetStatusRulesAsync(departmentId, CustomStateTypes.Personnel);
+ var windowsByUser = await GetPersonnelDispatchWindowsAsync(departmentId, windowStart, end);
+ var logsFrom = Earliest(windowStart, EarliestDispatch(windowsByUser, subjects.Keys));
+ var logsByUser = GroupByUser(departmentId, await _actionLogsRepository.GetAllActionLogsInDateRangeAsync(departmentId, logsFrom, end));
+
+ return InferPersonnel(call.CallId, end, subjects, logsByUser, baseTypes, statusLookup, windowsByUser);
+ }
+
+ public async Task>> GetActionLogsForCallsAsync(int departmentId, IReadOnlyCollection calls)
+ {
+ var result = new Dictionary>();
+ var departmentCalls = calls?.Where(x => x != null && x.DepartmentId == departmentId).GroupBy(x => x.CallId).Select(g => g.First()).ToList() ?? new List();
+ if (departmentCalls.Count == 0)
+ return result;
+
+ foreach (var call in departmentCalls)
+ result[call.CallId] = new List();
+
+ var loggedFrom = departmentCalls.Min(x => x.LoggedOn);
+ var loggedTo = departmentCalls.Max(x => x.LoggedOn);
+
+ var direct = ((await _callDispatchesRepository.GetCallDispatchesForCallsInRangeAsync(departmentId, loggedFrom, loggedTo)) ?? Enumerable.Empty())
+ .Where(x => x != null && result.ContainsKey(x.CallId)).ToList();
+ var groupDispatches = ((await _callDispatchGroupRepository.GetCallDispatchGroupsForCallsInRangeAsync(departmentId, loggedFrom, loggedTo)) ?? Enumerable.Empty())
+ .Where(x => x != null && result.ContainsKey(x.CallId)).ToList();
+ var roleDispatches = ((await _callDispatchRoleRepository.GetCallDispatchRolesForCallsInRangeAsync(departmentId, loggedFrom, loggedTo)) ?? Enumerable.Empty())
+ .Where(x => x != null && result.ContainsKey(x.CallId)).ToList();
+
+ var groupMembers = await GetGroupMembersAsync(departmentId, groupDispatches.Select(x => x.DepartmentGroupId));
+ var roleMembers = roleDispatches.Count == 0 ? new Dictionary>()
+ : ((await _personnelRoleUsersRepository.GetAllRoleUsersForDepartmentAsync(departmentId)) ?? Enumerable.Empty())
+ .Where(x => x != null && x.DepartmentId == departmentId)
+ .GroupBy(x => x.PersonnelRoleId)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.UserId).ToList());
+
+ // Statuses can trail a call (a status set against it after it closed), hence the extra day on the read window.
+ var windowStart = loggedFrom;
+ var windowEnd = departmentCalls.Max(CallEnd).AddDays(1);
+
+ var (statusLookup, baseTypes) = await GetStatusRulesAsync(departmentId, CustomStateTypes.Personnel);
+ var windowsByUser = await GetPersonnelDispatchWindowsAsync(departmentId, windowStart, windowEnd);
+ var logsFrom = Earliest(windowStart, EarliestDispatch(windowsByUser, windowsByUser.Keys));
+
+ var logs = ((await _actionLogsRepository.GetAllActionLogsInDateRangeAsync(departmentId, logsFrom, windowEnd)) ?? Enumerable.Empty())
+ .Where(x => x != null && x.DepartmentId == departmentId).ToList();
+
+ foreach (var log in logs)
+ {
+ var linkedCallId = CallStatusLinkage.LinkedCallId(log.DestinationId, log.DestinationType);
+ if (linkedCallId.HasValue && result.TryGetValue(linkedCallId.Value, out var callLogs) && log.BelongsToCall(statusLookup))
+ callLogs.Add(log);
+ }
+
+ var logsByUser = GroupByUser(departmentId, logs);
+ foreach (var call in departmentCalls)
+ {
+ var subjects = BuildPersonnelSubjects(call, direct.Where(x => x.CallId == call.CallId), groupDispatches.Where(x => x.CallId == call.CallId),
+ roleDispatches.Where(x => x.CallId == call.CallId), groupMembers, roleMembers);
+ if (subjects.Count == 0)
+ continue;
+
+ result[call.CallId].AddRange(InferPersonnel(call.CallId, CallEnd(call), subjects, logsByUser, baseTypes, statusLookup, windowsByUser));
+ }
+
+ return result;
+ }
+
+ public async Task>> GetUnitDispatchesForCallsAsync(int departmentId, IReadOnlyCollection calls)
+ {
+ var result = new Dictionary>();
+ var departmentCalls = calls?.Where(x => x != null && x.DepartmentId == departmentId).ToList() ?? new List();
+ if (departmentCalls.Count == 0)
+ return result;
+
+ foreach (var call in departmentCalls)
+ result[call.CallId] = new List();
+
+ var dispatches = await _callDispatchUnitRepository.GetCallUnitDispatchesForCallsInRangeAsync(departmentId,
+ departmentCalls.Min(x => x.LoggedOn), departmentCalls.Max(x => x.LoggedOn)) ?? Enumerable.Empty();
+
+ foreach (var dispatch in dispatches.Where(x => x != null && result.ContainsKey(x.CallId)))
+ result[dispatch.CallId].Add(dispatch);
+
+ return result;
+ }
+
+ public async Task>> GetUnitStatesForCallsAsync(int departmentId, IReadOnlyCollection calls)
+ {
+ var result = new Dictionary>();
+ var departmentCalls = calls?.Where(x => x != null && x.DepartmentId == departmentId).GroupBy(x => x.CallId).Select(g => g.First()).ToList() ?? new List();
+ if (departmentCalls.Count == 0)
+ return result;
+
+ foreach (var call in departmentCalls)
+ result[call.CallId] = new List();
+
+ var dispatchesByCall = await GetUnitDispatchesForCallsAsync(departmentId, departmentCalls);
+ var units = (await _unitsRepository.GetAllUnitsByDepartmentIdAsync(departmentId))?.ToList() ?? new List();
+
+ // States can trail a call (a status set against it after it closed), hence the extra day on the read window.
+ var windowStart = departmentCalls.Min(x => x.LoggedOn);
+ var windowEnd = departmentCalls.Max(CallEnd).AddDays(1);
+
+ var (statusLookup, baseTypes) = await GetStatusRulesAsync(departmentId, CustomStateTypes.Unit);
+ var windowsByUnit = await GetUnitDispatchWindowsAsync(departmentId, windowStart, windowEnd);
+
+ var statesByUnit = new Dictionary>();
+ foreach (var unit in units)
+ {
+ var statesFrom = Earliest(windowStart, EarliestDispatch(windowsByUnit, new[] { unit.UnitId }));
+ var states = (await _unitStatesRepository.GetAllUnitStatesForUnitInDateRangeAsync(unit.UnitId, statesFrom, windowEnd))?.Where(x => x != null).ToList() ?? new List();
+ foreach (var state in states)
+ state.Unit ??= unit;
+
+ statesByUnit[unit.UnitId] = states;
+
+ foreach (var state in states)
+ {
+ var linkedCallId = CallStatusLinkage.LinkedCallId(state.DestinationId, state.DestinationType);
+ if (linkedCallId.HasValue && result.TryGetValue(linkedCallId.Value, out var callStates) && state.BelongsToCall(statusLookup))
+ callStates.Add(state);
+ }
+ }
+
+ foreach (var call in departmentCalls)
+ {
+ var end = CallEnd(call);
+
+ foreach (var dispatch in EarliestPerUnit(dispatchesByCall[call.CallId]))
+ {
+ if (!statesByUnit.TryGetValue(dispatch.UnitId, out var states))
+ continue;
+
+ var start = DispatchStart(dispatch.DispatchedOn, call);
+ if (start > end)
+ continue;
+
+ result[call.CallId].AddRange(CallStatusAttribution.InferUnitStates(call.CallId, start, end, states,
+ s => CallStatusLinkage.IsClearingUnitState(s.State, baseTypes), statusLookup, OtherDispatches(windowsByUnit, dispatch.UnitId, call.CallId, start, end)));
+ }
+ }
+
+ return result;
+ }
+ #endregion Read-time inference
+
+ #region Helpers
+ ///
+ /// The people a call was dispatched to, with when and how: individually (count from the dispatch) or through a paged
+ /// group or role (count from their first engaging status). Someone reached both ways counts as dispatched individually.
+ ///
+ private static Dictionary BuildPersonnelSubjects(Call call, IEnumerable dispatches,
+ IEnumerable groupDispatches, IEnumerable roleDispatches,
+ IReadOnlyDictionary> groupMembers, IReadOnlyDictionary> roleMembers)
+ {
+ var subjects = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ void Add(string userId, DateTime dispatchedOn, bool requireEngagement)
+ {
+ if (string.IsNullOrWhiteSpace(userId))
+ return;
+
+ var start = DispatchStart(dispatchedOn, call);
+ if (subjects.TryGetValue(userId, out var existing))
+ subjects[userId] = (existing.Start < start ? existing.Start : start, existing.RequireEngagement && requireEngagement);
+ else
+ subjects[userId] = (start, requireEngagement);
+ }
+
+ foreach (var dispatch in dispatches ?? Enumerable.Empty())
+ if (dispatch != null)
+ Add(dispatch.UserId, dispatch.DispatchedOn, false);
+
+ foreach (var groupDispatch in groupDispatches ?? Enumerable.Empty())
+ if (groupDispatch != null && groupMembers.TryGetValue(groupDispatch.DepartmentGroupId, out var members))
+ foreach (var member in members)
+ Add(member, groupDispatch.DispatchedOn, true);
+
+ foreach (var roleDispatch in roleDispatches ?? Enumerable.Empty())
+ if (roleDispatch != null && roleMembers.TryGetValue(roleDispatch.RoleId, out var members))
+ foreach (var member in members)
+ Add(member, roleDispatch.DispatchedOn, true);
+
+ return subjects;
+ }
+
+ private static List InferPersonnel(int callId, DateTime end, Dictionary subjects,
+ IReadOnlyDictionary> logsByUser, IReadOnlyDictionary baseTypes,
+ IReadOnlyDictionary statusLookup, Dictionary> windowsByUser)
+ {
+ var inferred = new List();
+ foreach (var subject in subjects)
+ {
+ if (subject.Value.Start > end || !logsByUser.TryGetValue(subject.Key, out var logs))
+ continue;
+
+ inferred.AddRange(CallStatusAttribution.InferActionLogs(callId, subject.Value.Start, end, subject.Value.RequireEngagement, logs,
+ l => CallStatusLinkage.IsClearingPersonnelStatus(l.ActionTypeId, baseTypes), statusLookup,
+ OtherDispatches(windowsByUser, subject.Key, callId, subject.Value.Start, end)));
+ }
+
+ return inferred;
+ }
+
+ private static Dictionary> GroupByUser(int departmentId, IEnumerable logs)
+ {
+ return (logs ?? Enumerable.Empty())
+ .Where(x => x != null && !string.IsNullOrWhiteSpace(x.UserId) && x.DepartmentId == departmentId)
+ .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
+ }
+
+ private async Task>> GetGroupMembersAsync(int departmentId, IEnumerable groupIds)
+ {
+ var members = new Dictionary>();
+ foreach (var groupId in (groupIds ?? Enumerable.Empty()).Distinct())
+ members[groupId] = ((await _departmentGroupMembersRepository.GetAllGroupMembersByGroupIdAsync(groupId)) ?? Enumerable.Empty())
+ .Where(x => x != null && x.DepartmentId == departmentId).Select(x => x.UserId).ToList();
+
+ return members;
+ }
+
+ private static void Link(UnitState state, int callId, StatusDestinationSources source)
+ {
+ state.DestinationId = callId;
+ state.DestinationType = (int)DestinationEntityTypes.Call;
+ state.DestinationSource = (int)source;
+ }
+
+ private static void Link(ActionLog actionLog, int callId, StatusDestinationSources source)
+ {
+ actionLog.DestinationId = callId;
+ actionLog.DestinationType = (int)DestinationEntityTypes.Call;
+ actionLog.DestinationSource = (int)source;
+ }
+
+ private async Task> GetBaseTypesAsync(int departmentId, CustomStateTypes type)
+ {
+ var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId);
+
+ return CallStatusLinkage.BuildBaseTypeMap(customStates, type);
+ }
+
+ ///
+ /// One status kind's raw-status lookup (built-in and custom statuses, for )
+ /// and custom base-type map (for the clearing rules).
+ ///
+ private async Task<(Dictionary StatusLookup, Dictionary BaseTypes)> GetStatusRulesAsync(int departmentId, CustomStateTypes type)
+ {
+ var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId);
+ var builtIn = type == CustomStateTypes.Unit ? _customStateService.GetDefaultUnitStatuses() : _customStateService.GetDefaultPersonStatuses();
+
+ return (CallStatusLinkage.BuildStatusLookup(builtIn, customStates, type), CallStatusLinkage.BuildBaseTypeMap(customStates, type));
+ }
+
+ ///
+ /// Each unit's dispatches made from before
+ /// to , the ones that can overlap a dispatch in that window.
+ ///
+ private async Task>> GetUnitDispatchWindowsAsync(int departmentId, DateTime from, DateTime to)
+ {
+ var dispatchedFrom = from.Subtract(CallStatusAttribution.OverlapLookback);
+ var windows = await _callDispatchUnitRepository.GetUnitDispatchWindowsAsync(departmentId, dispatchedFrom, to,
+ dispatchedFrom.Subtract(CallStatusAttribution.MaxOverlapCallAge));
+
+ return (windows ?? Enumerable.Empty()).Where(x => x != null)
+ .GroupBy(x => x.UnitId).ToDictionary(g => g.Key, g => g.ToList());
+ }
+
+ /// The personnel equivalent of , by user.
+ private async Task>> GetPersonnelDispatchWindowsAsync(int departmentId, DateTime from, DateTime to)
+ {
+ var dispatchedFrom = from.Subtract(CallStatusAttribution.OverlapLookback);
+ var windows = await _callDispatchesRepository.GetPersonnelDispatchWindowsAsync(departmentId, dispatchedFrom, to,
+ dispatchedFrom.Subtract(CallStatusAttribution.MaxOverlapCallAge));
+
+ return (windows ?? Enumerable.Empty()).Where(x => x != null && !string.IsNullOrWhiteSpace(x.UserId))
+ .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// The unit's/person's dispatches to calls other than this one that overlap its walk: made from
+ /// before their dispatch to it () up to its
+ /// , and not over before ; null when there are none. A dispatch outside
+ /// that window can't take a status this walk sees.
+ ///
+ private static List OtherDispatches(Dictionary> windows, TKey key, int callId, DateTime start, DateTime end)
+ {
+ if (!windows.TryGetValue(key, out var subjectWindows))
+ return null;
+
+ var from = start.Subtract(CallStatusAttribution.OverlapLookback);
+ var others = CallStatusAttribution.DispatchSpans(subjectWindows.Where(x => x.CallId != callId), DateTime.UtcNow)
+ .Where(x => x.Start >= from && x.Start <= end && x.End >= start).ToList();
+
+ return others.Count > 0 ? others : null;
+ }
+
+ /// The earliest dispatch among the given units'/people's windows, or null when they have none.
+ private static DateTime? EarliestDispatch(Dictionary> windows, IEnumerable keys)
+ {
+ DateTime? earliest = null;
+ foreach (var key in keys)
+ {
+ if (!windows.TryGetValue(key, out var subjectWindows))
+ continue;
+
+ foreach (var span in CallStatusAttribution.DispatchSpans(subjectWindows, DateTime.UtcNow))
+ earliest = earliest.HasValue && earliest.Value <= span.Start ? earliest : span.Start;
+ }
+
+ return earliest;
+ }
+
+ private static DateTime Earliest(DateTime value, DateTime? other)
+ {
+ return other.HasValue && other.Value < value ? other.Value : value;
+ }
+
+ private async Task IsOpenCallAsync(int departmentId, int callId)
+ {
+ var call = await GetDepartmentCallAsync(departmentId, callId);
+
+ return call != null && call.State == (int)CallStates.Active;
+ }
+
+ private async Task GetDepartmentCallAsync(int departmentId, int callId)
+ {
+ if (callId <= 0)
+ return null;
+
+ var call = await _callsRepository.GetByIdAsync(callId);
+
+ return call != null && call.DepartmentId == departmentId && !call.IsDeleted ? call : null;
+ }
+
+ private static DateTime CallEnd(Call call)
+ {
+ return call.ClosedOn ?? DateTime.UtcNow;
+ }
+
+ private static DateTime DispatchStart(DateTime dispatchedOn, Call call)
+ {
+ return dispatchedOn == default(DateTime) ? call.LoggedOn : dispatchedOn;
+ }
+
+ private static IEnumerable EarliestPerUnit(IEnumerable dispatches)
+ {
+ return (dispatches ?? Enumerable.Empty()).Where(x => x != null)
+ .GroupBy(x => x.UnitId).Select(g => g.OrderBy(x => x.DispatchedOn).First());
+ }
+
+ private async Task PopulateUnitsAsync(int departmentId, List states)
+ {
+ if (states.All(x => x.Unit != null))
+ return;
+
+ var units = (await _unitsRepository.GetAllUnitsByDepartmentIdAsync(departmentId))?.ToDictionary(x => x.UnitId) ?? new Dictionary();
+ foreach (var state in states.Where(x => x.Unit == null))
+ {
+ if (units.TryGetValue(state.UnitId, out var unit))
+ state.Unit = unit;
+ }
+ }
+ #endregion Helpers
+ }
+}
diff --git a/Core/Resgrid.Services/CustomStateService.cs b/Core/Resgrid.Services/CustomStateService.cs
index b6ddae52c..2d07c061e 100644
--- a/Core/Resgrid.Services/CustomStateService.cs
+++ b/Core/Resgrid.Services/CustomStateService.cs
@@ -180,6 +180,11 @@ public async Task GetCustomDetailByIdAsync(int detailId)
public async Task SaveDetailAsync(CustomStateDetail customStateDetail, int departmentId, CancellationToken cancellationToken = default(CancellationToken))
{
var saved = await _customStateDetailRepository.SaveOrUpdateAsync(customStateDetail, cancellationToken);
+
+ // Details are served from the department's cached custom states; without this an edited status keeps its
+ // old text, colour and destination setting in the apps and call records for up to the 7 day cache length.
+ await _cacheProvider.RemoveAsync(string.Format(CacheKey, departmentId));
+
_eventAggregator.SendMessage(new DepartmentSettingsUpdateEvent() { DepartmentId = departmentId });
return saved;
@@ -251,7 +256,7 @@ public async Task GetCustomDetailForDepartmentAsync(int depar
if (states != null && states.Count > 0)
{
- var detail = states.Select(state => state.Details.FirstOrDefault(x => x.CustomStateDetailId == detailId)).FirstOrDefault(detail => detail != null);
+ var detail = states.Where(state => state.Details != null).Select(state => state.Details.FirstOrDefault(x => x.CustomStateDetailId == detailId)).FirstOrDefault(detail => detail != null);
return detail;
}
@@ -337,7 +342,7 @@ public List GetDefaultUnitStatuses()
{
List details = new List();
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UnitStateTypes.Responding, ButtonText = "Responding", ButtonColor = "#32db64", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Calls });
- details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonText = "Available", ButtonColor = "#d1dade", TextColor = "5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Stations });
+ details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonText = "Available", ButtonColor = "#d1dade", TextColor = "#5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Stations });
//details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UnitStateTypes.Unavailable, ButtonText = "Unavailable", ButtonColor = "" });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UnitStateTypes.Committed, ButtonText = "Committed", ButtonColor = "#50b8de", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.CallsAndStations });
//details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UnitStateTypes.Delayed, ButtonText = "Delayed", ButtonColor = "" });
@@ -360,8 +365,8 @@ public List GetDefaultPersonStatuses()
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.Responding, ButtonText = "Responding", ButtonColor = "#449d44", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.CallsAndStations });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.NotResponding, ButtonText = "Not Responding", ButtonColor = "#ed5565", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.OnScene, ButtonText = "On Scene", ButtonColor = "#262626", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Calls });
- details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.StandingBy, ButtonText = "Standing By", ButtonColor = "#d1dade", TextColor = "5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Stations });
- details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.AvailableStation, ButtonText = "Available Station", ButtonColor = "#d1dade", TextColor = "5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Stations });
+ details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.StandingBy, ButtonText = "Standing By", ButtonColor = "#d1dade", TextColor = "#5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Stations });
+ details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.AvailableStation, ButtonText = "Available Station", ButtonColor = "#d1dade", TextColor = "#5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Stations });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.RespondingToStation, ButtonText = "Responding to Station", ButtonColor = "#449d44", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Stations });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.RespondingToScene, ButtonText = "Responding to Scene", ButtonColor = "#449d44", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.Calls });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)ActionTypes.OnUnit, ButtonText = "On Unit", ButtonColor = "#449d44", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional, DetailType = (int)CustomStateDetailTypes.CallsAndStations });
@@ -372,7 +377,7 @@ public List GetDefaultPersonStatuses()
public List GetDefaultPersonStaffings()
{
List details = new List();
- details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UserStateTypes.Available, ButtonText = "Available", ButtonColor = "#d1dade", TextColor = "5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional });
+ details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UserStateTypes.Available, ButtonText = "Available", ButtonColor = "#d1dade", TextColor = "#5E5E5E", NoteType = (int)CustomStateNoteTypes.Optional });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UserStateTypes.Delayed, ButtonText = "Delayed", ButtonColor = "#f8ac59", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UserStateTypes.Unavailable, ButtonText = "Unavailable", ButtonColor = "#ed5565", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional });
details.Add(new CustomStateDetail() { CustomStateDetailId = (int)UserStateTypes.Committed, ButtonText = "Committed", ButtonColor = "#23c6c8", TextColor = "#ffffff", NoteType = (int)CustomStateNoteTypes.Optional });
diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.cs
index 21841bc32..8f8d1bb7f 100644
--- a/Core/Resgrid.Services/Invoicing/InvoicingService.cs
+++ b/Core/Resgrid.Services/Invoicing/InvoicingService.cs
@@ -462,6 +462,16 @@ public Task SaveInvoiceLineItemsAsync(string invoiceId, int departmentI
{
var existingLine = !string.IsNullOrWhiteSpace(line.InvoiceLineItemId) && current.TryGetValue(line.InvoiceLineItemId, out var found) ? found : null;
if (existingLine == null) line.InvoiceLineItemId = null;
+ // Provenance is the server's, not the editor's: an existing line keeps what it was generated with.
+ if (existingLine != null)
+ {
+ line.TimeSource = existingLine.TimeSource;
+ line.DeploymentTimeReportId ??= existingLine.DeploymentTimeReportId;
+ }
+ else if (line.TimeSource.HasValue && !Enum.IsDefined(typeof(InvoiceLineTimeSources), line.TimeSource.Value))
+ {
+ line.TimeSource = null;
+ }
line.InvoiceId = invoiceId;
line.DepartmentId = departmentId;
line.Amount = RoundMoney(line.Quantity * line.UnitRate);
@@ -495,6 +505,7 @@ public async Task> GenerateLineItemsFromCallAsync(int call
var lines = new List();
var callLabel = string.IsNullOrWhiteSpace(call.Number) ? call.CallId.ToString() : call.Number;
var states = (await _unitsService.GetUnitStatesForCallAsync(departmentId, callId))?.Where(x => x != null).OrderBy(x => x.Timestamp).ToList() ?? new List();
+ var customBaseTypes = await _unitsService.GetCustomUnitStateBaseTypesAsync(departmentId);
var fallbackEnd = call.ClosedOn ?? DateTime.UtcNow;
foreach (var item in card.Items.Where(x => x.Active && !x.IsDeleted).OrderBy(x => x.SortOrder))
@@ -507,7 +518,8 @@ public async Task> GenerateLineItemsFromCallAsync(int call
var unit = await _unitsService.GetUnitByIdAsync(group.Key);
if (unit == null) continue;
if (!string.IsNullOrWhiteSpace(item.UnitTypeFilter) && !string.Equals(unit.Type, item.UnitTypeFilter, StringComparison.OrdinalIgnoreCase)) continue;
- var minutes = OnSceneMinutes(group.ToList(), call.LoggedOn, fallbackEnd);
+ var window = OnSceneWindow(group.ToList(), call.LoggedOn, fallbackEnd, customBaseTypes);
+ var minutes = window.Minutes;
if (minutes <= 0) continue;
var billable = ApplyRounding(minutes, item);
lines.Add(new InvoiceLineItem
@@ -518,7 +530,8 @@ public async Task> GenerateLineItemsFromCallAsync(int call
Quantity = billable,
UnitRate = item.Rate,
Amount = Math.Max(RoundMoney(billable * item.Rate), item.MinimumCharge.HasValue ? RoundMoney(item.MinimumCharge.Value) : 0m),
- Taxable = item.Taxable
+ Taxable = item.Taxable,
+ TimeSource = (int)window.Source
});
}
break;
@@ -914,25 +927,43 @@ public static int DeriveStatus(Invoice invoice, DateTime now)
return invoice.DueOn.HasValue && invoice.DueOn.Value < now ? (int)InvoiceStatus.Overdue : (int)InvoiceStatus.Sent;
}
- /// Minutes between the unit's first OnScene state on the call and its next state; falls back to the call window when the unit never reported OnScene (plan decision 9).
- public static int OnSceneMinutes(IReadOnlyList unitStates, DateTime callLoggedOn, DateTime fallbackEnd)
+ /// Minutes between the unit's first OnScene state on the call and its next state; falls back to the call window when the unit never reported OnScene (plan decision 9). Custom unit statuses count as OnScene through their base type.
+ public static int OnSceneMinutes(IReadOnlyList unitStates, DateTime callLoggedOn, DateTime fallbackEnd, IReadOnlyDictionary customBaseTypes = null)
{
+ return OnSceneWindow(unitStates, callLoggedOn, fallbackEnd, customBaseTypes).Minutes;
+ }
+
+ ///
+ /// plus where the time came from: the unit's own statuses, statuses Resgrid linked to the
+ /// call or inferred for it (the least certain of the start and end status wins), or the call window.
+ ///
+ public static (int Minutes, InvoiceLineTimeSources Source) OnSceneWindow(IReadOnlyList unitStates, DateTime callLoggedOn, DateTime fallbackEnd, IReadOnlyDictionary customBaseTypes = null)
+ {
+ bool IsOnScene(UnitState state) => CallStatusLinkage.ResolveUnitStateKind(state.State, customBaseTypes) == UnitStateTypes.OnScene;
+
var ordered = unitStates.OrderBy(x => x.Timestamp).ToList();
- var onScene = ordered.FirstOrDefault(x => x.State == (int)UnitStateTypes.OnScene);
+ var onScene = ordered.FirstOrDefault(IsOnScene);
DateTime start, end;
+ InvoiceLineTimeSources source;
if (onScene != null)
{
start = onScene.Timestamp;
- var next = ordered.FirstOrDefault(x => x.Timestamp > onScene.Timestamp && x.State != (int)UnitStateTypes.OnScene);
+ var next = ordered.FirstOrDefault(x => x.Timestamp > onScene.Timestamp && !IsOnScene(x));
end = next?.Timestamp ?? fallbackEnd;
+
+ var used = next != null ? new[] { onScene, next } : new[] { onScene };
+ source = used.Any(x => CallStatusAttribution.IsInferred(x.DestinationSource)) ? InvoiceLineTimeSources.InferredStatus
+ : used.Any(x => CallStatusAttribution.IsAutoLinked(x.DestinationSource)) ? InvoiceLineTimeSources.AutoLinkedStatus
+ : InvoiceLineTimeSources.UnitStatus;
}
else
{
start = callLoggedOn;
end = fallbackEnd;
+ source = InvoiceLineTimeSources.CallWindow;
}
var minutes = (int)Math.Ceiling((end - start).TotalMinutes);
- return minutes < 0 ? 0 : minutes;
+ return (minutes < 0 ? 0 : minutes, source);
}
/// Billable hours after the item's minimum and rounding increment (plan decision 9).
diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs
index 3780d720e..b8ede1016 100644
--- a/Core/Resgrid.Services/Records/IncidentReportsService.cs
+++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs
@@ -987,6 +987,9 @@ private async Task> BuildUnitsFromCallAsync(RmsIncidentRep
return result;
var states = (await _unitsService.GetUnitStatesForCallAsync(report.DepartmentId, call.CallId) ?? new List()).OrderBy(s => s.Timestamp).ToList();
+ // A department's custom unit statuses store their detail id, not a UnitStateTypes value; they resolve
+ // through the status's base type so custom-status departments still get enroute/on-scene/clear times.
+ var customBaseTypes = await _unitsService.GetCustomUnitStateBaseTypesAsync(report.DepartmentId);
var ordinal = 0;
foreach (var dispatch in dispatches)
{
@@ -995,9 +998,19 @@ private async Task> BuildUnitsFromCallAsync(RmsIncidentRep
continue;
var unitStates = states.Where(s => s.UnitId == dispatch.UnitId).ToList();
- DateTime? At(params UnitStateTypes[] kinds) => unitStates.FirstOrDefault(s => kinds.Contains((UnitStateTypes)s.State))?.Timestamp;
- var onScene = At(UnitStateTypes.OnScene);
- var cleared = unitStates.Where(s => onScene.HasValue && s.Timestamp >= onScene.Value && ((UnitStateTypes)s.State == UnitStateTypes.Released || (UnitStateTypes)s.State == UnitStateTypes.Returning || (UnitStateTypes)s.State == UnitStateTypes.Available)).Select(s => (DateTime?)s.Timestamp).FirstOrDefault();
+ UnitStateTypes? KindOf(UnitState s) => CallStatusLinkage.ResolveUnitStateKind(s.State, customBaseTypes);
+ UnitState First(params UnitStateTypes[] kinds) => unitStates.FirstOrDefault(s => KindOf(s) is UnitStateTypes kind && kinds.Contains(kind));
+ var enrouteState = First(UnitStateTypes.Responding, UnitStateTypes.Enroute);
+ var onSceneState = First(UnitStateTypes.OnScene);
+ var stagingState = First(UnitStateTypes.Staging);
+ var canceledState = onSceneState != null ? null : First(UnitStateTypes.Cancelled);
+ var onScene = onSceneState?.Timestamp;
+ var clearedState = unitStates.FirstOrDefault(s => onScene.HasValue && s.Timestamp >= onScene.Value && (KindOf(s) == UnitStateTypes.Released || KindOf(s) == UnitStateTypes.Returning || KindOf(s) == UnitStateTypes.Available));
+ var cleared = clearedState?.Timestamp;
+ // A time taken from a status Resgrid linked to the call (auto-linked on save, or inferred for a dispatched unit)
+ // is Derived provenance, so the reviewer sees it was not the unit naming the call.
+ var timeStates = new[] { enrouteState, onSceneState, stagingState, canceledState, clearedState };
+ var anyServerLinked = timeStates.Any(s => s != null && (CallStatusAttribution.IsAutoLinked(s.DestinationSource) || CallStatusAttribution.IsInferred(s.DestinationSource)));
var response = new RmsUnitResponse
{
@@ -1010,13 +1023,13 @@ private async Task> BuildUnitsFromCallAsync(RmsIncidentRep
UnitTypeSnapshot = unit.Type,
StationGroupIdSnapshot = unit.StationGroupId,
DispatchedOn = dispatch.DispatchedOn,
- EnrouteOn = At(UnitStateTypes.Responding, UnitStateTypes.Enroute),
+ EnrouteOn = enrouteState?.Timestamp,
OnSceneOn = onScene,
- StagingOn = At(UnitStateTypes.Staging),
- CanceledEnrouteOn = onScene.HasValue ? null : At(UnitStateTypes.Cancelled),
+ StagingOn = stagingState?.Timestamp,
+ CanceledEnrouteOn = canceledState?.Timestamp,
ClearedOn = cleared,
ResponseMode = "EMERGENT",
- TimesSourceKind = (int)RmsSourceKind.App,
+ TimesSourceKind = anyServerLinked ? (int)RmsSourceKind.Derived : (int)RmsSourceKind.App,
Ordinal = ordinal++,
CreatedOn = now,
ModifiedOn = now,
@@ -1025,16 +1038,34 @@ private async Task> BuildUnitsFromCallAsync(RmsIncidentRep
result.Add(response);
facts.Add(Fact(report, NerisFactKeys.UnitTime(unit.UnitId, "dispatch"), RmsSourceKind.Dispatch, "Calls", "CallDispatchUnit", dispatch.CallDispatchUnitId.ToString(), Iso(dispatch.DispatchedOn), dispatch.DispatchedOn, now));
- foreach (var (field, value) in new[] { ("enroute_to_scene", response.EnrouteOn), ("on_scene", response.OnSceneOn), ("staging", response.StagingOn), ("canceled_enroute", response.CanceledEnrouteOn), ("unit_clear", response.ClearedOn) })
+ foreach (var (field, state) in new[] { ("enroute_to_scene", enrouteState), ("on_scene", onSceneState), ("staging", stagingState), ("canceled_enroute", canceledState), ("unit_clear", clearedState) })
{
- if (value.HasValue)
- facts.Add(Fact(report, NerisFactKeys.UnitTime(unit.UnitId, field), RmsSourceKind.App, "UnitStates", "Unit", unit.UnitId.ToString(), Iso(value), value, now));
+ if (state == null)
+ continue;
+
+ var (kind, system) = UnitTimeProvenance(state);
+ facts.Add(Fact(report, NerisFactKeys.UnitTime(unit.UnitId, field), kind, system, "Unit", unit.UnitId.ToString(), Iso(state.Timestamp), state.Timestamp, now));
}
}
return result;
}
+ ///
+ /// Provenance of a unit time taken from a unit state: App when the unit linked the status to the call itself, Derived
+ /// (with the reason in the source system) when Resgrid auto-linked it on save or inferred it for a dispatched unit.
+ ///
+ private static (RmsSourceKind Kind, string System) UnitTimeProvenance(UnitState state)
+ {
+ if (CallStatusAttribution.IsInferred(state.DestinationSource))
+ return (RmsSourceKind.Derived, "UnitStates (inferred)");
+
+ if (CallStatusAttribution.IsAutoLinked(state.DestinationSource))
+ return (RmsSourceKind.Derived, "UnitStates (auto-linked)");
+
+ return (RmsSourceKind.App, "UnitStates");
+ }
+
private static RmsSourceFact Fact(RmsIncidentReport report, string key, RmsSourceKind kind, string system, string entityType, string entityId, string value, DateTime? sourceTime, DateTime now)
{
return new RmsSourceFact
@@ -1192,8 +1223,11 @@ private async Task> ReplaceUnitsAsync(RmsIncidentReport re
Correct(facts, NerisFactKeys.UnitTime(unit.UnitId, "staging"), Iso(row.StagingOn), userId, now);
Correct(facts, NerisFactKeys.UnitTime(unit.UnitId, "canceled_enroute"), Iso(row.CanceledEnrouteOn), userId, now);
Correct(facts, NerisFactKeys.UnitTime(unit.UnitId, "unit_clear"), Iso(row.ClearedOn), userId, now);
- // Provenance survives an edit: the fact keeps its App/Dispatch origin, the row shows the source that still applies.
- row.TimesSourceKind = facts.Any(f => f.FactKey.StartsWith($"unit.{unit.UnitId}.", StringComparison.Ordinal) && f.CorrectedOn == null) ? (int)RmsSourceKind.App : (int)RmsSourceKind.None;
+ // Provenance survives an edit: the fact keeps its App/Dispatch/Derived origin, the row shows the source that still
+ // applies (Derived while any uncorrected time rests on a status Resgrid linked to the call).
+ var uncorrected = facts.Where(f => f.FactKey.StartsWith($"unit.{unit.UnitId}.", StringComparison.Ordinal) && f.CorrectedOn == null).ToList();
+ row.TimesSourceKind = uncorrected.Count == 0 ? (int)RmsSourceKind.None
+ : uncorrected.Any(f => f.SourceKind == (int)RmsSourceKind.Derived) ? (int)RmsSourceKind.Derived : (int)RmsSourceKind.App;
}
await _units.InsertAsync(row, cancellationToken, true);
result.Add(row);
diff --git a/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs b/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs
index 35d520118..869a3bacd 100644
--- a/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs
+++ b/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs
@@ -88,7 +88,10 @@ public async Task RenderAsync(int departmentId, string vie
Add(f, "C", "IncidentTypeCode", true, mappedType == null ? call.Type : call.Type + " → " + mappedType, mappedType == null ? Calls : Crosswalk, NerisFactKeys.IncidentType, "incident_types",
report != null ? report.Types.Count > 0 : (bool?)null, mappedType == null ? NfirsLegacyFieldStatus.Missing : NfirsLegacyFieldStatus.Populated);
Add(f, "E1", "AlarmDateTime", true, Utc(call.LoggedOn), Calls, NerisFactKeys.CallCreate, "dispatch", FactPopulated(report, NerisFactKeys.CallCreate));
- Add(f, "E1", "ArrivalDateTime", false, Utc(firstOnScene?.Timestamp), UnitStates, firstOnScene == null ? null : NerisFactKeys.UnitTime(firstOnScene.UnitId, "on_scene"), "unit_responses",
+ // The arrival source says when the on-scene status reached the call through Resgrid rather than the unit naming it.
+ var arrivalSource = CallStatusAttribution.IsInferred(firstOnScene?.DestinationSource) ? UnitStates + " (inferred)"
+ : CallStatusAttribution.IsAutoLinked(firstOnScene?.DestinationSource) ? UnitStates + " (auto-linked)" : UnitStates;
+ Add(f, "E1", "ArrivalDateTime", false, Utc(firstOnScene?.Timestamp), arrivalSource, firstOnScene == null ? null : NerisFactKeys.UnitTime(firstOnScene.UnitId, "on_scene"), "unit_responses",
report != null ? report.Units.Any(u => u.OnSceneOn.HasValue) : (bool?)null);
Add(f, "E1", "ControlledDateTime", false, Utc(times?.LastBenchmarkCompletedOn), IncidentCommand, NerisFactKeys.CommandLastBenchmark, "dispatch", FactPopulated(report, NerisFactKeys.CommandLastBenchmark));
Add(f, "E1", "LastUnitClearedDateTime", false, Utc(call.ClosedOn ?? times?.CommandClosedOn), call.ClosedOn.HasValue ? Calls : IncidentCommand, NerisFactKeys.IncidentClear, "dispatch", FactPopulated(report, NerisFactKeys.IncidentClear));
@@ -145,7 +148,9 @@ private async Task TryGetFirstOnSceneAsync(int departmentId, int call
try
{
var states = await _units.GetUnitStatesForCallAsync(departmentId, callId) ?? new List();
- return states.Where(s => s != null && s.State == (int)UnitStateTypes.OnScene).OrderBy(s => s.Timestamp).FirstOrDefault();
+ // Custom unit statuses resolve through their base type (see CallStatusLinkage.ResolveUnitStateKind).
+ var customBaseTypes = await _units.GetCustomUnitStateBaseTypesAsync(departmentId);
+ return states.Where(s => s != null && CallStatusLinkage.ResolveUnitStateKind(s.State, customBaseTypes) == UnitStateTypes.OnScene).OrderBy(s => s.Timestamp).FirstOrDefault();
}
catch (Exception ex)
{
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index d04131dc5..ce2ba1840 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -135,6 +135,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs
index 42ffee9f8..ecf12a632 100644
--- a/Core/Resgrid.Services/UnitsService.cs
+++ b/Core/Resgrid.Services/UnitsService.cs
@@ -40,6 +40,7 @@ public class UnitsService : IUnitsService
// Lazy: the Records cutover guard (RMS plan section 4.1) is consulted only on a legacy UnitLog write.
private readonly Lazy _recordsCutoverService;
+ private readonly ICallStatusAttributionService _callStatusAttributionService;
private readonly Lazy _searchProjections;
@@ -50,9 +51,11 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit
IUnitLocationsDocRepository unitLocationsDocRepository, Lazy unitLocationsMongoRepository,
IUnitActiveRolesRepository unitActiveRolesRepository,
IDepartmentGroupsService departmentGroupsService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService,
- Lazy protectedWriteService, Lazy recordsCutoverService, IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null, Lazy searchProjections = null)
+ Lazy protectedWriteService, Lazy recordsCutoverService,
+ ICallStatusAttributionService callStatusAttributionService, IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null, Lazy searchProjections = null)
{
_recordsCutoverService = recordsCutoverService;
+ _callStatusAttributionService = callStatusAttributionService;
if ((inventoryStore == null) != (inventoryUnitOfWork == null))
throw new ArgumentException("Inventory storage and its unit of work must be supplied together.", nameof(inventoryStore));
_inventoryStore = inventoryStore; _inventoryUnitOfWork = inventoryUnitOfWork;
@@ -384,6 +387,9 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type
}
}
+ // No destination is sent on this path: link the state to the call the unit is working, if there is one.
+ await _callStatusAttributionService.AttributeUnitStateAsync(state, previousState, departmentId);
+
var saved = await _unitStatesRepository.SaveOrUpdateAsync(state, cancellationToken);
// ADP write safety net (plan 4.2/19.2), catalog v2: unit-state note, geolocation and the
@@ -427,6 +433,10 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type
}
}
+ // Every client and server path lands here: a state sent without a destination is linked to the call the unit
+ // is working (previous state's open call, or its one open dispatch), and a sent destination is marked explicit.
+ await _callStatusAttributionService.AttributeUnitStateAsync(state, previousState, departmentId);
+
var saved = await _unitStatesRepository.SaveOrUpdateAsync(state, cancellationToken);
// ADP write safety net (plan 4.2/19.2), catalog v2: unit-state note, geolocation and the
@@ -615,33 +625,27 @@ public async Task> GetAllUnitsForGroupAsync(int groupId)
public async Task> GetUnitStatesForCallAsync(int departmentId, int callId)
{
- List callEnabledStates = new List();
- var states = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId);
+ // Every state that names the call counts (Cancelled, Delayed, a custom status whose destination
+ // setting was later changed, ...): the call record is an audit trail, not a filtered view. Only
+ // untyped legacy rows whose status targets stations are dropped, see CallStatusLinkage.
+ var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId);
+ var statusLookup = CallStatusLinkage.BuildStatusLookup(_customStateService.GetDefaultUnitStatuses(), customStates, CustomStateTypes.Unit);
- callEnabledStates.Add((int)UnitStateTypes.Enroute);
- callEnabledStates.Add((int)UnitStateTypes.Committed);
- callEnabledStates.Add((int)UnitStateTypes.Manual);
- callEnabledStates.Add((int)UnitStateTypes.OnScene);
- callEnabledStates.Add((int)UnitStateTypes.Responding);
- callEnabledStates.Add((int)UnitStateTypes.Returning);
- callEnabledStates.Add((int)UnitStateTypes.Released);
- callEnabledStates.Add((int)UnitStateTypes.Staging);
- callEnabledStates.Add((int)UnitStateTypes.Available);
+ var unitStates = (await _unitStatesRepository.GetAllStatesByCallIdAsync(departmentId, callId) ?? Enumerable.Empty())
+ .Where(us => us.BelongsToCall(statusLookup))
+ .ToList();
- var nonNullStates = from state in states
- where state.Details != null
- select state;
+ // Plus what the call's dispatched units did while working it without naming the call (flagged Inferred).
+ unitStates.AddRange(await _callStatusAttributionService.GetInferredUnitStatesForCallAsync(departmentId, callId) ?? new List