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()); - callEnabledStates.AddRange(from state in nonNullStates - from detail in state.Details - where detail.DetailType.SupportsCalls() - select detail.CustomStateDetailId); + return unitStates; + } - var unitStates = (from us in await _unitStatesRepository.GetAllStatesByCallIdAsync(callId) - where callEnabledStates.Contains(us.State) - select us).ToList(); + public async Task> GetCustomUnitStateBaseTypesAsync(int departmentId) + { + var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId); - return unitStates; + return CallStatusLinkage.BuildUnitBaseTypeMap(customStates); } public async Task AddUnitLocationAsync(UnitsLocation location, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0228_AddStatusDestinationSource.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0228_AddStatusDestinationSource.cs new file mode 100644 index 000000000..3969bc722 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0228_AddStatusDestinationSource.cs @@ -0,0 +1,26 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records how a unit state's or personnel status's destination was decided (StatusDestinationSources): chosen by the + /// client, or attributed by the server when the status arrived without one (carried forward from the previous status's + /// still-open call, the one open call the unit/person is dispatched to, or the unit a person is riding). Null on rows + /// written before this migration. + /// + [Migration(228)] + public class M0228_AddStatusDestinationSource : Migration + { + public override void Up() + { + Execute.Sql("IF COL_LENGTH('UnitStates', 'DestinationSource') IS NULL ALTER TABLE [UnitStates] ADD [DestinationSource] int NULL;"); + Execute.Sql("IF COL_LENGTH('ActionLogs', 'DestinationSource') IS NULL ALTER TABLE [ActionLogs] ADD [DestinationSource] int NULL;"); + } + + public override void Down() + { + Execute.Sql("IF COL_LENGTH('ActionLogs', 'DestinationSource') IS NOT NULL ALTER TABLE [ActionLogs] DROP COLUMN [DestinationSource];"); + Execute.Sql("IF COL_LENGTH('UnitStates', 'DestinationSource') IS NOT NULL ALTER TABLE [UnitStates] DROP COLUMN [DestinationSource];"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0229_BackfillExplicitStatusDestinations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0229_BackfillExplicitStatusDestinations.cs new file mode 100644 index 000000000..ccc8c8dd8 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0229_BackfillExplicitStatusDestinations.cs @@ -0,0 +1,72 @@ +using System; +using System.Data; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Every unit state and personnel status that already carries a destination was written before server-side call + /// attribution existed, so its destination is one the sender chose: mark it Explicit (StatusDestinationSources 1). + /// UnitStates and ActionLogs are among the largest tables, so the update walks the primary key in ranges, each range its + /// own short autocommitted statement (TransactionBehavior.None), and only touches rows still null — a re-run after a + /// timeout or partial apply resumes where it stopped. + /// + [Migration(229, TransactionBehavior.None)] + public class M0229_BackfillExplicitStatusDestinations : Migration + { + private const int RangeSize = 100000; + + public override void Up() + { + Execute.WithConnection((connection, transaction) => + { + Backfill(connection, transaction, "UnitStates", "UnitStateId"); + Backfill(connection, transaction, "ActionLogs", "ActionLogId"); + }); + } + + public override void Down() + { + // Nothing to undo: M0228's Down drops the column. + } + + private static void Backfill(IDbConnection connection, IDbTransaction transaction, string table, string key) + { + long min, max; + using (var bounds = connection.CreateCommand()) + { + bounds.Transaction = transaction; + bounds.CommandTimeout = 300; + // Unfiltered: the key bounds come off the clustered index, while the filtered form scans the table (no index covers + // DestinationSource) on every run; each range update applies the filter. + bounds.CommandText = $"SELECT MIN([{key}]), MAX([{key}]) FROM [{table}]"; + using var reader = bounds.ExecuteReader(); + if (!reader.Read() || reader.IsDBNull(0)) + return; + + min = Convert.ToInt64(reader.GetValue(0)); + max = Convert.ToInt64(reader.GetValue(1)); + } + + for (var from = min; from <= max; from += RangeSize) + { + using var update = connection.CreateCommand(); + update.Transaction = transaction; + update.CommandTimeout = 300; + update.CommandText = $"UPDATE [{table}] SET [DestinationSource] = 1 WHERE [{key}] >= @from AND [{key}] < @to AND [DestinationId] > 0 AND [DestinationSource] IS NULL"; + AddParameter(update, "@from", from); + AddParameter(update, "@to", from + RangeSize); + update.ExecuteNonQuery(); + } + } + + private static void AddParameter(IDbCommand command, string name, long value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.Int64; + parameter.Value = value; + command.Parameters.Add(parameter); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0230_AddInvoiceLineTimeSource.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0230_AddInvoiceLineTimeSource.cs new file mode 100644 index 000000000..838a726a1 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0230_AddInvoiceLineTimeSource.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Where a generated hourly unit line's on-scene time came from (InvoiceLineTimeSources): a unit status the unit linked + /// to the call, one Resgrid linked or inferred, or the call's whole logged-to-closed window. Shown to the clerk on the + /// invoice editor and view, never on the customer copy. Null for manual lines and lines written before this migration. + /// + [Migration(230)] + public class M0230_AddInvoiceLineTimeSource : Migration + { + public override void Up() + { + Execute.Sql("IF COL_LENGTH('InvoiceLineItems', 'TimeSource') IS NULL ALTER TABLE [InvoiceLineItems] ADD [TimeSource] int NULL;"); + } + + public override void Down() + { + Execute.Sql("IF COL_LENGTH('InvoiceLineItems', 'TimeSource') IS NOT NULL ALTER TABLE [InvoiceLineItems] DROP COLUMN [TimeSource];"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0228_AddStatusDestinationSourcePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0228_AddStatusDestinationSourcePg.cs new file mode 100644 index 000000000..f5de3ef63 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0228_AddStatusDestinationSourcePg.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records how a unit state's or personnel status's destination was decided (see the SQL Server M0228). + /// + [Migration(228)] + public class M0228_AddStatusDestinationSourcePg : Migration + { + public override void Up() + { + Execute.Sql("ALTER TABLE unitstates ADD COLUMN IF NOT EXISTS destinationsource integer NULL;"); + Execute.Sql("ALTER TABLE actionlogs ADD COLUMN IF NOT EXISTS destinationsource integer NULL;"); + } + + public override void Down() + { + Execute.Sql("ALTER TABLE actionlogs DROP COLUMN IF EXISTS destinationsource;"); + Execute.Sql("ALTER TABLE unitstates DROP COLUMN IF EXISTS destinationsource;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0229_BackfillExplicitStatusDestinationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0229_BackfillExplicitStatusDestinationsPg.cs new file mode 100644 index 000000000..9d526bd2e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0229_BackfillExplicitStatusDestinationsPg.cs @@ -0,0 +1,68 @@ +using System; +using System.Data; +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Marks every unit state and personnel status that already carries a destination as Explicit (see the SQL Server M0229): + /// primary-key ranges, each its own autocommitted statement, resumable on a re-run. + /// + [Migration(229, TransactionBehavior.None)] + public class M0229_BackfillExplicitStatusDestinationsPg : Migration + { + private const int RangeSize = 100000; + + public override void Up() + { + Execute.WithConnection((connection, transaction) => + { + Backfill(connection, transaction, "unitstates", "unitstateid"); + Backfill(connection, transaction, "actionlogs", "actionlogid"); + }); + } + + public override void Down() + { + // Nothing to undo: M0228's Down drops the column. + } + + private static void Backfill(IDbConnection connection, IDbTransaction transaction, string table, string key) + { + long min, max; + using (var bounds = connection.CreateCommand()) + { + bounds.Transaction = transaction; + bounds.CommandTimeout = 300; + // Unfiltered: the key bounds come off the primary key index; each range update applies the filter. + bounds.CommandText = $"SELECT MIN({key}), MAX({key}) FROM {table}"; + using var reader = bounds.ExecuteReader(); + if (!reader.Read() || reader.IsDBNull(0)) + return; + + min = Convert.ToInt64(reader.GetValue(0)); + max = Convert.ToInt64(reader.GetValue(1)); + } + + for (var from = min; from <= max; from += RangeSize) + { + using var update = connection.CreateCommand(); + update.Transaction = transaction; + update.CommandTimeout = 300; + update.CommandText = $"UPDATE {table} SET destinationsource = 1 WHERE {key} >= @from AND {key} < @to AND destinationid > 0 AND destinationsource IS NULL"; + AddParameter(update, "from", from); + AddParameter(update, "to", from + RangeSize); + update.ExecuteNonQuery(); + } + } + + private static void AddParameter(IDbCommand command, string name, long value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.Int64; + parameter.Value = value; + command.Parameters.Add(parameter); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0230_AddInvoiceLineTimeSourcePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0230_AddInvoiceLineTimeSourcePg.cs new file mode 100644 index 000000000..f48cb3abb --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0230_AddInvoiceLineTimeSourcePg.cs @@ -0,0 +1,21 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Where a generated hourly unit line's on-scene time came from (see the SQL Server M0230). + /// + [Migration(230)] + public class M0230_AddInvoiceLineTimeSourcePg : Migration + { + public override void Up() + { + Execute.Sql("ALTER TABLE invoicelineitems ADD COLUMN IF NOT EXISTS timesource integer NULL;"); + } + + public override void Down() + { + Execute.Sql("ALTER TABLE invoicelineitems DROP COLUMN IF EXISTS timesource;"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs index 00db08ee8..11d344dd1 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ActionLogsRepository.cs @@ -304,7 +304,7 @@ public async Task GetLastActionLogsForUserAsync(string userId, bool d } } - public async Task> GetActionLogsForCallAsync(int callId) + public async Task> GetActionLogsForCallAsync(int departmentId, int callId) { try { @@ -312,6 +312,7 @@ public async Task> GetActionLogsForCallAsync(int callId) { var dynamicParameters = new DynamicParametersExtension(); dynamicParameters.Add("CallId", callId); + dynamicParameters.Add("DepartmentId", departmentId); var query = _queryFactory.GetQuery(); @@ -347,53 +348,6 @@ public async Task> GetActionLogsForCallAsync(int callId) } } - public async Task> GetActionLogsForCallAndTypesAsync(int destinationId, List types) - { - try - { - var selectFunction = new Func>>(async x => - { - var dynamicParameters = new DynamicParametersExtension(); - dynamicParameters.Add("CallId", destinationId); - - var usersToQuery = String.Join(",", types.Select(p => $"{p.ToString()}").ToArray()); - //dynamicParameters.Add("Types", usersToQuery); - - var query = _queryFactory.GetQuery(); - query = query.Replace("%TYPES%", usersToQuery, StringComparison.InvariantCultureIgnoreCase); - - return await x.QueryAsync(sql: query, - param: dynamicParameters, - transaction: _unitOfWork.Transaction, - map: (up, u) => { up.User = u; return up; }/*, - splitOn: "Id"*/); - }); - - DbConnection conn = null; - if (_unitOfWork?.Connection == null) - { - using (conn = _connectionProvider.Create()) - { - await conn.OpenAsync(); - - return await selectFunction(conn); - } - } - else - { - conn = _unitOfWork.CreateOrGetConnection(); - - return await selectFunction(conn); - } - } - catch (Exception ex) - { - Logging.LogException(ex); - - throw; - } - } - public async Task GetPreviousActionLogAsync(string userId, int actionLogId) { try diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchGroupRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchGroupRepository.cs index dba7c9ec9..891588f52 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchGroupRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchGroupRepository.cs @@ -110,5 +110,48 @@ public async Task> GetAllCallDispatchGroupByCallI throw; } } + + public async Task> GetCallDispatchGroupsForCallsInRangeAsync(int departmentId, DateTime startDate, DateTime endDate) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchRoleRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchRoleRepository.cs index 050e7a1cd..6fb97f138 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchRoleRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchRoleRepository.cs @@ -69,5 +69,48 @@ public async Task> GetCallRoleDispatchesByCallIdAs throw; } } + + public async Task> GetCallDispatchRolesForCallsInRangeAsync(int departmentId, DateTime startDate, DateTime endDate) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs index 933dde646..621817c4a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs @@ -69,5 +69,134 @@ public async Task> GetCallUnitDispatchesByCallIdAs throw; } } + + public async Task> GetOpenCallIdsForUnitAsync(int departmentId, int unitId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("UnitId", unitId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } + + public async Task> GetCallUnitDispatchesForCallsInRangeAsync(int departmentId, DateTime startDate, DateTime endDate) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } + + public async Task> GetUnitDispatchWindowsAsync(int departmentId, DateTime startDate, DateTime endDate, DateTime loggedFrom) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + dynamicParameters.Add("LoggedFrom", loggedFrom); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs index f13e30ec5..77359dd93 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs @@ -128,5 +128,134 @@ public async Task> GetCallDispatchesByCallIdAsync(int throw; } } + + public async Task> GetOpenCallIdsForUserAsync(int departmentId, string userId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("UserId", userId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } + + public async Task> GetCallDispatchesForCallsInRangeAsync(int departmentId, DateTime startDate, DateTime endDate) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } + + public async Task> GetPersonnelDispatchWindowsAsync(int departmentId, DateTime startDate, DateTime endDate, DateTime loggedFrom) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("StartDate", startDate); + dynamicParameters.Add("EndDate", endDate); + dynamicParameters.Add("LoggedFrom", loggedFrom); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, + param: dynamicParameters, + transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + + throw; + } + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index 7efedbdc8..26159aea3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -34,7 +34,6 @@ protected SqlConfiguration() { } public string SelectALogsByDateRangeQuery { get; set; } public string SelectALogsByDidQuery { get; set; } public string SelectLastActionLogForUserQuery { get; set; } - public string SelectActionLogsByCallIdTypeQuery { get; set; } public string SelectPreviousActionLogsByUserQuery { get; set; } public string SelectLastActionLogByUserIdQuery { get; set; } public string SelectActionLogsByCallIdQuery {get;set;} @@ -349,6 +348,14 @@ protected SqlConfiguration() { } public string SelectCallNotesByCallIdQuery { get; set; } public string SelectAllCallGroupDispsByCallIdQuery { get; set; } public string SelectAllCallUnitDispsByCallIdQuery { get; set; } + public string SelectOpenCallIdsForUnitQuery { get; set; } + public string SelectOpenCallIdsForUserQuery { get; set; } + public string SelectCallUnitDispatchesForCallsInRangeQuery { get; set; } + public string SelectCallDispatchesForCallsInRangeQuery { get; set; } + public string SelectCallDispatchGroupsForCallsInRangeQuery { get; set; } + public string SelectCallDispatchRolesForCallsInRangeQuery { get; set; } + public string SelectUnitDispatchWindowsQuery { get; set; } + public string SelectPersonnelDispatchWindowsQuery { get; set; } public string SelectAllCallRoleDispsByCallIdQuery { get; set; } public string SelectCallYearsByDeptQuery { get; set; } public string SelectAllClosedCallsByDidYearQuery { get; set; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectActionLogsByCallIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectActionLogsByCallIdQuery.cs index 91e6d034d..35de99549 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectActionLogsByCallIdQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectActionLogsByCallIdQuery.cs @@ -20,10 +20,12 @@ public string GetQuery() string.Empty, _sqlConfiguration.ParameterNotation, new string[] { - "%CALLID%" + "%CALLID%", + "%DID%" }, new string[] { - "CallId" + "CallId", + "DepartmentId" }, new string[] { "%ACTIONLOGSTABLE%", diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchGroupsForCallsInRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchGroupsForCallsInRangeQuery.cs new file mode 100644 index 000000000..a8739ed54 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchGroupsForCallsInRangeQuery.cs @@ -0,0 +1,50 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calls +{ + public class SelectCallDispatchGroupsForCallsInRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCallDispatchGroupsForCallsInRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCallDispatchGroupsForCallsInRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%STARTDATE%", + "%ENDDATE%" + }, + new string[] { + "DepartmentId", + "StartDate", + "EndDate" + }, + new string[] { + "%CALLSTABLE%", + "%CALLDISPATCHGROUPSTABLE%" + }, + new string[] { + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchGroupsTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchRolesForCallsInRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchRolesForCallsInRangeQuery.cs new file mode 100644 index 000000000..cfe8ed033 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchRolesForCallsInRangeQuery.cs @@ -0,0 +1,50 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calls +{ + public class SelectCallDispatchRolesForCallsInRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCallDispatchRolesForCallsInRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCallDispatchRolesForCallsInRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%STARTDATE%", + "%ENDDATE%" + }, + new string[] { + "DepartmentId", + "StartDate", + "EndDate" + }, + new string[] { + "%CALLSTABLE%", + "%CALLDISPATCHROLESTABLE%" + }, + new string[] { + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchRolesTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchesForCallsInRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchesForCallsInRangeQuery.cs new file mode 100644 index 000000000..fbc66e2ce --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallDispatchesForCallsInRangeQuery.cs @@ -0,0 +1,50 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calls +{ + public class SelectCallDispatchesForCallsInRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCallDispatchesForCallsInRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCallDispatchesForCallsInRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%STARTDATE%", + "%ENDDATE%" + }, + new string[] { + "DepartmentId", + "StartDate", + "EndDate" + }, + new string[] { + "%CALLSTABLE%", + "%CALLDISPATCHESTABLE%" + }, + new string[] { + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchesTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallUnitDispatchesForCallsInRangeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallUnitDispatchesForCallsInRangeQuery.cs new file mode 100644 index 000000000..4ad4ff730 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectCallUnitDispatchesForCallsInRangeQuery.cs @@ -0,0 +1,50 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calls +{ + public class SelectCallUnitDispatchesForCallsInRangeQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectCallUnitDispatchesForCallsInRangeQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectCallUnitDispatchesForCallsInRangeQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%STARTDATE%", + "%ENDDATE%" + }, + new string[] { + "DepartmentId", + "StartDate", + "EndDate" + }, + new string[] { + "%CALLSTABLE%", + "%CALLDISPATCHUNITSTABLE%" + }, + new string[] { + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchUnitsTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectActionLogsByCallIdTypeQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUnitQuery.cs similarity index 63% rename from Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectActionLogsByCallIdTypeQuery.cs rename to Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUnitQuery.cs index 6fe78e911..0e673d7e1 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/ActionLogs/SelectActionLogsByCallIdTypeQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUnitQuery.cs @@ -3,35 +3,37 @@ using Resgrid.Repositories.DataRepository.Configs; using Resgrid.Repositories.DataRepository.Extensions; -namespace Resgrid.Repositories.DataRepository.Queries.ActionLogs +namespace Resgrid.Repositories.DataRepository.Queries.Calls { - public class SelectActionLogsByCallIdTypeQuery : ISelectQuery + public class SelectOpenCallIdsForUnitQuery : ISelectQuery { private readonly SqlConfiguration _sqlConfiguration; - public SelectActionLogsByCallIdTypeQuery(SqlConfiguration sqlConfiguration) + public SelectOpenCallIdsForUnitQuery(SqlConfiguration sqlConfiguration) { _sqlConfiguration = sqlConfiguration; } public string GetQuery() { - var query = _sqlConfiguration.SelectActionLogsByCallIdTypeQuery + var query = _sqlConfiguration.SelectOpenCallIdsForUnitQuery .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, string.Empty, _sqlConfiguration.ParameterNotation, new string[] { - "%CALLID%" + "%DID%", + "%UNITID%" }, new string[] { - "CallId" + "DepartmentId", + "UnitId" }, new string[] { - "%ACTIONLOGSTABLE%", - "%ASPNETUSERSTABLE%" + "%CALLSTABLE%", + "%CALLDISPATCHUNITSTABLE%" }, new string[] { - _sqlConfiguration.ActionLogsTable, - _sqlConfiguration.UserTable + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchUnitsTable } ); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUserQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUserQuery.cs new file mode 100644 index 000000000..b2cb8569c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUserQuery.cs @@ -0,0 +1,56 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calls +{ + public class SelectOpenCallIdsForUserQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectOpenCallIdsForUserQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectOpenCallIdsForUserQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%USERID%" + }, + new string[] { + "DepartmentId", + "UserId" + }, + new string[] { + "%CALLSTABLE%", + "%CALLDISPATCHESTABLE%", + "%CALLDISPATCHGROUPSTABLE%", + "%CALLDISPATCHROLESTABLE%", + "%DEPARTMENTGROUPMEMBERSTABLE%", + "%PERSONNELROLEUSERSTABLE%" + }, + new string[] { + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchesTable, + _sqlConfiguration.CallDispatchGroupsTable, + _sqlConfiguration.CallDispatchRolesTable, + _sqlConfiguration.DepartmentGroupMembersTable, + _sqlConfiguration.PersonnelRoleUsersTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectPersonnelDispatchWindowsQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectPersonnelDispatchWindowsQuery.cs new file mode 100644 index 000000000..a22642efb --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectPersonnelDispatchWindowsQuery.cs @@ -0,0 +1,60 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calls +{ + public class SelectPersonnelDispatchWindowsQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectPersonnelDispatchWindowsQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectPersonnelDispatchWindowsQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%STARTDATE%", + "%ENDDATE%", + "%LOGGEDFROM%" + }, + new string[] { + "DepartmentId", + "StartDate", + "EndDate", + "LoggedFrom" + }, + new string[] { + "%CALLSTABLE%", + "%CALLDISPATCHESTABLE%", + "%CALLDISPATCHGROUPSTABLE%", + "%CALLDISPATCHROLESTABLE%", + "%DEPARTMENTGROUPMEMBERSTABLE%", + "%PERSONNELROLEUSERSTABLE%" + }, + new string[] { + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchesTable, + _sqlConfiguration.CallDispatchGroupsTable, + _sqlConfiguration.CallDispatchRolesTable, + _sqlConfiguration.DepartmentGroupMembersTable, + _sqlConfiguration.PersonnelRoleUsersTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectUnitDispatchWindowsQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectUnitDispatchWindowsQuery.cs new file mode 100644 index 000000000..718da65e7 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectUnitDispatchWindowsQuery.cs @@ -0,0 +1,52 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.Calls +{ + public class SelectUnitDispatchWindowsQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectUnitDispatchWindowsQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectUnitDispatchWindowsQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + string.Empty, + _sqlConfiguration.ParameterNotation, + new string[] { + "%DID%", + "%STARTDATE%", + "%ENDDATE%", + "%LOGGEDFROM%" + }, + new string[] { + "DepartmentId", + "StartDate", + "EndDate", + "LoggedFrom" + }, + new string[] { + "%CALLSTABLE%", + "%CALLDISPATCHUNITSTABLE%" + }, + new string[] { + _sqlConfiguration.CallsTable, + _sqlConfiguration.CallDispatchUnitsTable + } + ); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + throw new System.NotImplementedException(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Units/SelectUnitStatesByCallIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Units/SelectUnitStatesByCallIdQuery.cs index 00fa21bb3..f3384e41e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Units/SelectUnitStatesByCallIdQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Units/SelectUnitStatesByCallIdQuery.cs @@ -20,10 +20,12 @@ public string GetQuery() string.Empty, _sqlConfiguration.ParameterNotation, new string[] { - "%CALLID%" + "%CALLID%", + "%DID%" }, new string[] { - "CallId" + "CallId", + "DepartmentId" }, new string[] { "%UNITSTATESTABLE%", diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 15794f87a..5c467b882 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -87,13 +87,6 @@ public PostgreSqlConfiguration() INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId WHERE al.UserId = %USERID% AND (%DAA% = true OR al.Timestamp >= %TS%) ORDER BY ActionLogId DESC limit 1"; - SelectActionLogsByCallIdTypeQuery = @" - SELECT al.*, u.* - FROM %SCHEMA%.%ACTIONLOGSTABLE% al - INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId - WHERE al.DestinationId = %CALLID% - AND (al.DestinationType IS NULL OR al.DestinationType = 2) - AND (al.ActionTypeId IS NULL OR al.ActionTypeId IN (%TYPES%))"; SelectPreviousActionLogsByUserQuery = @" SELECT a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 @@ -110,7 +103,9 @@ public PostgreSqlConfiguration() SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId - WHERE al.DestinationId = %DID%"; + WHERE al.DestinationId = %CALLID% + AND al.DepartmentId = %DID% + AND (al.DestinationType IS NULL OR al.DestinationType = 2)"; #endregion ActionLogs @@ -718,6 +713,7 @@ SELECT ur.* FROM %SCHEMA%.%UNITSTATESTABLE% us INNER JOIN %SCHEMA%.%UNITSTABLE% u ON u.UnitId = us.UnitId WHERE us.DestinationId = %CALLID% + AND u.DepartmentId = %DID% AND (us.DestinationType IS NULL OR us.DestinationType = 2)"; SelectUnitByDIdTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Type = %TYPE%"; @@ -1279,6 +1275,68 @@ UPDATE CallDispatches SelectCallAttachmentByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; SelectAllCallGroupDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; SelectAllCallUnitDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; + SelectOpenCallIdsForUnitQuery = @" + SELECT c.CallId + FROM %SCHEMA%.%CALLSTABLE% c + WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false + AND EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu WHERE cdu.CallId = c.CallId AND cdu.UnitId = %UNITID%)"; + SelectOpenCallIdsForUserQuery = @" + SELECT c.CallId + FROM %SCHEMA%.%CALLSTABLE% c + WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false + AND (EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd WHERE cd.CallId = c.CallId AND cd.UserId = %USERID%) + OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.DepartmentGroupId = cg.DepartmentGroupId + WHERE cg.CallId = c.CallId AND gm.UserId = %USERID%) + OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.PersonnelRoleId = cr.RoleId + WHERE cr.CallId = c.CallId AND ru.UserId = %USERID%))"; + SelectCallUnitDispatchesForCallsInRangeQuery = @" + SELECT cdu.* + FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cdu.CallId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %STARTDATE% AND c.LoggedOn <= %ENDDATE%"; + SelectCallDispatchesForCallsInRangeQuery = @" + SELECT cd.* + FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cd.CallId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %STARTDATE% AND c.LoggedOn <= %ENDDATE%"; + SelectCallDispatchGroupsForCallsInRangeQuery = @" + SELECT cg.* + FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cg.CallId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %STARTDATE% AND c.LoggedOn <= %ENDDATE%"; + SelectCallDispatchRolesForCallsInRangeQuery = @" + SELECT cr.* + FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cr.CallId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %STARTDATE% AND c.LoggedOn <= %ENDDATE%"; + SelectUnitDispatchWindowsQuery = @" + SELECT cdu.CallId, cdu.UnitId, cdu.DispatchedOn, c.LoggedOn, c.ClosedOn + FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cdu.CallId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %LOGGEDFROM% AND c.LoggedOn <= %ENDDATE% + AND cdu.DispatchedOn >= %STARTDATE% AND cdu.DispatchedOn <= %ENDDATE%"; + SelectPersonnelDispatchWindowsQuery = @" + SELECT cd.CallId, CAST(cd.UserId AS text) AS UserId, cd.DispatchedOn, c.LoggedOn, c.ClosedOn, false AS Paged + FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cd.CallId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %LOGGEDFROM% AND c.LoggedOn <= %ENDDATE% + AND cd.DispatchedOn >= %STARTDATE% AND cd.DispatchedOn <= %ENDDATE% + UNION ALL + SELECT cg.CallId, CAST(gm.UserId AS text), cg.DispatchedOn, c.LoggedOn, c.ClosedOn, true + FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cg.CallId + INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.DepartmentGroupId = cg.DepartmentGroupId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %LOGGEDFROM% AND c.LoggedOn <= %ENDDATE% + AND cg.DispatchedOn >= %STARTDATE% AND cg.DispatchedOn <= %ENDDATE% + UNION ALL + SELECT cr.CallId, CAST(ru.UserId AS text), cr.DispatchedOn, c.LoggedOn, c.ClosedOn, true + FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cr.CallId + INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.PersonnelRoleId = cr.RoleId + WHERE c.DepartmentId = %DID% AND c.IsDeleted = false AND c.LoggedOn >= %LOGGEDFROM% AND c.LoggedOn <= %ENDDATE% + AND cr.DispatchedOn >= %STARTDATE% AND cr.DispatchedOn <= %ENDDATE%"; SelectAllCallRoleDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; SelectCallNotesByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; CallVideoFeedsTable = "CallVideoFeeds"; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 8b1adb8d1..f09e6aaaf 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -85,13 +85,6 @@ public SqlServerConfiguration() INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.[Id] = al.[UserId] WHERE al.[UserId] = %USERID% AND (%DAA% = 1 OR al.[Timestamp] >= %TS%) ORDER BY ActionLogId DESC"; - SelectActionLogsByCallIdTypeQuery = @" - SELECT al.*, u.* - FROM %SCHEMA%.%ACTIONLOGSTABLE% al - INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId - WHERE al.[DestinationId] = %CALLID% - AND (al.[DestinationType] IS NULL OR al.[DestinationType] = 2) - AND (al.[ActionTypeId] IS NULL OR al.[ActionTypeId] IN (%TYPES%))"; SelectPreviousActionLogsByUserQuery = @" SELECT TOP 1 a1.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% a1 @@ -108,7 +101,9 @@ public SqlServerConfiguration() SELECT al.*, u.* FROM %SCHEMA%.%ACTIONLOGSTABLE% al INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = al.UserId - WHERE al.DestinationId = %DID%"; + WHERE al.[DestinationId] = %CALLID% + AND al.[DepartmentId] = %DID% + AND (al.[DestinationType] IS NULL OR al.[DestinationType] = 2)"; #endregion ActionLogs @@ -715,6 +710,7 @@ FROM [dbo].[Units] u FROM %SCHEMA%.%UNITSTATESTABLE% us INNER JOIN %SCHEMA%.%UNITSTABLE% u ON u.[UnitId] = us.[UnitId] WHERE us.[DestinationId] = %CALLID% + AND u.[DepartmentId] = %DID% AND (us.[DestinationType] IS NULL OR us.[DestinationType] = 2)"; SelectUnitByDIdTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [Type] = %TYPE%"; @@ -1232,6 +1228,68 @@ UPDATE CallDispatches SelectCallAttachmentByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; SelectAllCallGroupDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; SelectAllCallUnitDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; + SelectOpenCallIdsForUnitQuery = @" + SELECT c.[CallId] + FROM %SCHEMA%.%CALLSTABLE% c + WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0 + AND EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu WHERE cdu.[CallId] = c.[CallId] AND cdu.[UnitId] = %UNITID%)"; + SelectOpenCallIdsForUserQuery = @" + SELECT c.[CallId] + FROM %SCHEMA%.%CALLSTABLE% c + WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0 + AND (EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd WHERE cd.[CallId] = c.[CallId] AND cd.[UserId] = %USERID%) + OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.[DepartmentGroupId] = cg.[DepartmentGroupId] + WHERE cg.[CallId] = c.[CallId] AND gm.[UserId] = %USERID%) + OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.[PersonnelRoleId] = cr.[RoleId] + WHERE cr.[CallId] = c.[CallId] AND ru.[UserId] = %USERID%))"; + SelectCallUnitDispatchesForCallsInRangeQuery = @" + SELECT cdu.* + FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cdu.[CallId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %STARTDATE% AND c.[LoggedOn] <= %ENDDATE%"; + SelectCallDispatchesForCallsInRangeQuery = @" + SELECT cd.* + FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cd.[CallId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %STARTDATE% AND c.[LoggedOn] <= %ENDDATE%"; + SelectCallDispatchGroupsForCallsInRangeQuery = @" + SELECT cg.* + FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cg.[CallId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %STARTDATE% AND c.[LoggedOn] <= %ENDDATE%"; + SelectCallDispatchRolesForCallsInRangeQuery = @" + SELECT cr.* + FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cr.[CallId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %STARTDATE% AND c.[LoggedOn] <= %ENDDATE%"; + SelectUnitDispatchWindowsQuery = @" + SELECT cdu.[CallId], cdu.[UnitId], cdu.[DispatchedOn], c.[LoggedOn], c.[ClosedOn] + FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cdu.[CallId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %LOGGEDFROM% AND c.[LoggedOn] <= %ENDDATE% + AND cdu.[DispatchedOn] >= %STARTDATE% AND cdu.[DispatchedOn] <= %ENDDATE%"; + SelectPersonnelDispatchWindowsQuery = @" + SELECT cd.[CallId], cd.[UserId], cd.[DispatchedOn], c.[LoggedOn], c.[ClosedOn], CAST(0 AS bit) AS [Paged] + FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cd.[CallId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %LOGGEDFROM% AND c.[LoggedOn] <= %ENDDATE% + AND cd.[DispatchedOn] >= %STARTDATE% AND cd.[DispatchedOn] <= %ENDDATE% + UNION ALL + SELECT cg.[CallId], gm.[UserId], cg.[DispatchedOn], c.[LoggedOn], c.[ClosedOn], CAST(1 AS bit) + FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cg.[CallId] + INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.[DepartmentGroupId] = cg.[DepartmentGroupId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %LOGGEDFROM% AND c.[LoggedOn] <= %ENDDATE% + AND cg.[DispatchedOn] >= %STARTDATE% AND cg.[DispatchedOn] <= %ENDDATE% + UNION ALL + SELECT cr.[CallId], ru.[UserId], cr.[DispatchedOn], c.[LoggedOn], c.[ClosedOn], CAST(1 AS bit) + FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cr.[CallId] + INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.[PersonnelRoleId] = cr.[RoleId] + WHERE c.[DepartmentId] = %DID% AND c.[IsDeleted] = 0 AND c.[LoggedOn] >= %LOGGEDFROM% AND c.[LoggedOn] <= %ENDDATE% + AND cr.[DispatchedOn] >= %STARTDATE% AND cr.[DispatchedOn] <= %ENDDATE%"; SelectAllCallRoleDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; SelectCallNotesByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; CallVideoFeedsTable = "CallVideoFeeds"; diff --git a/Repositories/Resgrid.Repositories.DataRepository/UnitStatesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UnitStatesRepository.cs index c807627ad..a028bbb06 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/UnitStatesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/UnitStatesRepository.cs @@ -249,7 +249,7 @@ public async Task GetUnitStateByUnitStateIdAsync(int unitStateId) } } - public async Task> GetAllStatesByCallIdAsync(int callId) + public async Task> GetAllStatesByCallIdAsync(int departmentId, int callId) { try { @@ -257,6 +257,7 @@ public async Task> GetAllStatesByCallIdAsync(int callId) { var dynamicParameters = new DynamicParametersExtension(); dynamicParameters.Add("CallId", callId); + dynamicParameters.Add("DepartmentId", departmentId); var query = _queryFactory.GetQuery(); diff --git a/Tests/Resgrid.Tests/Models/CallStatusAttributionTests.cs b/Tests/Resgrid.Tests/Models/CallStatusAttributionTests.cs new file mode 100644 index 000000000..c60f85135 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/CallStatusAttributionTests.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Reporting; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class CallStatusAttributionTests + { + private const int CallId = 42; + private static readonly DateTime T0 = new DateTime(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + + private static bool Clearing(UnitState s) => CallStatusLinkage.IsClearingUnitState(s.State, null); + + private static UnitState State(int id, int minutes, UnitStateTypes state, int? destinationId = null, int? destinationType = null) => + new UnitState { UnitStateId = id, UnitId = 5, State = (int)state, Timestamp = T0.AddMinutes(minutes), DestinationId = destinationId, DestinationType = destinationType }; + + [Test] + public void carry_forward_needs_a_linked_open_call_and_a_non_clearing_previous_status() + { + CallStatusAttribution.CanCarryForward(CallId, false, true).Should().BeTrue(); + CallStatusAttribution.CanCarryForward(CallId, true, true).Should().BeFalse("the previous status cleared the call"); + CallStatusAttribution.CanCarryForward(CallId, false, false).Should().BeFalse("the call is closed"); + CallStatusAttribution.CanCarryForward(null, false, true).Should().BeFalse(); + } + + [Test] + public void dispatch_rule_needs_exactly_one_candidate_other_than_the_cleared_call() + { + CallStatusAttribution.PickDispatchCall(new[] { 7 }, null).Should().Be(7); + CallStatusAttribution.PickDispatchCall(new[] { 7, 7 }, null).Should().Be(7); + CallStatusAttribution.PickDispatchCall(new[] { 7, 8 }, null).Should().BeNull("ambiguous"); + CallStatusAttribution.PickDispatchCall(new[] { 7, 8 }, 8).Should().Be(7); + CallStatusAttribution.PickDispatchCall(new[] { 7 }, 7).Should().BeNull("the unit already cleared that call"); + CallStatusAttribution.PickDispatchCall(Array.Empty(), null).Should().BeNull(); + CallStatusAttribution.PickDispatchCall(null, null).Should().BeNull(); + } + + [Test] + public void inference_takes_unlinked_statuses_from_dispatch_until_the_first_clearing_status() + { + var states = new List + { + State(1, -5, UnitStateTypes.Available), // before dispatch + State(2, 1, UnitStateTypes.Responding, CallId, (int)DestinationEntityTypes.Call), // already linked + State(3, 9, UnitStateTypes.OnScene), // inferred + State(4, 30, UnitStateTypes.Staging), // inferred + State(5, 50, UnitStateTypes.Available), // inferred, clearing: stop + State(6, 70, UnitStateTypes.Responding) // after clearing + }; + + var inferred = CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(3), states, Clearing); + + inferred.Select(x => x.UnitStateId).Should().Equal(3, 4, 5); + inferred.Should().OnlyContain(x => x.DestinationId == CallId && x.DestinationType == (int)DestinationEntityTypes.Call + && x.DestinationSource == (int)StatusDestinationSources.Inferred); + states.Single(x => x.UnitStateId == 3).DestinationId.Should().BeNull("inferred rows are copies"); + } + + [Test] + public void inference_stops_when_the_unit_moves_to_another_call_or_a_station() + { + var toAnotherCall = new List + { + State(1, 2, UnitStateTypes.Responding), + State(2, 10, UnitStateTypes.Responding, 99, (int)DestinationEntityTypes.Call), + State(3, 20, UnitStateTypes.OnScene) + }; + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), toAnotherCall, Clearing).Select(x => x.UnitStateId).Should().Equal(1); + + var toStation = new List + { + State(1, 2, UnitStateTypes.OnScene), + State(2, 10, UnitStateTypes.Returning, 3, (int)DestinationEntityTypes.Station), + State(3, 20, UnitStateTypes.Committed) + }; + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), toStation, Clearing).Select(x => x.UnitStateId).Should().Equal(1); + } + + [Test] + public void inference_skips_statuses_another_calls_walk_takes_too() + { + // The unit was also dispatched to call 99, open from minute 20 to minute 40. + var otherDispatches = new[] { new CallDispatchSpan(99, T0.AddMinutes(20), T0.AddMinutes(40), false) }; + + var nothingLinkedYet = new List + { + State(1, 25, UnitStateTypes.OnScene), // ambiguous: skipped + State(2, 45, UnitStateTypes.Staging), // after call 99 closed: inferred + State(3, 50, UnitStateTypes.Available) // inferred, clearing: stop + }; + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), nothingLinkedYet, Clearing, null, otherDispatches) + .Select(x => x.UnitStateId).Should().Equal(2, 3); + + var followsThisCall = new List + { + State(1, 10, UnitStateTypes.Responding, CallId, (int)DestinationEntityTypes.Call), + State(2, 25, UnitStateTypes.OnScene) // follows a status on this call's record: carried forward + }; + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), followsThisCall, Clearing, null, otherDispatches) + .Select(x => x.UnitStateId).Should().Equal(2); + + var ambiguousClear = new List + { + State(1, 25, UnitStateTypes.Available), // cleared something, can't tell which call: the walk ends + State(2, 45, UnitStateTypes.Responding) + }; + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), ambiguousClear, Clearing, null, otherDispatches).Should().BeEmpty(); + } + + [Test] + public void a_call_left_open_after_the_unit_cleared_it_does_not_make_later_statuses_ambiguous() + { + // Call 77 was dispatched two hours earlier and never closed, but the unit cleared it before this dispatch. + var otherDispatches = new[] { new CallDispatchSpan(77, T0.AddHours(-2), T0.AddHours(3), false) }; + var states = new List + { + State(1, -110, UnitStateTypes.Responding), + State(2, -100, UnitStateTypes.Available), + State(3, 10, UnitStateTypes.OnScene) + }; + + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), states, Clearing, null, otherDispatches) + .Select(x => x.UnitStateId).Should().Equal(3); + } + + [Test] + public void an_untyped_legacy_station_destination_equal_to_the_call_id_ends_the_walk() + { + var stationOnly = new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Returning, DetailType = (int)CustomStateDetailTypes.Stations }; + var lookup = new Dictionary { [stationOnly.CustomStateDetailId] = stationOnly }; + var states = new List + { + State(1, 5, UnitStateTypes.OnScene), + State(2, 10, UnitStateTypes.Returning, CallId), // legacy row: station 42, not call 42 + State(3, 20, UnitStateTypes.Committed) + }; + + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), states, s => false, lookup).Select(x => x.UnitStateId).Should().Equal(1); + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), states, s => false).Select(x => x.UnitStateId).Should().Equal(new[] { 1, 3 }, "without the status lookup the legacy row reads as the call"); + } + + [Test] + public void dispatch_spans_mirror_each_calls_own_walk() + { + var now = T0.AddHours(5); + var spans = CallStatusAttribution.DispatchSpans(new[] + { + new CallDispatchWindow { CallId = 1, DispatchedOn = T0.AddMinutes(10), LoggedOn = T0.AddMinutes(-1), ClosedOn = T0.AddHours(1), Paged = true }, + new CallDispatchWindow { CallId = 1, DispatchedOn = T0, LoggedOn = T0.AddMinutes(-1), ClosedOn = T0.AddHours(1) }, + new CallDispatchWindow { CallId = 2, LoggedOn = T0, Paged = true }, + null + }, now); + + spans.Should().Equal( + new CallDispatchSpan(1, T0, T0.AddHours(1), false), // earliest dispatch; paged and direct counts as direct + new CallDispatchSpan(2, T0, now, true)); // no dispatch time: from the logging; open: to now + } + + [Test] + public void only_a_status_set_now_is_live() + { + var now = T0; + CallStatusAttribution.IsLiveStatus(now, now).Should().BeTrue(); + CallStatusAttribution.IsLiveStatus(now.AddMinutes(-4), now).Should().BeTrue(); + CallStatusAttribution.IsLiveStatus(now.AddMinutes(3), now).Should().BeTrue("device clocks run ahead"); + CallStatusAttribution.IsLiveStatus(now.AddMinutes(-6), now).Should().BeFalse("replayed from an offline queue"); + } + + [Test] + public void inference_ends_at_the_call_close() + { + var states = new List { State(1, 5, UnitStateTypes.OnScene), State(2, 90, UnitStateTypes.Committed) }; + + CallStatusAttribution.InferUnitStates(CallId, T0, T0.AddHours(1), states, Clearing).Select(x => x.UnitStateId).Should().Equal(1); + } + + [Test] + public void paged_personnel_only_count_once_they_engage() + { + bool PersonClearing(ActionLog l) => CallStatusLinkage.IsClearingPersonnelStatus(l.ActionTypeId, null); + var logs = new List + { + new ActionLog { ActionLogId = 1, UserId = "u", ActionTypeId = (int)ActionTypes.StandingBy, Timestamp = T0.AddMinutes(1) }, + new ActionLog { ActionLogId = 2, UserId = "u", ActionTypeId = (int)ActionTypes.Responding, Timestamp = T0.AddMinutes(3) }, + new ActionLog { ActionLogId = 3, UserId = "u", ActionTypeId = (int)ActionTypes.OnScene, Timestamp = T0.AddMinutes(12) }, + new ActionLog { ActionLogId = 4, UserId = "u", ActionTypeId = (int)ActionTypes.StandingBy, Timestamp = T0.AddMinutes(60) } + }; + + CallStatusAttribution.InferActionLogs(CallId, T0, T0.AddHours(2), true, logs, PersonClearing).Select(x => x.ActionLogId).Should().Equal(2, 3, 4); + CallStatusAttribution.InferActionLogs(CallId, T0, T0.AddHours(2), false, logs, PersonClearing).Select(x => x.ActionLogId).Should().Equal(1); + + var neverEngaged = logs.Take(1).ToList(); + CallStatusAttribution.InferActionLogs(CallId, T0, T0.AddHours(2), true, neverEngaged, PersonClearing).Should().BeEmpty(); + } + + [Test] + public void clearing_statuses_cover_built_in_and_custom_base_types() + { + CallStatusLinkage.IsClearingUnitState((int)UnitStateTypes.Available, null).Should().BeTrue(); + CallStatusLinkage.IsClearingUnitState((int)UnitStateTypes.Released, null).Should().BeTrue(); + CallStatusLinkage.IsClearingUnitState((int)UnitStateTypes.OnScene, null).Should().BeFalse(); + CallStatusLinkage.IsClearingUnitState(900, new Dictionary { [900] = (int)ActionBaseTypes.Cleared }).Should().BeTrue(); + CallStatusLinkage.IsClearingUnitState(901, new Dictionary { [901] = (int)ActionBaseTypes.None }).Should().BeFalse(); + + CallStatusLinkage.IsClearingPersonnelStatus((int)ActionTypes.StandingBy, null).Should().BeTrue(); + CallStatusLinkage.IsClearingPersonnelStatus((int)ActionTypes.RespondingToStation, null).Should().BeFalse("volunteers respond to the station to pick up the apparatus"); + CallStatusLinkage.IsClearingPersonnelStatus(902, new Dictionary { [902] = (int)ActionBaseTypes.Returning }).Should().BeTrue(); + } + + [Test] + public void unit_times_come_from_dispatch_and_the_first_status_of_each_kind() + { + var dispatches = new[] { new CallDispatchUnit { CallId = CallId, UnitId = 5, DispatchedOn = T0 } }; + var states = new List + { + State(1, 1, UnitStateTypes.Responding, CallId, (int)DestinationEntityTypes.Call), + State(2, 9, UnitStateTypes.OnScene, CallId, (int)DestinationEntityTypes.Call), + State(3, 44, UnitStateTypes.Available, CallId, (int)DestinationEntityTypes.Call) + }; + states[1].DestinationSource = (int)StatusDestinationSources.CarryForward; + + var row = CallUnitTimesCalculator.Compute(dispatches, states, null).Single(); + + row.DispatchedOn.Should().Be(T0); + row.EnrouteOn.Should().Be(T0.AddMinutes(1)); + row.OnSceneOn.Should().Be(T0.AddMinutes(9)); + row.StagingOn.Should().BeNull(); + row.ClearedOn.Should().Be(T0.AddMinutes(44)); + row.Source.Should().Be(CallUnitTimesSources.AutoLinked); + } + + [Test] + public void unit_times_mark_dispatch_only_and_inferred_rows() + { + var dispatches = new[] + { + new CallDispatchUnit { CallId = CallId, UnitId = 5, DispatchedOn = T0 }, + new CallDispatchUnit { CallId = CallId, UnitId = 6, DispatchedOn = T0 } + }; + var inferredOnScene = State(1, 9, UnitStateTypes.OnScene, CallId, (int)DestinationEntityTypes.Call); + inferredOnScene.DestinationSource = (int)StatusDestinationSources.Inferred; + + var rows = CallUnitTimesCalculator.Compute(dispatches, new[] { inferredOnScene }, null); + + rows.Single(x => x.UnitId == 5).Source.Should().Be(CallUnitTimesSources.Inferred); + rows.Single(x => x.UnitId == 6).Source.Should().Be(CallUnitTimesSources.DispatchOnly); + } + } +} diff --git a/Tests/Resgrid.Tests/Models/CallStatusLinkageTests.cs b/Tests/Resgrid.Tests/Models/CallStatusLinkageTests.cs new file mode 100644 index 000000000..1336dad44 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/CallStatusLinkageTests.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class CallStatusLinkageTests + { + private static CustomState UnitCustomState(params CustomStateDetail[] details) => + new CustomState { CustomStateId = 1, Type = (int)CustomStateTypes.Unit, Details = new List(details) }; + + private static CustomState PersonnelCustomState(params CustomStateDetail[] details) => + new CustomState { CustomStateId = 2, Type = (int)CustomStateTypes.Personnel, Details = new List(details) }; + + private static Dictionary UnitLookup(params CustomState[] customStates) => + CallStatusLinkage.BuildStatusLookup(new CustomStateService(null, null, null, null).GetDefaultUnitStatuses(), customStates, CustomStateTypes.Unit); + + private static Dictionary PersonnelLookup(params CustomState[] customStates) => + CallStatusLinkage.BuildStatusLookup(new CustomStateService(null, null, null, null).GetDefaultPersonStatuses(), customStates, CustomStateTypes.Personnel); + + [Test] + public void an_explicit_call_destination_always_belongs_to_the_call_whatever_the_status() + { + var lookup = UnitLookup(); + + new UnitState { State = (int)UnitStateTypes.Available, DestinationId = 5, DestinationType = (int)DestinationEntityTypes.Call }.BelongsToCall(lookup).Should().BeTrue(); + new UnitState { State = (int)UnitStateTypes.Cancelled, DestinationId = 5, DestinationType = (int)DestinationEntityTypes.Call }.BelongsToCall(lookup).Should().BeTrue(); + new UnitState { State = (int)UnitStateTypes.OutOfService, DestinationId = 5, DestinationType = (int)DestinationEntityTypes.Call }.BelongsToCall(lookup).Should().BeTrue(); + } + + [Test] + public void a_custom_status_edited_to_no_destination_keeps_its_explicit_call_history() + { + var lookup = UnitLookup(UnitCustomState(new CustomStateDetail { CustomStateDetailId = 500, DetailType = (int)CustomStateDetailTypes.None })); + + new UnitState { State = 500, DestinationId = 5, DestinationType = (int)DestinationEntityTypes.Call }.BelongsToCall(lookup).Should().BeTrue(); + } + + [Test] + public void station_and_poi_typed_rows_never_belong_to_a_call() + { + var lookup = UnitLookup(); + + new UnitState { State = (int)UnitStateTypes.OnScene, DestinationId = 5, DestinationType = (int)DestinationEntityTypes.Station }.BelongsToCall(lookup).Should().BeFalse(); + new UnitState { State = (int)UnitStateTypes.OnScene, DestinationId = 5, DestinationType = (int)DestinationEntityTypes.Poi }.BelongsToCall(lookup).Should().BeFalse(); + } + + [Test] + public void untyped_legacy_unit_rows_are_kept_unless_the_status_only_targets_stations() + { + var lookup = UnitLookup(UnitCustomState( + new CustomStateDetail { CustomStateDetailId = 501, DetailType = (int)CustomStateDetailTypes.Stations }, + new CustomStateDetail { CustomStateDetailId = 502, DetailType = (int)CustomStateDetailTypes.CallsAndStations })); + + new UnitState { State = (int)UnitStateTypes.OnScene, DestinationId = 5 }.BelongsToCall(lookup).Should().BeTrue(); + new UnitState { State = (int)UnitStateTypes.Released, DestinationId = 5 }.BelongsToCall(lookup).Should().BeTrue("Released has no default button, so it can't be a station status"); + new UnitState { State = (int)UnitStateTypes.Available, DestinationId = 5 }.BelongsToCall(lookup).Should().BeFalse("the default Available status targets stations"); + new UnitState { State = (int)UnitStateTypes.Returning, DestinationId = 5 }.BelongsToCall(lookup).Should().BeFalse("the default Returning status targets stations"); + new UnitState { State = 501, DestinationId = 5 }.BelongsToCall(lookup).Should().BeFalse(); + new UnitState { State = 502, DestinationId = 5 }.BelongsToCall(lookup).Should().BeTrue(); + } + + [Test] + public void untyped_personnel_rows_follow_the_default_personnel_destination_settings() + { + var lookup = PersonnelLookup(); + + new ActionLog { ActionTypeId = (int)ActionTypes.Responding, DestinationId = 5 }.BelongsToCall(lookup).Should().BeTrue(); + new ActionLog { ActionTypeId = (int)ActionTypes.OnUnit, DestinationId = 5 }.BelongsToCall(lookup).Should().BeTrue(); + new ActionLog { ActionTypeId = (int)ActionTypes.RespondingToScene, DestinationId = 5 }.BelongsToCall(lookup).Should().BeTrue(); + new ActionLog { ActionTypeId = (int)ActionTypes.NotResponding, DestinationId = 5 }.BelongsToCall(lookup).Should().BeTrue("a status with no destination setting can't be a station status"); + new ActionLog { ActionTypeId = (int)ActionTypes.RespondingToStation, DestinationId = 5 }.BelongsToCall(lookup).Should().BeFalse(); + new ActionLog { ActionTypeId = (int)ActionTypes.AvailableStation, DestinationId = 5 }.BelongsToCall(lookup).Should().BeFalse(); + } + + [Test] + public void personnel_lookup_ignores_unit_custom_states_and_vice_versa() + { + var unitState = UnitCustomState(new CustomStateDetail { CustomStateDetailId = 600, DetailType = (int)CustomStateDetailTypes.Stations }); + var personnelState = PersonnelCustomState(new CustomStateDetail { CustomStateDetailId = 601, DetailType = (int)CustomStateDetailTypes.Stations }); + + var personnelLookup = PersonnelLookup(unitState, personnelState); + + personnelLookup.Should().ContainKey(601); + personnelLookup.Should().NotContainKey(600); + } + + [Test] + public void built_in_unit_states_resolve_to_themselves() + { + CallStatusLinkage.ResolveUnitStateKind((int)UnitStateTypes.OnScene, null).Should().Be(UnitStateTypes.OnScene); + CallStatusLinkage.ResolveUnitStateKind((int)UnitStateTypes.Enroute, new Dictionary()).Should().Be(UnitStateTypes.Enroute); + CallStatusLinkage.ResolveUnitStateKind(20, null).Should().BeNull("20 is inside the built-in range but not a UnitStateTypes value"); + } + + [Test] + public void custom_unit_states_resolve_through_their_base_type() + { + var baseTypes = CallStatusLinkage.BuildUnitBaseTypeMap(new[] + { + UnitCustomState( + new CustomStateDetail { CustomStateDetailId = 700, BaseType = (int)ActionBaseTypes.OnScene }, + new CustomStateDetail { CustomStateDetailId = 701, BaseType = (int)ActionBaseTypes.Responding }, + new CustomStateDetail { CustomStateDetailId = 702, BaseType = (int)ActionBaseTypes.Cleared }, + new CustomStateDetail { CustomStateDetailId = 703, BaseType = (int)ActionBaseTypes.None }, + new CustomStateDetail { CustomStateDetailId = 704, BaseType = (int)ActionBaseTypes.Staging, IsDeleted = true }) + }); + + CallStatusLinkage.ResolveUnitStateKind(700, baseTypes).Should().Be(UnitStateTypes.OnScene); + CallStatusLinkage.ResolveUnitStateKind(701, baseTypes).Should().Be(UnitStateTypes.Responding); + CallStatusLinkage.ResolveUnitStateKind(702, baseTypes).Should().Be(UnitStateTypes.Released); + CallStatusLinkage.ResolveUnitStateKind(703, baseTypes).Should().BeNull(); + CallStatusLinkage.ResolveUnitStateKind(704, baseTypes).Should().Be(UnitStateTypes.Staging, "deleted statuses still resolve for history"); + CallStatusLinkage.ResolveUnitStateKind(799, baseTypes).Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Models/StatusTimestampHelperTests.cs b/Tests/Resgrid.Tests/Models/StatusTimestampHelperTests.cs new file mode 100644 index 000000000..e4916b762 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/StatusTimestampHelperTests.cs @@ -0,0 +1,63 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Helpers; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class StatusTimestampHelperTests + { + private static readonly DateTime Now = new DateTime(2026, 9, 23, 18, 0, 0, DateTimeKind.Utc); + + [Test] + public void an_offline_status_keeps_the_time_it_was_set() + { + var tapped = Now.AddMinutes(-40); + + StatusTimestampHelper.ResolveStatusTimeUtc(tapped, null, Now).Should().Be(tapped); + } + + [Test] + public void an_unzoned_utc_field_is_read_as_utc() + { + var tapped = DateTime.SpecifyKind(Now.AddMinutes(-10), DateTimeKind.Unspecified); + + var result = StatusTimestampHelper.ResolveStatusTimeUtc(tapped, null, Now); + + result.Should().Be(Now.AddMinutes(-10)); + result.Kind.Should().Be(DateTimeKind.Utc); + } + + [Test] + public void a_zoned_device_timestamp_is_used_when_there_is_no_utc_field() + { + var tapped = Now.AddMinutes(-5); + + StatusTimestampHelper.ResolveStatusTimeUtc(null, tapped, Now).Should().Be(tapped); + } + + [Test] + public void an_unzoned_device_timestamp_is_ignored() + { + var local = DateTime.SpecifyKind(Now.AddHours(-7), DateTimeKind.Unspecified); + + StatusTimestampHelper.ResolveStatusTimeUtc(null, local, Now).Should().Be(Now); + } + + [Test] + public void missing_or_implausible_times_fall_back_to_now() + { + StatusTimestampHelper.ResolveStatusTimeUtc(null, null, Now).Should().Be(Now); + StatusTimestampHelper.ResolveStatusTimeUtc(DateTime.MinValue, null, Now).Should().Be(Now); + StatusTimestampHelper.ResolveStatusTimeUtc(Now.AddDays(-8), null, Now).Should().Be(Now); + StatusTimestampHelper.ResolveStatusTimeUtc(Now.AddHours(1), null, Now).Should().Be(Now); + } + + [Test] + public void small_clock_skew_ahead_is_clamped_to_now() + { + StatusTimestampHelper.ResolveStatusTimeUtc(Now.AddMinutes(2), null, Now).Should().Be(Now); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs index e484b5701..fc8d83b94 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs @@ -148,6 +148,55 @@ private IncidentReportsService BuildService(IRecordsUdfService udf = null, IReco _feeds.Object, Protection); } + [Test] + public async Task Start_from_call_reads_unit_times_from_custom_statuses_through_their_base_type() + { + _units.Setup(u => u.GetUnitStatesForCallAsync(Dept, CallId)).ReturnsAsync(new List + { + new UnitState { UnitId = 5, State = 901, Timestamp = LoggedOn.AddMinutes(3) }, + new UnitState { UnitId = 5, State = 902, Timestamp = LoggedOn.AddMinutes(11) }, + new UnitState { UnitId = 5, State = 903, Timestamp = LoggedOn.AddMinutes(55) } + }); + _units.Setup(u => u.GetCustomUnitStateBaseTypesAsync(Dept)).ReturnsAsync(new Dictionary + { + [901] = (int)ActionBaseTypes.Responding, + [902] = (int)ActionBaseTypes.OnScene, + [903] = (int)ActionBaseTypes.Cleared + }); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + + var unit = aggregate.Units.Should().ContainSingle().Subject; + unit.EnrouteOn.Should().Be(LoggedOn.AddMinutes(3)); + unit.OnSceneOn.Should().Be(LoggedOn.AddMinutes(11)); + unit.ClearedOn.Should().Be(LoggedOn.AddMinutes(55)); + } + + [Test] + public async Task Start_from_call_marks_unit_times_from_auto_linked_or_inferred_statuses_as_derived() + { + _units.Setup(u => u.GetUnitStatesForCallAsync(Dept, CallId)).ReturnsAsync(new List + { + new UnitState { UnitId = 5, State = (int)UnitStateTypes.Responding, Timestamp = LoggedOn.AddMinutes(2), DestinationSource = (int)StatusDestinationSources.Explicit }, + new UnitState { UnitId = 5, State = (int)UnitStateTypes.OnScene, Timestamp = LoggedOn.AddMinutes(10), DestinationSource = (int)StatusDestinationSources.CarryForward }, + new UnitState { UnitId = 5, State = (int)UnitStateTypes.Available, Timestamp = LoggedOn.AddMinutes(60), DestinationSource = (int)StatusDestinationSources.Inferred } + }); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + + aggregate.Units.Single().TimesSourceKind.Should().Be((int)RmsSourceKind.Derived); + var facts = aggregate.Facts; + var enroute = facts.Single(f => f.FactKey == NerisFactKeys.UnitTime(5, "enroute_to_scene")); + enroute.SourceKind.Should().Be((int)RmsSourceKind.App); + enroute.SourceSystem.Should().Be("UnitStates"); + var onScene = facts.Single(f => f.FactKey == NerisFactKeys.UnitTime(5, "on_scene")); + onScene.SourceKind.Should().Be((int)RmsSourceKind.Derived); + onScene.SourceSystem.Should().Be("UnitStates (auto-linked)"); + var clear = facts.Single(f => f.FactKey == NerisFactKeys.UnitTime(5, "unit_clear")); + clear.SourceKind.Should().Be((int)RmsSourceKind.Derived); + clear.SourceSystem.Should().Be("UnitStates (inferred)"); + } + [Test] public async Task Start_from_call_prefills_dispatch_facts_with_provenance() { diff --git a/Tests/Resgrid.Tests/Services/ActionLogsCallLinkageTests.cs b/Tests/Resgrid.Tests/Services/ActionLogsCallLinkageTests.cs new file mode 100644 index 000000000..9e600bc1f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ActionLogsCallLinkageTests.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ActionLogsCallLinkageTests + { + private const int DepartmentId = 7; + private const int CallId = 42; + + private Mock _repository; + private Mock _customStates; + private Mock _attribution; + private ActionLogsService _service; + + [SetUp] + public void SetUp() + { + _repository = new Mock(); + _customStates = new Mock(); + _customStates.Setup(x => x.GetDefaultPersonStatuses()).Returns(new CustomStateService(null, null, null, null).GetDefaultPersonStatuses()); + _customStates.Setup(x => x.GetAllCustomStatesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + _attribution = new Mock(); + _attribution.Setup(x => x.GetInferredActionLogsForCallAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + _service = new ActionLogsService(_repository.Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, + new Mock().Object, new Mock().Object, _customStates.Object, new Mock().Object, _attribution.Object); + } + + [Test] + public async Task call_record_includes_responding_and_on_unit_statuses_and_releases() + { + _repository.Setup(x => x.GetActionLogsForCallAsync(DepartmentId, CallId)).ReturnsAsync(new List + { + new ActionLog { ActionLogId = 1, ActionTypeId = (int)ActionTypes.Responding, DestinationId = CallId, DestinationType = (int)DestinationEntityTypes.Call }, + new ActionLog { ActionLogId = 2, ActionTypeId = (int)ActionTypes.OnUnit, DestinationId = CallId, DestinationType = (int)DestinationEntityTypes.Call }, + new ActionLog { ActionLogId = 3, ActionTypeId = (int)ActionTypes.OnScene, DestinationId = CallId, DestinationType = (int)DestinationEntityTypes.Call }, + new ActionLog { ActionLogId = 4, ActionTypeId = (int)ActionTypes.StandingBy, DestinationId = CallId, DestinationType = (int)DestinationEntityTypes.Call } + }); + + var logs = await _service.GetActionLogsForCallAsync(DepartmentId, CallId); + + logs.Select(x => x.ActionLogId).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 }); + } + + [Test] + public async Task call_record_drops_untyped_rows_whose_status_targets_stations() + { + _repository.Setup(x => x.GetActionLogsForCallAsync(DepartmentId, CallId)).ReturnsAsync(new List + { + new ActionLog { ActionLogId = 1, ActionTypeId = (int)ActionTypes.RespondingToScene, DestinationId = CallId }, + new ActionLog { ActionLogId = 2, ActionTypeId = (int)ActionTypes.RespondingToStation, DestinationId = CallId } + }); + + var logs = await _service.GetActionLogsForCallAsync(DepartmentId, CallId); + + logs.Select(x => x.ActionLogId).Should().BeEquivalentTo(new[] { 1 }); + } + + [Test] + public async Task call_record_is_read_for_the_calls_department_only() + { + _repository.Setup(x => x.GetActionLogsForCallAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + await _service.GetActionLogsForCallAsync(DepartmentId, CallId); + + _repository.Verify(x => x.GetActionLogsForCallAsync(DepartmentId, CallId), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs b/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs index d77a3d5ac..65aa33840 100644 --- a/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs @@ -181,7 +181,7 @@ protected with_the_actionLogs_service() }); _actionLogsService = Resolve(); - _actionLogsServiceMocked = new ActionLogsService(_actionLogsRepositoryMock.Object, _usersServiceMock.Object, _departmentMembersRepositoryMock.Object, _departmentGroupsServiceMock.Object, _departmentsServiceMock.Object, _departmentSettingsServiceMock.Object, _eventAggregatorMock.Object, _geoServiceMock.Object, _customStateServiceMock.Object, _cacheProviderMock.Object); + _actionLogsServiceMocked = new ActionLogsService(_actionLogsRepositoryMock.Object, _usersServiceMock.Object, _departmentMembersRepositoryMock.Object, _departmentGroupsServiceMock.Object, _departmentsServiceMock.Object, _departmentSettingsServiceMock.Object, _eventAggregatorMock.Object, _geoServiceMock.Object, _customStateServiceMock.Object, _cacheProviderMock.Object, Mock.Of()); } } diff --git a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs index 567cfa990..f5fc6032f 100644 --- a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs @@ -35,7 +35,7 @@ public void SetUp() .Setup(x => x.GetDepartmentByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new Department { DepartmentId = 7, TimeZone = "UTC" }); _actionLogsService - .Setup(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ActionLog()); _unitsService .Setup(x => x.SetUnitStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -76,8 +76,8 @@ public async Task ApplyDispatchStatusesAsync_uses_default_shift_and_unit_dispatc await _service.ApplyDispatchStatusesAsync(call); - _actionLogsService.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToScene, null, 12, It.IsAny()), Times.Once); - _actionLogsService.Verify(x => x.SetUserActionAsync("user2", 7, (int)ActionTypes.RespondingToScene, null, 12, It.IsAny()), Times.Once); + _actionLogsService.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToScene, null, 12, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + _actionLogsService.Verify(x => x.SetUserActionAsync("user2", 7, (int)ActionTypes.RespondingToScene, null, 12, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); _unitsService.Verify(x => x.SetUnitStateAsync( It.Is(s => s.UnitId == 11 && @@ -109,7 +109,7 @@ public async Task ApplyReleaseStatusesAsync_uses_configured_release_statuses() await _service.ApplyReleaseStatusesAsync(call, new[] { 5 }, new[] { 11 }); - _actionLogsService.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.AvailableStation, null, 22, It.IsAny()), Times.Once); + _actionLogsService.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.AvailableStation, null, 22, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); _unitsService.Verify(x => x.SetUnitStateAsync( It.Is(s => s.UnitId == 11 && @@ -138,7 +138,7 @@ public async Task ApplyDispatchStatusesAsync_skips_shift_personnel_when_auto_sta await _service.ApplyDispatchStatusesAsync(call, new[] { 5 }, new[] { 11 }); _shiftsService.Verify(x => x.GetShiftSignupsByDepartmentGroupIdAndDayAsync(It.IsAny(), It.IsAny()), Times.Never); - _actionLogsService.Verify(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _actionLogsService.Verify(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _unitsService.Verify(x => x.SetUnitStateAsync( It.Is(s => s.UnitId == 11 && diff --git a/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs b/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs new file mode 100644 index 000000000..6e984850b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class CallStatusAttributionServiceTests + { + private const int DepartmentId = 7; + private const int UnitId = 5; + private const string UserId = "user-1"; + private static readonly DateTime T0 = new DateTime(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + + private Mock _calls; + private Mock _unitDispatches; + private Mock _dispatches; + private Mock _groupDispatches; + private Mock _roleDispatches; + private Mock _groupMembers; + private Mock _roleUsers; + private Mock _unitStates; + private Mock _actionLogs; + private Mock _units; + private Mock _customStates; + private CallStatusAttributionService _service; + + [SetUp] + public void SetUp() + { + _calls = new Mock(); + _unitDispatches = new Mock(); + _dispatches = new Mock(); + _groupDispatches = new Mock(); + _roleDispatches = new Mock(); + _groupMembers = new Mock(); + _roleUsers = new Mock(); + _unitStates = new Mock(); + _actionLogs = new Mock(); + _units = new Mock(); + _customStates = new Mock(); + + _customStates.Setup(x => x.GetAllCustomStatesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + _customStates.Setup(x => x.GetDefaultUnitStatuses()).Returns(new CustomStateService(null, null, null, null).GetDefaultUnitStatuses()); + _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _dispatches.Setup(x => x.GetOpenCallIdsForUserAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _units.Setup(x => x.GetAllUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List { new Unit { UnitId = UnitId, DepartmentId = DepartmentId, Name = "Engine 5" } }); + + _service = new CallStatusAttributionService(_calls.Object, _unitDispatches.Object, _dispatches.Object, _groupDispatches.Object, + _roleDispatches.Object, _groupMembers.Object, _roleUsers.Object, _unitStates.Object, _actionLogs.Object, _units.Object, _customStates.Object); + } + + private void Call(int callId, CallStates state = CallStates.Active, int departmentId = DepartmentId, DateTime? closedOn = null) => + _calls.Setup(x => x.GetByIdAsync(callId)).ReturnsAsync(new Call { CallId = callId, DepartmentId = departmentId, State = (int)state, LoggedOn = T0, ClosedOn = closedOn }); + + private static UnitState Previous(UnitStateTypes state, int? callId) => + new UnitState { UnitId = UnitId, State = (int)state, DestinationId = callId, DestinationType = callId.HasValue ? (int)DestinationEntityTypes.Call : (int?)null }; + + [Test] + public async Task a_sent_destination_is_kept_and_marked_explicit() + { + var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.OnScene, DestinationId = 3, DestinationType = (int)DestinationEntityTypes.Station }; + + await _service.AttributeUnitStateAsync(state, Previous(UnitStateTypes.Responding, 42), DepartmentId); + + state.DestinationId.Should().Be(3); + state.DestinationSource.Should().Be((int)StatusDestinationSources.Explicit); + } + + [Test] + public async Task on_scene_without_a_call_keeps_the_open_call_of_the_previous_responding_status() + { + Call(42); + var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.OnScene }; + + await _service.AttributeUnitStateAsync(state, Previous(UnitStateTypes.Responding, 42), DepartmentId); + + state.DestinationId.Should().Be(42); + state.DestinationType.Should().Be((int)DestinationEntityTypes.Call); + state.DestinationSource.Should().Be((int)StatusDestinationSources.CarryForward); + } + + [Test] + public async Task the_clearing_status_that_ends_the_call_is_still_carried_forward() + { + Call(42); + var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Available }; + + await _service.AttributeUnitStateAsync(state, Previous(UnitStateTypes.OnScene, 42), DepartmentId); + + state.DestinationId.Should().Be(42, "the clear time belongs on the call's record"); + } + + [Test] + public async Task a_closed_previous_call_is_not_carried_forward_and_the_dispatch_rule_applies() + { + Call(42, CallStates.Closed); + _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 43 }); + var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; + + await _service.AttributeUnitStateAsync(state, Previous(UnitStateTypes.OnScene, 42), DepartmentId); + + state.DestinationId.Should().Be(43); + state.DestinationSource.Should().Be((int)StatusDestinationSources.Dispatch); + } + + [Test] + public async Task the_dispatch_rule_skips_a_call_the_unit_already_cleared_and_ambiguous_dispatches() + { + Call(42); + _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 42 }); + var afterClearing = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; + + await _service.AttributeUnitStateAsync(afterClearing, Previous(UnitStateTypes.Available, 42), DepartmentId); + afterClearing.DestinationId.Should().BeNull(); + + _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 43, 44 }); + var ambiguous = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; + + await _service.AttributeUnitStateAsync(ambiguous, Previous(UnitStateTypes.Available, null), DepartmentId); + ambiguous.DestinationId.Should().BeNull(); + } + + [Test] + public async Task a_clearing_status_is_never_linked_by_the_dispatch_rule() + { + var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.OutOfService }; + + await _service.AttributeUnitStateAsync(state, Previous(UnitStateTypes.Available, null), DepartmentId); + + state.DestinationId.Should().BeNull(); + _unitDispatches.Verify(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task a_replayed_offline_status_is_left_for_the_read_time_walk() + { + Call(42); + _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 43 }); + _dispatches.Setup(x => x.GetOpenCallIdsForUserAsync(DepartmentId, UserId)).ReturnsAsync(new[] { 43 }); + var replayedState = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow.AddHours(-2), State = (int)UnitStateTypes.OnScene }; + var replayedLog = new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.OnScene, Timestamp = DateTime.UtcNow.AddHours(-2) }; + + await _service.AttributeUnitStateAsync(replayedState, Previous(UnitStateTypes.Responding, 42), DepartmentId); + await _service.AttributeActionLogAsync(replayedLog, new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.Responding, DestinationId = 42, DestinationType = (int)DestinationEntityTypes.Call }); + + replayedState.DestinationId.Should().BeNull("the current open calls say nothing about where the unit was two hours ago"); + replayedLog.DestinationId.Should().BeNull(); + _unitDispatches.Verify(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task attribution_failures_never_block_the_status() + { + _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new TimeoutException()); + var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; + + await _service.Awaiting(s => s.AttributeUnitStateAsync(state, null, DepartmentId)).Should().NotThrowAsync(); + state.DestinationId.Should().BeNull(); + } + + [Test] + public async Task a_person_placed_on_a_unit_takes_the_unit_statuses_call() + { + _unitStates.Setup(x => x.GetUnitStateByUnitStateIdAsync(900)).ReturnsAsync(new UnitState { UnitStateId = 900, UnitId = UnitId, DestinationId = 42, DestinationType = (int)DestinationEntityTypes.Call }); + var log = new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.OnUnit, UnitStateId = 900 }; + + await _service.AttributeActionLogAsync(log, null); + + log.DestinationId.Should().Be(42); + log.DestinationSource.Should().Be((int)StatusDestinationSources.Unit); + } + + [Test] + public async Task a_person_paged_to_one_open_call_is_linked_to_it_but_a_previous_status_from_another_department_is_ignored() + { + Call(42); + _dispatches.Setup(x => x.GetOpenCallIdsForUserAsync(DepartmentId, UserId)).ReturnsAsync(new[] { 43 }); + var otherDepartment = new ActionLog { UserId = UserId, DepartmentId = 99, ActionTypeId = (int)ActionTypes.OnScene, DestinationId = 42, DestinationType = (int)DestinationEntityTypes.Call }; + var log = new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.Responding, Timestamp = DateTime.UtcNow }; + + await _service.AttributeActionLogAsync(log, otherDepartment); + + log.DestinationId.Should().Be(43); + log.DestinationSource.Should().Be((int)StatusDestinationSources.Dispatch); + } + + [Test] + public async Task a_call_record_infers_a_dispatched_units_unlinked_statuses() + { + Call(42, CallStates.Closed, closedOn: T0.AddHours(2)); + _unitDispatches.Setup(x => x.GetCallUnitDispatchesByCallIdAsync(42)).ReturnsAsync(new[] { new CallDispatchUnit { CallId = 42, UnitId = UnitId, DispatchedOn = T0 } }); + _unitStates.Setup(x => x.GetAllUnitStatesForUnitInDateRangeAsync(UnitId, T0, T0.AddHours(2))).ReturnsAsync(new[] + { + new UnitState { UnitStateId = 1, UnitId = UnitId, State = (int)UnitStateTypes.Responding, Timestamp = T0.AddMinutes(1), DestinationId = 42, DestinationType = (int)DestinationEntityTypes.Call }, + new UnitState { UnitStateId = 2, UnitId = UnitId, State = (int)UnitStateTypes.OnScene, Timestamp = T0.AddMinutes(9) } + }); + + var inferred = await _service.GetInferredUnitStatesForCallAsync(DepartmentId, 42); + + inferred.Should().ContainSingle(); + inferred[0].UnitStateId.Should().Be(2); + inferred[0].DestinationSource.Should().Be((int)StatusDestinationSources.Inferred); + inferred[0].Unit.Should().NotBeNull("call views read the unit name"); + } + + [Test] + public async Task a_unit_also_dispatched_to_an_overlapping_call_is_not_inferred_onto_both() + { + Call(42, CallStates.Closed, closedOn: T0.AddHours(2)); + _unitDispatches.Setup(x => x.GetCallUnitDispatchesByCallIdAsync(42)).ReturnsAsync(new[] { new CallDispatchUnit { CallId = 42, UnitId = UnitId, DispatchedOn = T0 } }); + var windows = new[] + { + new CallDispatchWindow { CallId = 42, UnitId = UnitId, DispatchedOn = T0, LoggedOn = T0, ClosedOn = T0.AddHours(2) }, + new CallDispatchWindow { CallId = 43, UnitId = UnitId, DispatchedOn = T0.AddMinutes(20), LoggedOn = T0.AddMinutes(20), ClosedOn = T0.AddMinutes(40) } + }; + _unitDispatches.Setup(x => x.GetUnitDispatchWindowsAsync(DepartmentId, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(windows); + _unitStates.Setup(x => x.GetAllUnitStatesForUnitInDateRangeAsync(UnitId, T0, T0.AddHours(2))).ReturnsAsync(new[] + { + new UnitState { UnitStateId = 1, UnitId = UnitId, State = (int)UnitStateTypes.Responding, Timestamp = T0.AddMinutes(5) }, + new UnitState { UnitStateId = 2, UnitId = UnitId, State = (int)UnitStateTypes.OnScene, Timestamp = T0.AddMinutes(25) }, + new UnitState { UnitStateId = 3, UnitId = UnitId, State = (int)UnitStateTypes.Staging, Timestamp = T0.AddMinutes(50) } + }); + + var inferred = await _service.GetInferredUnitStatesForCallAsync(DepartmentId, 42); + + inferred.Select(x => x.UnitStateId).Should().Equal(new[] { 1, 2, 3 }, "each status follows one already on call 42, as write-time carry-forward would have linked it"); + + // Call 42's walk takes the on-scene status too, and call 43 has nothing of its own before it: nothing is inferred there. + // The states are read from call 42's dispatch so that walk can be followed. + _calls.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(new Call { CallId = 43, DepartmentId = DepartmentId, State = (int)CallStates.Closed, LoggedOn = T0.AddMinutes(20), ClosedOn = T0.AddMinutes(40) }); + _unitDispatches.Setup(x => x.GetCallUnitDispatchesByCallIdAsync(43)).ReturnsAsync(new[] { new CallDispatchUnit { CallId = 43, UnitId = UnitId, DispatchedOn = T0.AddMinutes(20) } }); + _unitStates.Setup(x => x.GetAllUnitStatesForUnitInDateRangeAsync(UnitId, T0, T0.AddMinutes(40))).ReturnsAsync(new[] + { + new UnitState { UnitStateId = 1, UnitId = UnitId, State = (int)UnitStateTypes.Responding, Timestamp = T0.AddMinutes(5) }, + new UnitState { UnitStateId = 2, UnitId = UnitId, State = (int)UnitStateTypes.OnScene, Timestamp = T0.AddMinutes(25) } + }); + + (await _service.GetInferredUnitStatesForCallAsync(DepartmentId, 43)).Should().BeEmpty(); + } + + [Test] + public async Task a_call_from_another_department_infers_nothing() + { + Call(42, departmentId: 99); + + (await _service.GetInferredUnitStatesForCallAsync(DepartmentId, 42)).Should().BeEmpty(); + (await _service.GetInferredActionLogsForCallAsync(DepartmentId, 42)).Should().BeEmpty(); + _unitDispatches.Verify(x => x.GetCallUnitDispatchesByCallIdAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task a_report_over_many_calls_counts_linked_and_inferred_personnel_per_call() + { + var callA = new Call { CallId = 42, DepartmentId = DepartmentId, State = (int)CallStates.Closed, LoggedOn = T0, ClosedOn = T0.AddHours(1) }; + var callB = new Call { CallId = 43, DepartmentId = DepartmentId, State = (int)CallStates.Closed, LoggedOn = T0.AddHours(3), ClosedOn = T0.AddHours(4) }; + _customStates.Setup(x => x.GetDefaultPersonStatuses()).Returns(new CustomStateService(null, null, null, null).GetDefaultPersonStatuses()); + _dispatches.Setup(x => x.GetCallDispatchesForCallsInRangeAsync(DepartmentId, T0, T0.AddHours(3))).ReturnsAsync(new[] + { + new CallDispatch { CallId = 42, UserId = "paged-direct", DispatchedOn = T0 } + }); + _groupDispatches.Setup(x => x.GetCallDispatchGroupsForCallsInRangeAsync(DepartmentId, T0, T0.AddHours(3))).ReturnsAsync(new[] + { + new CallDispatchGroup { CallId = 43, DepartmentGroupId = 3, DispatchedOn = T0.AddHours(3) } + }); + _roleDispatches.Setup(x => x.GetCallDispatchRolesForCallsInRangeAsync(DepartmentId, T0, T0.AddHours(3))).ReturnsAsync(new List()); + _groupMembers.Setup(x => x.GetAllGroupMembersByGroupIdAsync(3)).ReturnsAsync(new[] { new DepartmentGroupMember { DepartmentGroupId = 3, DepartmentId = DepartmentId, UserId = "station" } }); + _actionLogs.Setup(x => x.GetAllActionLogsInDateRangeAsync(DepartmentId, T0, T0.AddHours(4).AddDays(1))).ReturnsAsync(new[] + { + new ActionLog { ActionLogId = 1, UserId = "linked", DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.OnScene, Timestamp = T0.AddMinutes(5), DestinationId = 42, DestinationType = (int)DestinationEntityTypes.Call }, + new ActionLog { ActionLogId = 2, UserId = "paged-direct", DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.Responding, Timestamp = T0.AddMinutes(2) }, + new ActionLog { ActionLogId = 3, UserId = "station", DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.Responding, Timestamp = T0.AddHours(3).AddMinutes(4) } + }); + + var byCall = await _service.GetActionLogsForCallsAsync(DepartmentId, new[] { callA, callB }); + + byCall[42].Select(x => x.UserId).Should().BeEquivalentTo(new[] { "linked", "paged-direct" }); + byCall[42].Single(x => x.UserId == "paged-direct").DestinationSource.Should().Be((int)StatusDestinationSources.Inferred); + byCall[43].Select(x => x.UserId).Should().Equal("station"); + } + + [Test] + public async Task a_call_record_infers_group_paged_members_only_after_they_engage() + { + Call(42, CallStates.Closed, closedOn: T0.AddHours(2)); + _dispatches.Setup(x => x.GetCallDispatchesByCallIdAsync(42)).ReturnsAsync(new List()); + _roleDispatches.Setup(x => x.GetCallRoleDispatchesByCallIdAsync(42)).ReturnsAsync(new List()); + _groupDispatches.Setup(x => x.GetAllCallDispatchGroupByCallIdAsync(42)).ReturnsAsync(new[] { new CallDispatchGroup { CallId = 42, DepartmentGroupId = 3, DispatchedOn = T0 } }); + _groupMembers.Setup(x => x.GetAllGroupMembersByGroupIdAsync(3)).ReturnsAsync(new[] + { + new DepartmentGroupMember { DepartmentGroupId = 3, DepartmentId = DepartmentId, UserId = "responder" }, + new DepartmentGroupMember { DepartmentGroupId = 3, DepartmentId = DepartmentId, UserId = "stayed-home" } + }); + _actionLogs.Setup(x => x.GetAllActionLogsInDateRangeAsync(DepartmentId, T0, T0.AddHours(2))).ReturnsAsync(new[] + { + new ActionLog { ActionLogId = 1, UserId = "stayed-home", DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.StandingBy, Timestamp = T0.AddMinutes(2) }, + new ActionLog { ActionLogId = 2, UserId = "responder", DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.Responding, Timestamp = T0.AddMinutes(3) } + }); + + var inferred = await _service.GetInferredActionLogsForCallAsync(DepartmentId, 42); + + inferred.Select(x => x.ActionLogId).Should().Equal(2); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs index a311db0b6..8b8309fdd 100644 --- a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs +++ b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs @@ -272,7 +272,8 @@ private static UnitsService CreateUnitsService(IEventAggregator eventAggregator, new Mock().Object, new Mock().Object, ProtectedWriteStub(), - new Lazy(() => Mock.Of())); + new Lazy(() => Mock.Of()), + Mock.Of()); } /// diff --git a/Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs b/Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs index d422ed067..784f6d531 100644 --- a/Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryHolderRetentionTests.cs @@ -92,7 +92,7 @@ public async Task Unit_history_blocks_state_deletion_and_respects_transaction_ow events.Object, Mock.Of(), new Lazy>(() => Mock.Of>()), Mock.Of(), new Lazy(() => Mock.Of()), activeRoles.Object, Mock.Of(), limits.Object, Mock.Of(), - new Lazy(() => Mock.Of()), new Lazy(() => Mock.Of()), store.Object, work.Object); + new Lazy(() => Mock.Of()), new Lazy(() => Mock.Of()), Mock.Of(), store.Object, work.Object); var error = (await ((Func)(async () => await service.DeleteUnitAsync(501))).Should().ThrowAsync()).Which; error.Code.Should().Be("HolderHistoryRetained"); error.StatusCode.Should().Be(409); diff --git a/Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs b/Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs index d6667dc77..36d6df1cd 100644 --- a/Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryPr506Tests.cs @@ -135,7 +135,7 @@ public void Every_registered_inventory_trigger_has_a_label_in_each_supported_loc [TestCase(false, false), TestCase(true, true), TestCase(true, false), TestCase(false, true)] public void Unit_retention_dependencies_are_accepted_only_as_a_pair(bool hasStore, bool hasUnit) { - Action construct = () => new UnitsService(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + Action construct = () => new UnitsService(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, hasStore ? Mock.Of() : null, hasUnit ? Mock.Of() : null); if (hasStore == hasUnit) construct.Should().NotThrow(); else construct.Should().Throw(); diff --git a/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs index 187e60273..81b6c7d76 100644 --- a/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs @@ -197,6 +197,22 @@ public void On_scene_minutes_run_from_first_on_scene_to_the_next_state() InvoicingService.OnSceneMinutes(states, t0, t0.AddHours(3)).Should().Be(83); } + [Test] + public void On_scene_window_reports_where_the_billed_time_came_from() + { + var t0 = new DateTime(2026, 9, 18, 8, 0, 0, DateTimeKind.Utc); + List States(int? onSceneSource, int? endSource) => new List + { + new UnitState { UnitId = 1, State = (int)UnitStateTypes.OnScene, Timestamp = t0.AddMinutes(12), DestinationSource = onSceneSource }, + new UnitState { UnitId = 1, State = (int)UnitStateTypes.Available, Timestamp = t0.AddMinutes(95), DestinationSource = endSource } + }; + + InvoicingService.OnSceneWindow(States((int)StatusDestinationSources.Explicit, null), t0, t0.AddHours(3)).Should().Be((83, InvoiceLineTimeSources.UnitStatus)); + InvoicingService.OnSceneWindow(States((int)StatusDestinationSources.CarryForward, (int)StatusDestinationSources.Explicit), t0, t0.AddHours(3)).Source.Should().Be(InvoiceLineTimeSources.AutoLinkedStatus); + InvoicingService.OnSceneWindow(States((int)StatusDestinationSources.Explicit, (int)StatusDestinationSources.Inferred), t0, t0.AddHours(3)).Source.Should().Be(InvoiceLineTimeSources.InferredStatus, "an inferred end status makes the billed window inferred"); + InvoicingService.OnSceneWindow(new List(), t0, t0.AddMinutes(45)).Should().Be((45, InvoiceLineTimeSources.CallWindow)); + } + [Test] public void On_scene_minutes_fall_back_to_the_call_window_and_never_go_negative() { @@ -577,6 +593,29 @@ public async Task Saving_line_items_re_applies_the_rate_card_minimum_charge() _rateCardItems.Verify(r => r.GetByIdForDepartmentAsync("item-1", 7), Times.Once, "the minimum is looked up once per rate card item"); } + [Test] + public async Task Saving_line_items_keeps_the_generated_time_source_and_time_report_of_existing_lines() + { + DraftWithProfile(); + + var first = await Build().SaveInvoiceLineItemsAsync("inv-1", 7, new List + { + new InvoiceLineItem { Description = "Engine 5 on scene", Quantity = 1.5m, UnitRate = 100m, TimeSource = (int)InvoiceLineTimeSources.InferredStatus, DeploymentTimeReportId = "dtr-1" }, + new InvoiceLineItem { Description = "Bogus source", Quantity = 1m, UnitRate = 10m, TimeSource = 99 } + }, "user-1", null, null); + var lineId = first.LineItems.First(l => l.Description == "Engine 5 on scene").InvoiceLineItemId; + + // The editor posts existing lines back without provenance. + await Build().SaveInvoiceLineItemsAsync("inv-1", 7, new List + { + new InvoiceLineItem { InvoiceLineItemId = lineId, Description = "Engine 5 on scene", Quantity = 2m, UnitRate = 100m } + }, "user-1", null, null); + + _lineItems.Verify(r => r.SaveOrUpdateAsync(It.Is(l => l.InvoiceLineItemId == lineId && l.TimeSource == (int)InvoiceLineTimeSources.InferredStatus && l.DeploymentTimeReportId == "dtr-1"), + It.IsAny(), It.IsAny()), Times.Exactly(2)); + _lineItems.Verify(r => r.SaveOrUpdateAsync(It.Is(l => l.Description == "Bogus source" && l.TimeSource == null), It.IsAny(), It.IsAny()), Times.Once); + } + [Test] public async Task Draft_save_commits_header_and_lines_as_one_transaction() { diff --git a/Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs b/Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs index 54afa0e37..167e2963a 100644 --- a/Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs +++ b/Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs @@ -51,7 +51,8 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), new Lazy(() => _protectedWriteService.Object), - new Lazy(() => Mock.Of())); + new Lazy(() => Mock.Of()), + Mock.Of()); } private static UnitState BuildState() => new UnitState diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 0b0cc99db..6fe8aa719 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -355,14 +355,14 @@ public async System.Threading.Tasks.Task should_mark_station_response_from_multi _callsServiceMock.Setup(x => x.GetCallByIdAsync(42, true)).ReturnsAsync(call); _departmentGroupsServiceMock.Setup(x => x.GetAllStationGroupsForDepartmentAsync(7)).ReturnsAsync(stations); _actionLogsServiceMock - .Setup(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToStation, null, selectedStation.DepartmentGroupId, It.IsAny())) + .Setup(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToStation, null, selectedStation.DepartmentGroupId, (int)DestinationEntityTypes.Station, It.IsAny())) .ReturnsAsync(new ActionLog()); var result = await BuildController().VoiceCallRespond("user1", 42, new VoiceRequest { Digits = "13" }); var content = ((ContentResult)result).Content; content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.RespondingToStation(selectedStation.Name))); - _actionLogsServiceMock.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToStation, null, selectedStation.DepartmentGroupId, It.IsAny()), Times.Once); + _actionLogsServiceMock.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToStation, null, selectedStation.DepartmentGroupId, (int)DestinationEntityTypes.Station, It.IsAny()), Times.Once); } [Test] diff --git a/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs index 0cc57e883..832727e0d 100644 --- a/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/UnitsToolProvider.cs @@ -144,7 +144,8 @@ private void RegisterSetUnitStatusTool(McpServer server) ["accessToken"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "OAuth2 access token obtained from authentication" }, ["unitId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "The unique identifier of the unit" }, ["statusType"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Status type code (1=Available, 2=Delayed, 3=Unavailable, 4=Committed, 5=Out Of Service, 6=Responding, 7=On Scene, 8=Staging, 9=Returning, 10=Cancelled, 11=Released, 12=Manual, 13=Enroute)" }, - ["note"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional note about the status change" } + ["note"] = new SchemaBuilder.PropertySchema { Type = "string", Description = "Optional note about the status change" }, + ["callId"] = new SchemaBuilder.PropertySchema { Type = "integer", Description = "Optional id of the call the unit is working, so the status appears on that call's record" } }, new[] { "accessToken", "unitId", "statusType" } ); @@ -176,15 +177,20 @@ private void RegisterSetUnitStatusTool(McpServer server) _logger.LogInformation("Setting status for unit {UnitId}", args.UnitId); + // The tool numbers statuses from 1; UnitStateTypes starts at 0 (Available). The v4 endpoint is + // SaveUnitStatus and takes the unit as Id, the status as a string Type and the call as RespondingTo. var statusData = new { - unitId = args.UnitId, - type = args.StatusType, - note = args.Note + Id = args.UnitId.ToString(), + Type = (args.StatusType - 1).ToString(), + Note = args.Note, + RespondingTo = args.CallId.HasValue && args.CallId.Value > 0 ? args.CallId.Value.ToString() : "0", + RespondingToType = args.CallId.HasValue && args.CallId.Value > 0 ? (int?)2 : null, + Timestamp = DateTime.UtcNow }; var result = await _apiClient.PostAsync( - "/api/v4/UnitStatus/SetUnitStatus", + "/api/v4/UnitStatus/SaveUnitStatus", statusData, args.AccessToken ); @@ -277,6 +283,9 @@ private sealed class SetUnitStatusArgs [JsonProperty("note")] public string Note { get; set; } + + [JsonProperty("callId")] + public int? CallId { get; set; } } } } diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index b37987f45..331823c95 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -944,7 +944,7 @@ public async Task VoiceCallRespond(string userId, int callId, [Fro if (twilioRequest?.Digits == "1") { - await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId); + await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId, (int)DestinationEntityTypes.Call); await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToScene, call.DepartmentId); response.Hangup(); return CreateVoiceContentResult(response); @@ -961,7 +961,7 @@ public async Task VoiceCallRespond(string userId, int callId, [Fro if (station != null) { - await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, station.DepartmentGroupId); + await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, station.DepartmentGroupId, (int)DestinationEntityTypes.Station); await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId); response.Hangup(); return CreateVoiceContentResult(response); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 5fd776958..2088ad962 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -566,15 +566,15 @@ public async Task> GetCallExtraData(int callId var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(call.DepartmentId); - var unitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, callId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); - var actionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, callId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); + var unitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, callId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UnitId).ToList(); + var actionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, callId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UserId).ToList(); var names = await _usersService.GetUserGroupAndRolesByDepartmentIdAsync(DepartmentId, true, true, true); var priority = await _callsService.GetCallPrioritiesByIdAsync(call.DepartmentId, call.Priority, false); var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(call.DepartmentId); var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(call.DepartmentId); var defaultUnitStatuses = _customStateService.GetDefaultUnitStatuses(); - var defaultUserStatuses = await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(call.DepartmentId); + var defaultUserStatuses = _customStateService.GetDefaultPersonStatuses(); if (priority != null) { @@ -607,29 +607,17 @@ public async Task> GetCallExtraData(int callId eventResult.StatusId = actionLog.ActionTypeId; eventResult.Location = actionLog.GeoLocationData; eventResult.Note = actionLog.Note; + eventResult.DestinationSource = actionLog.DestinationSource; - if (actionLog.ActionTypeId <= 25) + var personnelStatus = ResolveStatusDetail(actionLog.ActionTypeId, defaultUserStatuses, customStates); + if (personnelStatus != null) { - var state = defaultUserStatuses.FirstOrDefault(x => x.CustomStateDetailId == actionLog.ActionTypeId); - - if (state != null) - { - eventResult.StatusText = state.ButtonText; - eventResult.StatusColor = state.ButtonColor; - } + eventResult.StatusText = personnelStatus.ButtonText; + eventResult.StatusColor = personnelStatus.ButtonColor; } - else + else if (actionLog.ActionTypeId <= CallStatusLinkage.MaxBuiltInStatusId) { - if (customStates != null && customStates.Count > 0) - { - var state = customStates.Select(state => state.Details.FirstOrDefault(x => x.CustomStateDetailId == actionLog.ActionTypeId)).FirstOrDefault(detail => detail != null); - - if (state != null) - { - eventResult.StatusText = state.ButtonText; - eventResult.StatusColor = state.ButtonColor; - } - } + eventResult.StatusText = actionLog.GetActionText(); } if (String.IsNullOrWhiteSpace(eventResult.StatusText)) @@ -659,29 +647,17 @@ public async Task> GetCallExtraData(int callId eventResult.StatusId = unitLog.State; eventResult.Location = unitLog.GeoLocationData; eventResult.Note = unitLog.Note; + eventResult.DestinationSource = unitLog.DestinationSource; - if (unitLog.UnitStateId <= 12) + var unitStatus = ResolveStatusDetail(unitLog.State, defaultUnitStatuses, customStates); + if (unitStatus != null) { - var state = defaultUnitStatuses.FirstOrDefault(x => x.CustomStateDetailId == unitLog.UnitStateId); - - if (state != null) - { - eventResult.StatusText = state.ButtonText; - eventResult.StatusColor = state.ButtonColor; - } + eventResult.StatusText = unitStatus.ButtonText; + eventResult.StatusColor = unitStatus.ButtonColor; } - else + else if (unitLog.State <= CallStatusLinkage.MaxBuiltInStatusId) { - if (customStates != null && customStates.Count > 0) - { - var state = customStates.Select(state => state.Details.FirstOrDefault(x => x.CustomStateDetailId == unitLog.State)).FirstOrDefault(detail => detail != null); - - if (state != null) - { - eventResult.StatusText = state.ButtonText; - eventResult.StatusColor = state.ButtonColor; - } - } + eventResult.StatusText = unitLog.GetStatusText(); } if (String.IsNullOrWhiteSpace(eventResult.StatusText)) @@ -2052,6 +2028,9 @@ public async Task> GetCallHistory(int callId) if (call.DepartmentId != DepartmentId) return Unauthorized(); + if (!IsSystemApiKeyRequest && !await _authorizationService.CanUserViewCallAsync(UserId, callId)) + return Unauthorized(); + call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); // Attended protected read (plan 7.1): the history entries below embed note text — @@ -2082,32 +2061,22 @@ public async Task> GetCallHistory(int callId) var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(call.DepartmentId); - var unitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, callId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); - var actionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, callId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); + var unitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, callId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UnitId).ToList(); + var actionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, callId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UserId).ToList(); var names = await _usersService.GetUserGroupAndRolesByDepartmentIdAsync(DepartmentId, true, true, true); var priority = await _callsService.GetCallPrioritiesByIdAsync(call.DepartmentId, call.Priority, false); var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(call.DepartmentId); var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(call.DepartmentId); var defaultUnitStatuses = _customStateService.GetDefaultUnitStatuses(); - var defaultUserStatuses = await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(call.DepartmentId); + var defaultUserStatuses = _customStateService.GetDefaultPersonStatuses(); foreach (var actionLog in actionLogs) { var nameInfo = names.FirstOrDefault(x => x.UserId == actionLog.UserId); - CustomStateDetail state = null; - if (actionLog.ActionTypeId <= 25) - { - state = defaultUserStatuses.FirstOrDefault(x => x.CustomStateDetailId == actionLog.ActionTypeId); - } - else - { - if (customStates != null && customStates.Count > 0) - { - state = customStates.Select(state => state.Details.FirstOrDefault(x => x.CustomStateDetailId == actionLog.ActionTypeId)).FirstOrDefault(detail => detail != null); - } - } + var state = ResolveStatusDetail(actionLog.ActionTypeId, defaultUserStatuses, customStates); + var statusText = state?.ButtonText ?? (actionLog.ActionTypeId <= CallStatusLinkage.MaxBuiltInStatusId ? actionLog.GetActionText() : "Unknown"); if (nameInfo != null) { @@ -2117,25 +2086,17 @@ public async Task> GetCallHistory(int callId) TimestampUtc = actionLog.Timestamp, Timestamp = actionLog.Timestamp.TimeConverter(department), Type = 2, - Info = $"{nameInfo.LastName},{nameInfo.FirstName} set status to {state.ButtonText} at {actionLog.GeoLocationData}" + Info = $"{nameInfo.LastName},{nameInfo.FirstName} set status to {statusText} at {actionLog.GeoLocationData}{LinkSuffix(actionLog.DestinationSource)}", + DestinationSource = actionLog.DestinationSource }); } } foreach (var unitLog in unitStates) { - CustomStateDetail state = null; - if (unitLog.UnitStateId <= 12) - { - state = defaultUnitStatuses.FirstOrDefault(x => x.CustomStateDetailId == unitLog.UnitStateId); - } - else - { - if (customStates != null && customStates.Count > 0) - { - state = customStates.Select(state => state.Details.FirstOrDefault(x => x.CustomStateDetailId == unitLog.State)).FirstOrDefault(detail => detail != null); - } - } + var state = ResolveStatusDetail(unitLog.State, defaultUnitStatuses, customStates); + var statusText = state?.ButtonText ?? (unitLog.State <= CallStatusLinkage.MaxBuiltInStatusId ? unitLog.GetStatusText() : "Unknown"); + var unitName = unitLog.Unit?.Name ?? units?.FirstOrDefault(x => x.UnitId == unitLog.UnitId)?.Name ?? "Unknown Unit"; result.Data.Add(new CallHistoryResultData() { @@ -2143,7 +2104,8 @@ public async Task> GetCallHistory(int callId) TimestampUtc = unitLog.Timestamp, Timestamp = unitLog.Timestamp.TimeConverter(department), Type = 3, - Info = $"{unitLog.Unit.Name} set status to {state.ButtonText} at {unitLog.GeoLocationData}" + Info = $"{unitName} set status to {statusText} at {unitLog.GeoLocationData}{LinkSuffix(unitLog.DestinationSource)}", + DestinationSource = unitLog.DestinationSource }); } @@ -2488,6 +2450,33 @@ private static string FirstNonEmpty(params string[] values) return values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); } + /// + /// Marks history text for statuses that reached the call without the sender naming it. + /// + private static string LinkSuffix(int? destinationSource) + { + if (CallStatusAttribution.IsInferred(destinationSource)) + return " (inferred)"; + + if (CallStatusAttribution.IsAutoLinked(destinationSource)) + return " (auto-linked)"; + + return string.Empty; + } + + /// + /// Resolves a raw unit state / personnel action type (built-in value or custom status detail id) to its + /// status detail for labelling. Returns null when the status can't be resolved (e.g. a built-in value with + /// no default button, or a custom detail from another department). + /// + private static CustomStateDetail ResolveStatusDetail(int rawStatus, List builtInStatuses, List customStates) + { + if (rawStatus <= CallStatusLinkage.MaxBuiltInStatusId) + return builtInStatuses?.FirstOrDefault(x => x.CustomStateDetailId == rawStatus); + + return customStates?.Where(x => x?.Details != null).SelectMany(x => x.Details).FirstOrDefault(x => x != null && x.CustomStateDetailId == rawStatus); + } + private async Task GetValidatedDestinationPoiAsync(int? destinationPoiId, int? departmentIdOverride = null) { if (!destinationPoiId.HasValue || destinationPoiId.Value <= 0) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs index ed25cc67e..ad38bae99 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs @@ -347,70 +347,24 @@ public async Task> GetSetUnitStatusData(stri } else { - var customStateResult = new CustomStatusResultData(); - customStateResult.Id = "0"; - customStateResult.Type = 0; - customStateResult.StateId = "0"; - customStateResult.Text = "Available"; - customStateResult.BColor = "#FFFFFF"; - customStateResult.Color = "#000000"; - customStateResult.Gps = false; - customStateResult.Note = 0; - customStateResult.Detail = 0; - - result.Data.Statuses.Add(customStateResult); - - var customStateResult2 = new CustomStatusResultData(); - customStateResult2.Id = "3"; - customStateResult2.Type = 3; - customStateResult2.StateId = "3"; - customStateResult2.Text = "Committed"; - customStateResult2.BColor = "#FFFFFF"; - customStateResult2.Color = "#000000"; - customStateResult2.Gps = false; - customStateResult2.Note = 0; - customStateResult2.Detail = 0; - - result.Data.Statuses.Add(customStateResult2); - - var customStateResult3 = new CustomStatusResultData(); - customStateResult3.Id = "1"; - customStateResult3.Type = 1; - customStateResult3.StateId = "1"; - customStateResult3.Text = "Delayed"; - customStateResult3.BColor = "#FFFFFF"; - customStateResult3.Color = "#000000"; - customStateResult3.Gps = false; - customStateResult3.Note = 0; - customStateResult3.Detail = 0; - - result.Data.Statuses.Add(customStateResult3); - - var customStateResult4 = new CustomStatusResultData(); - customStateResult4.Id = "4"; - customStateResult4.Type = 4; - customStateResult4.StateId = "4"; - customStateResult4.Text = "Out Of Service"; - customStateResult4.BColor = "#FFFFFF"; - customStateResult4.Color = "#000000"; - customStateResult4.Gps = false; - customStateResult4.Note = 0; - customStateResult4.Detail = 0; - - result.Data.Statuses.Add(customStateResult4); - - var customStateResult5 = new CustomStatusResultData(); - customStateResult5.Id = "2"; - customStateResult5.Type = 2; - customStateResult5.StateId = "2"; - customStateResult5.Text = "Unavailable"; - customStateResult5.BColor = "#FFFFFF"; - customStateResult5.Color = "#000000"; - customStateResult5.Gps = false; - customStateResult5.Note = 0; - customStateResult5.Detail = 0; - - result.Data.Statuses.Add(customStateResult5); + // Units without a custom status set use the same built-in statuses (and destination settings) that + // /Statuses/GetAllUnitStatuses serves as the "0" group, so Responding / On Scene / Staging prompt for + // a call here too. Id, Type and StateId all carry the UnitStateTypes value, as before. + foreach (var defaultStatus in _customStateService.GetDefaultUnitStatuses()) + { + var customStateResult = new CustomStatusResultData(); + customStateResult.Id = defaultStatus.CustomStateDetailId.ToString(); + customStateResult.Type = defaultStatus.CustomStateDetailId; + customStateResult.StateId = defaultStatus.CustomStateDetailId.ToString(); + customStateResult.Text = defaultStatus.ButtonText; + customStateResult.BColor = defaultStatus.ButtonColor; + customStateResult.Color = defaultStatus.TextColor; + customStateResult.Gps = defaultStatus.GpsRequired; + customStateResult.Note = defaultStatus.NoteType; + customStateResult.Detail = defaultStatus.DetailType; + + result.Data.Statuses.Add(customStateResult); + } } result.PageSize = 1; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs index a4ab73f7e..6bfcb196c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs @@ -338,7 +338,7 @@ private static InvoiceResultData Convert(Invoice invoice, IReadOnlyDictionary()).OrderBy(x => x.SortOrder).Select(x => new InvoiceLineItemData { - InvoiceLineItemId = x.InvoiceLineItemId, CallId = x.CallId, Description = x.Description, Quantity = x.Quantity, UnitRate = x.UnitRate, Amount = x.Amount, Taxable = x.Taxable, SortOrder = x.SortOrder + InvoiceLineItemId = x.InvoiceLineItemId, CallId = x.CallId, Description = x.Description, Quantity = x.Quantity, UnitRate = x.UnitRate, Amount = x.Amount, Taxable = x.Taxable, SortOrder = x.SortOrder, TimeSource = x.TimeSource }).ToList(); data.Payments = (invoice.Payments ?? new List()).OrderBy(x => x.PaidOn).Select(x => new InvoicePaymentData { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs index 97e5d80a7..8f6fff72c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStatusesController.cs @@ -152,9 +152,12 @@ public async Task> SavePersonStatus(SavePer if (!String.IsNullOrWhiteSpace(input.Latitude) && !String.IsNullOrWhiteSpace(input.Longitude)) geolocation = $"{input.Latitude},{input.Longitude}"; + if (!int.TryParse(input.Type, out var statusType)) + return BadRequest(); + ActionLog log = null; if (String.IsNullOrWhiteSpace(input.RespondingTo) || input.RespondingTo == "0") - log = await _actionLogsService.SetUserActionAsync(input.UserId, DepartmentId, int.Parse(input.Type), geolocation, cancellationToken); + log = await SavePersonStatusAsync(input.UserId, statusType, geolocation, null, null, input.Note, input.TimestampUtc, input.Timestamp, cancellationToken); else { if (!int.TryParse(input.RespondingTo, out var destinationId)) @@ -165,7 +168,7 @@ public async Task> SavePersonStatus(SavePer if (!await IsValidDestinationAsync(destinationId, destinationType)) return BadRequest(); - log = await _actionLogsService.SetUserActionAsync(input.UserId, DepartmentId, int.Parse(input.Type), geolocation, destinationId, destinationType, input.Note, cancellationToken); + log = await SavePersonStatusAsync(input.UserId, statusType, geolocation, destinationId, destinationType, input.Note, input.TimestampUtc, input.Timestamp, cancellationToken); // Entity-need response: a member that command requested answering the call lands on the // incident log. Best-effort — never blocks the status save. @@ -218,6 +221,9 @@ public async Task> SavePersonsStatuses(S if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && input.UserIds.Any(x => x != UserId)) return Unauthorized(); + if (!int.TryParse(input.Type, out var statusType)) + return BadRequest(); + List logIds = new List(); foreach (var userId in input.UserIds) { @@ -241,7 +247,7 @@ public async Task> SavePersonsStatuses(S ActionLog log = null; if (String.IsNullOrWhiteSpace(input.RespondingTo) || input.RespondingTo == "0") - log = await _actionLogsService.SetUserActionAsync(userId, DepartmentId, int.Parse(input.Type), geolocation, cancellationToken); + log = await SavePersonStatusAsync(userId, statusType, geolocation, null, null, input.Note, input.TimestampUtc, input.Timestamp, cancellationToken); else { if (!int.TryParse(input.RespondingTo, out var destinationId)) @@ -252,7 +258,7 @@ public async Task> SavePersonsStatuses(S if (!await IsValidDestinationAsync(destinationId, destinationType)) continue; - log = await _actionLogsService.SetUserActionAsync(userId, DepartmentId, int.Parse(input.Type), geolocation, destinationId, destinationType, input.Note, cancellationToken); + log = await SavePersonStatusAsync(userId, statusType, geolocation, destinationId, destinationType, input.Note, input.TimestampUtc, input.Timestamp, cancellationToken); } logIds.Add(log.ActionLogId.ToString()); @@ -303,6 +309,29 @@ public static GetCurrentStatusResultData ConvertPersonStatus(ActionLog actionLog return statusResult; } + /// + /// Writes a personnel status. The note is kept whether or not a destination was picked, and the status is + /// stamped with the time the member set it (so an offline-queued status replayed later keeps its real + /// time), falling back to now when the client time is missing or implausible. + /// + private async Task SavePersonStatusAsync(string userId, int statusType, string geolocation, int? destinationId, int? destinationType, + string note, DateTime? timestampUtc, DateTime? timestamp, CancellationToken cancellationToken) + { + var log = new ActionLog + { + UserId = userId, + DepartmentId = DepartmentId, + ActionTypeId = statusType, + GeoLocationData = geolocation, + DestinationId = destinationId, + DestinationType = destinationType, + Note = note, + Timestamp = StatusTimestampHelper.ResolveStatusTimeUtc(timestampUtc, timestamp, DateTime.UtcNow) + }; + + return await _actionLogsService.SaveActionLogAsync(log, cancellationToken); + } + private async Task IsValidDestinationAsync(int destinationId, int destinationType) { var entityType = (DestinationEntityTypes)destinationType; diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallExtraDataResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallExtraDataResult.cs index ed16ae70d..487cb4fc3 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallExtraDataResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallExtraDataResult.cs @@ -78,5 +78,13 @@ public class DispatchedEventResultData public string Location { get; set; } public string StatusText { get; set; } public string StatusColor { get; set; } + + /// + /// For status activity: how the status came to be on this call (StatusDestinationSources: 1 chosen by the sender, + /// 2 carried forward from the previous status's call, 3 from the one open dispatch, 4 from the unit the person rode, + /// 5 inferred from a dispatched unit's/person's status that named no destination). Null for older rows and for + /// non-status entries. + /// + public int? DestinationSource { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallHistoryResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallHistoryResult.cs index 8a1d656d2..5f5e7cc99 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallHistoryResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallHistoryResult.cs @@ -55,5 +55,10 @@ public class CallHistoryResultData /// Text describing the history event /// public string Info { get; set; } + + /// + /// For status events: how the status came to be on this call (see DispatchedEventResultData.DestinationSource). + /// + public int? DestinationSource { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs index 734c974cd..a440e19f0 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs @@ -91,6 +91,12 @@ public class InvoiceLineItemData public decimal Amount { get; set; } public bool Taxable { get; set; } public int SortOrder { get; set; } + + /// + /// Generated hourly unit lines: where the on-scene time came from (InvoiceLineTimeSources: 1 the unit's own status, + /// 2 a status Resgrid auto-linked to the call, 3 a status inferred for the call, 4 the call window). Null otherwise. + /// + public int? TimeSource { get; set; } } public class InvoicePaymentData diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index a9da096a7..e0336c158 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -469,6 +469,18 @@ A redacted name part collapses the whole display name to the REDACTED placeholder. + + + Marks history text for statuses that reached the call without the sender naming it. + + + + + Resolves a raw unit state / personnel action type (built-in value or custom status detail id) to its + status detail for labelling. Returns null when the status can't be resolved (e.g. a built-in value with + no default button, or a custom detail from another department). + + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department @@ -2877,6 +2889,13 @@ The cancellation token that can be used by other objects or threads to receive notice of cancellation. ActionResult. + + + Writes a personnel status. The note is kept whether or not a destination was picked, and the status is + stamped with the time the member set it (so an offline-queued status replayed later keeps its real + time), falling back to now when the client time is missing or implausible. + + The options for Protocols in the Resgrid system @@ -7700,6 +7719,14 @@ Default constructor + + + For status activity: how the status came to be on this call (StatusDestinationSources: 1 chosen by the sender, + 2 carried forward from the previous status's call, 3 from the one open dispatch, 4 from the unit the person rode, + 5 inferred from a dispatched unit's/person's status that named no destination). Null for older rows and for + non-status entries. + + Gets the history of all actions taken during a call @@ -7745,6 +7772,11 @@ Text describing the history event + + + For status events: how the status came to be on this call (see DispatchedEventResultData.DestinationSource). + + Depicts a call in the Resgrid system. @@ -12163,6 +12195,12 @@ Populated by GetInvoice only. + + + Generated hourly unit lines: where the on-scene time came from (InvoiceLineTimeSources: 1 the unit's own status, + 2 a status Resgrid auto-linked to the call, 3 a status inferred for the call, 4 the call window). Null otherwise. + + InvoicePaymentMethods value (Check 0, Cash 1, Ach 2, CardExternal 3, Other 4, Online 5). diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 4c080255d..4f1e31298 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -2286,8 +2286,8 @@ public async Task CallExport(int callId) await _protectedReadService.ResolveCallLogsForReadAsync(DepartmentId, model.CallLogs, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(model.Call.DepartmentId, false); - model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(model.Call.DepartmentId, callId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); - model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(model.Call.DepartmentId, callId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); + model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(model.Call.DepartmentId, callId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UnitId).ToList(); + model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(model.Call.DepartmentId, callId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UserId).ToList(); model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); model.Units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true); @@ -2353,8 +2353,8 @@ public async Task CallExportEx(string query) await _protectedReadService.ResolveCallLogsForReadAsync(call.DepartmentId, model.CallLogs, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(call.DepartmentId, false); - model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); - model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); + model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UnitId).ToList(); + model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, call.CallId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UserId).ToList(); model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(call.DepartmentId); model.Units = await _unitsService.GetUnitsForDepartmentAsync(call.DepartmentId); model.Names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(call.DepartmentId); @@ -2392,8 +2392,8 @@ await _protectedReadService.ResolveCallLogsForReadAsync(call.DepartmentId, model await _protectedReadService.ResolveCallLogsForReadAsync(call.DepartmentId, model.CallLogs, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(call.DepartmentId, false); - model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); - model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); + model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UnitId).ToList(); + model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, call.CallId)).OrderBy(y => y.Timestamp).ThenBy(x => x.UserId).ToList(); model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(call.DepartmentId); model.Units = await _unitsService.GetUnitsForDepartmentAsync(call.DepartmentId); model.Names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(call.DepartmentId); @@ -3427,6 +3427,10 @@ private async Task FillViewCallView(ViewCallView model) model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(model.Call.DepartmentId, model.Call.CallId)).OrderBy(y => y.Timestamp).ToList(); model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(model.Call.DepartmentId, model.Call.CallId)).OrderBy(y => y.Timestamp).ToList(); + // The call-scoped state query joins the unit but not its station, which the Units tab's Group column reads. + foreach (var unitState in model.UnitStates.Where(x => x.Unit != null && x.Unit.StationGroup == null && x.Unit.StationGroupId.HasValue)) + unitState.Unit.StationGroup = model.Groups?.FirstOrDefault(g => g.DepartmentGroupId == unitState.Unit.StationGroupId.Value); + model.UserGroupRoles = await _usersService.GetUserGroupAndRolesByDepartmentIdAsync(model.Call.DepartmentId, true, true, true); var allUsers = await _departmentsService.GetAllUsersForDepartmentAsync(model.Department.DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 650c7b641..11991c0a9 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -1195,8 +1195,21 @@ public async Task SetUserState(DashboardModel model) [Authorize(Policy = ResgridResources.Department_View)] public async Task UserRespondingToStation(int stationId) { - await _actionLogsService.SetUserActionAsync(UserId, (await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId, - (int)ActionTypes.RespondingToStation, null, stationId); + if (stationId > 0) + { + // Only a station group of this department is recorded as the destination, typed so it can never be read as a + // call. A named station that doesn't resolve is refused rather than saved without one, which would let call + // attribution link the status to a call the member never picked. + var station = await _departmentGroupsService.GetGroupByIdAsync(stationId); + + if (station == null || station.DepartmentId != DepartmentId || station.Type != (int)DepartmentGroupTypes.Station) + return Unauthorized(); + + await _actionLogsService.SetUserActionAsync(UserId, DepartmentId, (int)ActionTypes.RespondingToStation, null, + station.DepartmentGroupId, (int)DestinationEntityTypes.Station); + } + else + await _actionLogsService.SetUserActionAsync(UserId, DepartmentId, (int)ActionTypes.RespondingToStation, null); return RedirectToAction("Dashboard", "Home", new { area = "User" }); } @@ -1205,11 +1218,19 @@ await _actionLogsService.SetUserActionAsync(UserId, (await _departmentsService.G public async Task UserRespondingToCall(int callId) { if (callId > 0) - await _actionLogsService.SetUserActionAsync(UserId, (await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId, - (int)ActionTypes.RespondingToScene, null, callId); + { + // Only a call of this department is recorded as the destination. A named call that doesn't resolve is refused + // rather than saved without one, which would let call attribution link the status to a different call. + var call = await _callsService.GetCallByIdAsync(callId); + + if (call == null || call.DepartmentId != DepartmentId) + return Unauthorized(); + + await _actionLogsService.SetUserActionAsync(UserId, DepartmentId, (int)ActionTypes.RespondingToScene, null, + call.CallId, (int)DestinationEntityTypes.Call); + } else - await _actionLogsService.SetUserActionAsync(UserId, (await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId, - (int)ActionTypes.RespondingToScene, null); + await _actionLogsService.SetUserActionAsync(UserId, DepartmentId, (int)ActionTypes.RespondingToScene, null); return RedirectToAction("Dashboard", "Home", new { area = "User" }); } @@ -1225,7 +1246,16 @@ public async Task SetStateForUser(string userId, UserStateTypes s [Authorize(Policy = ResgridResources.Department_View)] public async Task SetActionForUser(string userId, int actionType) { - await _actionLogsService.SetUserActionAsync(userId, (await _departmentsService.GetDepartmentByUserIdAsync(UserId)).DepartmentId, actionType); + // Same rule as SetCustomUserAction: members of this department only, and only an admin sets someone else's status. + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId); + + if (member == null) + return Unauthorized(); + + if (userId != UserId && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + await _actionLogsService.SetUserActionAsync(userId, DepartmentId, actionType); return RedirectToAction("Dashboard", "Home", new { area = "User" }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs index bcb1ede91..e8c61389b 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs @@ -294,6 +294,7 @@ public async Task Save(InvoiceHeaderInput input, CancellationToke Quantity = l.Quantity, UnitRate = l.UnitRate, Taxable = l.Taxable, + TimeSource = l.TimeSource, SortOrder = order++ }).ToList(); var saved = await _invoicing.SaveDraftAsync(invoice, lineItems, UserId, Ip, UserAgent, cancellationToken); @@ -532,7 +533,7 @@ public async Task PreviewCallLines(string id, int callId, string try { var lines = await _invoicing.GenerateLineItemsFromCallAsync(callId, rateCardId, DepartmentId); - return Json(lines.Select(l => new { callId = l.CallId, rateCardItemId = l.RateCardItemId, description = l.Description, quantity = l.Quantity, unitRate = l.UnitRate, taxable = l.Taxable })); + return Json(lines.Select(l => new { callId = l.CallId, rateCardItemId = l.RateCardItemId, description = l.Description, quantity = l.Quantity, unitRate = l.UnitRate, taxable = l.Taxable, timeSource = l.TimeSource })); } catch (Exception ex) when (IsInvoicingError(ex)) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 33a555b4c..4fbf4dfc5 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -2406,6 +2406,7 @@ public async Task GeneratePersonnelEventsReport(IFormCollection f var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); + var actionLogs = new List(); foreach (var eventId in eventIds) { var actionLog = await _actionLogsService.GetActionLogByIdAsync(eventId); @@ -2418,6 +2419,15 @@ public async Task GeneratePersonnelEventsReport(IFormCollection f if (!await _authorizationService.CanUserViewPersonAsync(UserId, actionLog.UserId, DepartmentId)) continue; + actionLogs.Add(actionLog); + } + + var calls = await ReferencedCallsHelper.AddReferencedCallsAsync(_callsService, DepartmentId, activeCalls, actionLogs + .Where(x => x.DestinationId.HasValue && x.GetEffectiveDestinationType() != DestinationEntityTypes.Station && x.GetEffectiveDestinationType() != DestinationEntityTypes.Poi) + .Select(x => x.DestinationId.Value)); + + foreach (var actionLog in actionLogs) + { var personnelEvent = new PersonnelEventJson(); personnelEvent.EventId = actionLog.ActionLogId; personnelEvent.UserId = actionLog.UserId; @@ -2430,7 +2440,7 @@ public async Task GeneratePersonnelEventsReport(IFormCollection f personnelEvent.Timestamp = actionLog.Timestamp.TimeConverterToString(model.Department); personnelEvent.Note = actionLog.Note; - var destination = DestinationResolutionHelper.Resolve(actionLog.DestinationId, actionLog.DestinationType, statusDetail?.DetailType, activeCalls, stations, pois, _localizer); + var destination = DestinationResolutionHelper.Resolve(actionLog.DestinationId, actionLog.DestinationType, statusDetail?.DetailType, calls, stations, pois, _localizer); personnelEvent.DestinationName = destination.Name; var coordinates = actionLog.GetCoordinates(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index dd0a4e3f6..5e81f25fd 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -12,6 +12,7 @@ using Resgrid.Config; using Resgrid.Model; using Resgrid.Model.Helpers; +using Resgrid.Model.Reporting; using Resgrid.Model.Services; using Resgrid.Providers.Claims; using Resgrid.Web.Areas.User.Models; @@ -61,6 +62,7 @@ public partial class ReportsController : SecureBaseController // totals reconcile across the Records cutover (RMS plan section 4.10). private readonly IRecordsReportingService _recordsReporting; private readonly IBusinessOperationsAccessService _businessOperationsAccess; + private readonly ICallStatusAttributionService _callStatusAttributionService; public ReportsController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, @@ -74,7 +76,7 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u IUnitsService unitsService, IUnitStatesService unitStatesService, ICalendarService calendarService, IDepartmentMemberSensitiveDataService memberSensitiveDataService, IProtectedReadService protectedReadService, IRecordsReportingService recordsReporting, - IBusinessOperationsAccessService businessOperationsAccess) + IBusinessOperationsAccessService businessOperationsAccess, ICallStatusAttributionService callStatusAttributionService) { _departmentsService = departmentsService; _usersService = usersService; @@ -99,6 +101,7 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u _calendarService = calendarService; _recordsReporting = recordsReporting; _businessOperationsAccess = businessOperationsAccess; + _callStatusAttributionService = callStatusAttributionService; } #endregion Private Members and Constructors @@ -559,6 +562,79 @@ public async Task CallSummaryReportParams(PersonnelHoursReportPar return RedirectToAction("CallSummaryReport", new { start = model.Start, end = model.End }); } + [HttpGet] + [Authorize(Policy = ResgridResources.Reports_View)] + public async Task CallUnitTimesReportParams() + { + var model = new PersonnelHoursReportParams(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var today = DateTime.UtcNow.TimeConverter(department).Date; + + model.Start = today.AddDays(-7); + model.End = today.AddDays(1).AddSeconds(-1); + + return View(model); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Reports_View)] + public async Task CallUnitTimesReportParams(PersonnelHoursReportParams model) + { + return RedirectToAction("CallUnitTimesReport", new { start = model.Start, end = model.End }); + } + + /// + /// Dispatched / en route / on scene / staging / cleared per unit per call, for a period or (callId) one call. + /// + [HttpGet] + [Authorize(Policy = ResgridResources.Reports_View)] + public async Task CallUnitTimesReport(DateTime? start, DateTime? end, int? callId) + { + if (callId.HasValue && callId.Value > 0 && !await _authorizationService.CanUserViewCallAsync(UserId, callId.Value)) + return Unauthorized(); + + var model = await CallUnitTimesReportModel(DepartmentId, start, end, callId); + if (model == null) + return NotFound(); + + return View(model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Reports_View)] + public async Task CallUnitTimesReportCsv(DateTime? start, DateTime? end, int? callId) + { + if (callId.HasValue && callId.Value > 0 && !await _authorizationService.CanUserViewCallAsync(UserId, callId.Value)) + return Unauthorized(); + + var model = await CallUnitTimesReportModel(DepartmentId, start, end, callId); + if (model == null) + return NotFound(); + + string Time(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(model.Department) : string.Empty; + + var csv = new StringBuilder(); + csv.AppendLine(string.Join(",", new[] { "Call Number", "Call Name", "Call Type", "Logged On", "Closed On", "Unit", "Group", + "Dispatched", "En Route", "On Scene", "Staging", "Cleared", "Linked By" }.Select(CsvCell))); + + foreach (var call in model.Calls) + { + foreach (var unit in call.Units) + { + csv.AppendLine(string.Join(",", new[] + { + call.Number, call.Name, call.Type, Time(call.LoggedOn), Time(call.ClosedOn), unit.UnitName, unit.Group, + Time(unit.Times.DispatchedOn), Time(unit.Times.EnrouteOn), Time(unit.Times.OnSceneOn), Time(unit.Times.StagingOn), + Time(unit.Times.ClearedOn), unit.Times.Source.ToString() + }.Select(CsvCell))); + } + } + + var fileName = callId.HasValue && callId.Value > 0 ? $"call-unit-times-{callId.Value}.csv" : "call-unit-times.csv"; + return File(new UTF8Encoding(true).GetBytes(csv.ToString()), "text/csv", fileName); + } + [HttpGet] [Authorize(Policy = ResgridResources.Reports_View)] public async Task ActiveCallsResourcesReport() @@ -1602,9 +1678,13 @@ private async Task CallSummaryReportModel(int departmentId, Dat model.End = new DateTime(DateTime.UtcNow.Year, 12, 31, 23, 59, 59); } - var calls = await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, start.Value, end.Value); - var logs = await _logService.GetAllLogsByDepartmentDateRangeAsync(departmentId, LogTypes.Run, model.Start, - model.End); + // The range is picked in department-local time; the call and log timestamps are UTC. + var startUtc = model.Start.DepartmentLocalToUtc(model.Department); + var endUtc = model.End.DepartmentLocalToUtc(model.Department); + + var calls = await _callsService.GetAllCallsByDepartmentDateRangeAsync(departmentId, startUtc, endUtc); + var logs = await _logService.GetAllLogsByDepartmentDateRangeAsync(departmentId, LogTypes.Run, startUtc, + endUtc); model.TotalCalls = calls.Count; model.CallTypeCount = new List>(); @@ -1632,6 +1712,13 @@ private async Task CallSummaryReportModel(int departmentId, Dat model.CallCloseCount.Add(new Tuple("Total", calls.Count)); + // Calls without a run log (most departments never write one, and none can once Records is active) take + // their unit/personnel counts and first on-scene time from the status history linked to the call. + var callsWithoutLogs = calls.Where(c => !logs.Any(l => l.CallId == c.CallId)).ToList(); + var statusActivity = callsWithoutLogs.Count > 0 + ? await GetCallStatusActivityAsync(departmentId, callsWithoutLogs, startUtc, endUtc) + : new Dictionary(); + foreach (var call in calls) { var summary = new CallSummary(); @@ -1668,6 +1755,12 @@ private async Task CallSummaryReportModel(int departmentId, Dat summary.FirstOnSceneTime = onSceneTime; } + else if (statusActivity.TryGetValue(call.CallId, out var activity)) + { + summary.FirstOnSceneTime = activity.FirstOnScene; + summary.UnitsCount = activity.Units; + summary.PersonnelCount = activity.Personnel; + } else { summary.FirstOnSceneTime = null; @@ -1681,6 +1774,129 @@ private async Task CallSummaryReportModel(int departmentId, Dat return model; } + /// + /// Per call: distinct units and personnel with a status on the call's record (linked or inferred, see + /// CallStatusAttribution), and the first unit on-scene time (custom statuses count through their base type). Status + /// history is read once for the report window rather than once per call. + /// + private async Task> GetCallStatusActivityAsync(int departmentId, + List calls, DateTime startUtc, DateTime endUtc) + { + var callIds = calls.Select(c => c.CallId).ToHashSet(); + var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId); + var unitBaseTypes = CallStatusLinkage.BuildUnitBaseTypeMap(customStates); + + var statesByCall = await _callStatusAttributionService.GetUnitStatesForCallsAsync(departmentId, calls); + var logsByCall = await _callStatusAttributionService.GetActionLogsForCallsAsync(departmentId, calls); + + var result = new Dictionary(); + foreach (var callId in callIds) + { + var callUnitStates = statesByCall.TryGetValue(callId, out var callStates) ? callStates : new List(); + var firstOnScene = callUnitStates + .Where(s => CallStatusLinkage.ResolveUnitStateKind(s.State, unitBaseTypes) == UnitStateTypes.OnScene) + .Select(s => (DateTime?)s.Timestamp) + .OrderBy(t => t) + .FirstOrDefault(); + + var callLogs = logsByCall.TryGetValue(callId, out var logs) ? logs : new List(); + + result[callId] = (callUnitStates.Select(s => s.UnitId).Distinct().Count(), + callLogs.Select(a => a.UserId).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + firstOnScene); + } + + return result; + } + + /// + /// The unit times report for a period or (callId) one call; null when that call is deleted or not the department's. + /// + private async Task CallUnitTimesReportModel(int departmentId, DateTime? start, DateTime? end, int? callId) + { + var model = new CallUnitTimesView(); + model.Department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + model.RunOn = DateTime.UtcNow.TimeConverter(model.Department); + + var calls = new List(); + if (callId.HasValue && callId.Value > 0) + { + model.CallId = callId.Value; + var call = await _callsService.GetCallByIdAsync(callId.Value); + + // A deleted call has no report (the caller answers NotFound). + if (call == null || call.DepartmentId != departmentId || call.IsDeleted) + return null; + + calls.Add(call); + model.Start = call.LoggedOn.TimeConverter(model.Department); + model.End = (call.ClosedOn ?? DateTime.UtcNow).TimeConverter(model.Department); + } + else + { + var today = DateTime.UtcNow.TimeConverter(model.Department).Date; + model.Start = start ?? today.AddDays(-7); + model.End = end ?? today.AddDays(1).AddSeconds(-1); + + // The range is picked in department-local time; call timestamps are UTC. + calls = (await _callsService.GetAllCallsByDepartmentDateRangeAsync(departmentId, + model.Start.DepartmentLocalToUtc(model.Department), model.End.DepartmentLocalToUtc(model.Department))) ?? new List(); + } + + if (calls.Count == 0) + return model; + + var statesByCall = await _callStatusAttributionService.GetUnitStatesForCallsAsync(departmentId, calls); + var dispatchesByCall = await _callStatusAttributionService.GetUnitDispatchesForCallsAsync(departmentId, calls); + var customBaseTypes = await _unitsService.GetCustomUnitStateBaseTypesAsync(departmentId); + var units = (await _unitsService.GetUnitsForDepartmentAsync(departmentId) ?? new List()).ToDictionary(x => x.UnitId); + var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId) ?? new List(); + + foreach (var call in calls.OrderBy(x => x.LoggedOn)) + { + var callModel = new CallUnitTimesCall + { + CallId = call.CallId, + Number = call.Number, + Name = ProtectedDataEnvelope.SafeDisplay(call.Name), + Type = ProtectedDataEnvelope.SafeDisplay(call.Type), + LoggedOn = call.LoggedOn, + ClosedOn = call.ClosedOn + }; + + statesByCall.TryGetValue(call.CallId, out var states); + dispatchesByCall.TryGetValue(call.CallId, out var dispatches); + + foreach (var row in CallUnitTimesCalculator.Compute(dispatches, states, customBaseTypes)) + { + units.TryGetValue(row.UnitId, out var unit); + var group = unit?.StationGroupId != null ? groups.FirstOrDefault(g => g.DepartmentGroupId == unit.StationGroupId.Value) : null; + + callModel.Units.Add(new CallUnitTimesUnit { UnitName = unit?.Name ?? $"#{row.UnitId}", Group = group?.Name, Times = row }); + + model.AnyAutoLinked |= row.Source == CallUnitTimesSources.AutoLinked; + model.AnyInferred |= row.Source == CallUnitTimesSources.Inferred; + } + + callModel.Units = callModel.Units.OrderBy(x => x.Times.DispatchedOn ?? x.Times.EnrouteOn ?? x.Times.OnSceneOn ?? DateTime.MaxValue).ThenBy(x => x.UnitName).ToList(); + model.Calls.Add(callModel); + } + + return model; + } + + /// Quotes a CSV cell and neutralises spreadsheet formula prefixes in user-entered text. + private static string CsvCell(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + if ("=+-@\t\r".Contains(value[0])) + value = "'" + value; + + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + private async Task PersonnelStatusHistoryReportModel(int departmentId, bool groupSelect, int groupId, string userId, DateTime? start, DateTime? end) { @@ -1704,35 +1920,48 @@ private async Task PersonnelStatusHistoryReportModel await ApplyMemberIdentificationNumbersAsync(departmentId, profiles?.Values); var groups = await _departmentGroupsService.GetAllDepartmentGroupsForDepartmentAsync(departmentId); + // The range is picked in department-local time; action log timestamps are UTC. + var startUtc = model.Start.DepartmentLocalToUtc(model.Department); + var endUtc = model.End.DepartmentLocalToUtc(model.Department); + var statuses = new List(); if (groupSelect && groupId > 0) { var group = await _departmentGroupsService.GetGroupByIdAsync(groupId); - var usersInGroup = group.Members.Select(x => x.UserId); - foreach (var user in usersInGroup) + if (group != null && group.DepartmentId == departmentId && group.Members != null) { - statuses.AddRange( - await _actionLogsService.GetAllActionLogsForUserInDateRangeAsync(user, model.Start, model.End)); + var usersInGroup = group.Members.Select(x => x.UserId); + + foreach (var user in usersInGroup) + { + statuses.AddRange( + await _actionLogsService.GetAllActionLogsForUserInDateRangeAsync(user, startUtc, endUtc)); + } } } else { if (!String.IsNullOrWhiteSpace(userId)) { - statuses.AddRange( - await _actionLogsService.GetAllActionLogsForUserInDateRangeAsync(userId, model.Start, - model.End)); + // Only a member of this department can be reported on. + if (await _departmentsService.GetDepartmentMemberAsync(userId, departmentId) != null) + statuses.AddRange( + await _actionLogsService.GetAllActionLogsForUserInDateRangeAsync(userId, startUtc, + endUtc)); } else { statuses.AddRange( - await _actionLogsService.GetAllActionLogsInDateRangeAsync(DepartmentId, model.Start, - model.End)); + await _actionLogsService.GetAllActionLogsInDateRangeAsync(departmentId, startUtc, + endUtc)); } } + // A member of several departments has statuses in each; only this department's belong here. + statuses = statuses.Where(x => x != null && x.DepartmentId == departmentId).ToList(); + var groupedStates = from s in statuses group s by s.UserId into g @@ -1808,16 +2037,20 @@ private async Task UnitStateHistoryReportModel(int departm var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(departmentId); + // The range is picked in department-local time; unit state timestamps are UTC. + var startUtc = model.Start.DepartmentLocalToUtc(model.Department); + var endUtc = model.End.DepartmentLocalToUtc(model.Department); + var statuses = new List(); if (groupSelect && groupId > 0) { - var group = await _departmentGroupsService.GetGroupByIdAsync(groupId); - var unitsInGroup = await _unitsService.GetAllUnitsForGroupAsync(groupId); + // Only this department's units are reported on, whatever group or unit id was posted. + var unitsInGroup = (units ?? new List()).Where(x => x.StationGroupId == groupId); foreach (var unit in unitsInGroup) { - var groupUnitStates = await _unitStatesService.GetAllStatesForUnitInDateRangeAsync(unit.UnitId, model.Start, model.End); + var groupUnitStates = await _unitStatesService.GetAllStatesForUnitInDateRangeAsync(unit.UnitId, startUtc, endUtc); if (groupUnitStates != null) statuses.AddRange(groupUnitStates); @@ -1827,16 +2060,19 @@ private async Task UnitStateHistoryReportModel(int departm { if (unitId > 0) { - var perUnitStates = await _unitStatesService.GetAllStatesForUnitInDateRangeAsync(unitId, model.Start, model.End); + if (units != null && units.Any(x => x.UnitId == unitId)) + { + var perUnitStates = await _unitStatesService.GetAllStatesForUnitInDateRangeAsync(unitId, startUtc, endUtc); - if (perUnitStates != null) - statuses.AddRange(perUnitStates); + if (perUnitStates != null) + statuses.AddRange(perUnitStates); + } } else { foreach (var unit in units) { - var unitStates = await _unitStatesService.GetAllStatesForUnitInDateRangeAsync(unit.UnitId, model.Start, model.End); + var unitStates = await _unitStatesService.GetAllStatesForUnitInDateRangeAsync(unit.UnitId, startUtc, endUtc); if (unitStates != null) statuses.AddRange(unitStates); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index 6756d7c06..0b862edc6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -1098,8 +1098,9 @@ public async Task GenerateReport(IFormCollection form) { if (key.ToString().StartsWith("selectEvent_")) { - var eventId = int.Parse(key.ToString().Replace("selectEvent_", "")); - eventIds.Add(eventId); + var suffix = key.ToString().Replace("selectEvent_", ""); + if (int.TryParse(suffix, out var eventId)) + eventIds.Add(eventId); } } @@ -1109,20 +1110,38 @@ public async Task GenerateReport(IFormCollection form) var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); + model.RunOn = DateTime.UtcNow.TimeConverter(model.Department); + // Only this department's unit states, and only for units the member may view. + var eventRecords = new List(); foreach (var eventId in eventIds) { - var eventJson = new UnitEventJson(); var eventRecord = await _unitsService.GetUnitStateByIdAsync(eventId); - model.RunOn = DateTime.UtcNow.TimeConverter(model.Department); + if (eventRecord?.Unit == null || eventRecord.Unit.DepartmentId != DepartmentId) + continue; + + if (!await _authorizationService.CanUserViewUnitAsync(UserId, eventRecord.UnitId)) + continue; + + eventRecords.Add(eventRecord); + } + + var calls = await ReferencedCallsHelper.AddReferencedCallsAsync(_callsService, DepartmentId, activeCalls, eventRecords + .Where(x => x.DestinationId.HasValue && (!x.DestinationType.HasValue || x.DestinationType == (int)DestinationEntityTypes.Call)) + .Select(x => x.DestinationId.Value)); + + foreach (var eventRecord in eventRecords) + { + var eventJson = new UnitEventJson(); eventJson.UnitName = eventRecord.Unit.Name; - eventJson.State = StringHelpers.GetDescription(((UnitStateTypes)eventRecord.State)); + var customState = await _customStateService.GetCustomUnitStateAsync(eventRecord); + // Custom statuses store their detail id, which has no UnitStateTypes description. + eventJson.State = customState?.ButtonText ?? StringHelpers.GetDescription(((UnitStateTypes)eventRecord.State)); eventJson.Timestamp = eventRecord.Timestamp.TimeConverterToString(model.Department).ToString(); eventJson.Note = eventRecord.Note; - var customState = await _customStateService.GetCustomUnitStateAsync(eventRecord); - var destination = DestinationResolutionHelper.Resolve(eventRecord.DestinationId, eventRecord.DestinationType, customState?.DetailType, activeCalls, stations, pois, _localizer); + var destination = DestinationResolutionHelper.Resolve(eventRecord.DestinationId, eventRecord.DestinationType, customState?.DetailType, calls, stations, pois, _localizer); eventJson.DestinationName = destination.Name; if (eventRecord.LocalTimestamp.HasValue) @@ -1387,7 +1406,7 @@ public async Task GetUnitsList() unitJson.StateId = state.State; unitJson.State = "Unknown"; unitJson.StateColor = "#d1dade"; - unitJson.TextColor = "5E5E5E"; + unitJson.TextColor = "#5E5E5E"; unitJson.Timestamp = state.Timestamp.TimeConverterToString(department); } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs b/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs index 816d528a7..89e4249e2 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs @@ -228,6 +228,9 @@ public sealed class InvoiceLineInput public decimal Quantity { get; set; } public decimal UnitRate { get; set; } public bool Taxable { get; set; } = true; + + /// Only honoured for a new line added from a call preview; existing lines keep the time source they were generated with. + public int? TimeSource { get; set; } } public sealed class RecordPaymentInput diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs index e4dc876c3..38a3fead0 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs @@ -229,6 +229,11 @@ public string ProvenanceFor(string factKey) return null; if (fact.CorrectedOn.HasValue) return "Corrected"; + // Unit times taken from a status Resgrid linked to the call (IncidentReportsService.UnitTimeProvenance). + if (fact.SourceKind == (int)RmsSourceKind.Derived && fact.SourceSystem == "UnitStates (inferred)") + return "Inferred"; + if (fact.SourceKind == (int)RmsSourceKind.Derived && fact.SourceSystem == "UnitStates (auto-linked)") + return "AutoLinked"; return ((RmsSourceKind)fact.SourceKind).ToString(); } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Reports/Calls/CallUnitTimesView.cs b/Web/Resgrid.Web/Areas/User/Models/Reports/Calls/CallUnitTimesView.cs new file mode 100644 index 000000000..d0382bed4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Reports/Calls/CallUnitTimesView.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; +using Resgrid.Model.Reporting; + +namespace Resgrid.Web.Areas.User.Models.Reports.Calls +{ + public class CallUnitTimesView + { + public Department Department { get; set; } + public DateTime RunOn { get; set; } + + /// The period shown, department-local. + public DateTime Start { get; set; } + public DateTime End { get; set; } + + /// Set when the report covers a single call. + public int? CallId { get; set; } + + public List Calls { get; set; } = new List(); + + public bool AnyAutoLinked { get; set; } + public bool AnyInferred { get; set; } + } + + public class CallUnitTimesCall + { + public int CallId { get; set; } + public string Number { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public DateTime LoggedOn { get; set; } + public DateTime? ClosedOn { get; set; } + public List Units { get; set; } = new List(); + } + + public class CallUnitTimesUnit + { + public string UnitName { get; set; } + public string Group { get; set; } + public CallUnitTimesRow Times { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml index a3913c101..bf5d48b6f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml @@ -309,7 +309,7 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml index 36039fa65..b693dca09 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallData.cshtml @@ -148,7 +148,7 @@ - + diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml index 8453ecc76..d949eb7f5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExport.cshtml @@ -456,17 +456,18 @@ @foreach (var us in Model.UnitStates) { - var customState = await CustomStatesHelper.GetCustomUnitState(us); + var customState = await CustomStatesHelper.GetCustomUnitState(us) ?? CustomStatesHelper.UnknownState(commonLocalizer["Unknown"]); + var linkSource = us.DestinationSource; @us.Unit.Name - @if (us.State <= 25) + @if (!CustomStatesHelper.IsHexColor(customState.ButtonColor)) { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } else { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } @us.Timestamp.TimeConverterToString(Model.Department) @us.Note @@ -491,17 +492,18 @@ @foreach (var al in Model.ActionLogs) { - var customState = await CustomStatesHelper.GetCustomPersonnelStatus(Model.Department.DepartmentId, al); + var customState = await CustomStatesHelper.GetCustomPersonnelStatus(Model.Department.DepartmentId, al) ?? CustomStatesHelper.UnknownState(commonLocalizer["Unknown"]); + var linkSource = al.DestinationSource; @(await UserHelper.GetFullNameForUser(al.UserId)) - @if (al.ActionTypeId <= 25) + @if (!CustomStatesHelper.IsHexColor(customState.ButtonColor)) { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } else { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } @al.Timestamp.TimeConverterToString(Model.Department) @al.Note @@ -509,6 +511,14 @@ } + @{ + var anyAutoLinked = Model.UnitStates.Any(x => Resgrid.Model.CallStatusAttribution.IsInferred(x.DestinationSource) || Resgrid.Model.CallStatusAttribution.IsAutoLinked(x.DestinationSource)) + || Model.ActionLogs.Any(x => Resgrid.Model.CallStatusAttribution.IsInferred(x.DestinationSource) || Resgrid.Model.CallStatusAttribution.IsAutoLinked(x.DestinationSource)); + } + @if (anyAutoLinked) + { +

@localizer["StatusLinkLegend"]

+ } @if (Model.Call.CheckInTimersEnabled && Model.TimerConfigs != null && Model.TimerConfigs.Any()) diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml index c86f47639..e7647b540 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/CallExportEx.cshtml @@ -445,17 +445,18 @@ @foreach (var us in Model.UnitStates) { - var customState = await CustomStatesHelper.GetCustomUnitState(us); + var customState = await CustomStatesHelper.GetCustomUnitState(us) ?? CustomStatesHelper.UnknownState(commonLocalizer["Unknown"]); + var linkSource = us.DestinationSource; @us.Unit.Name - @if (us.State <= 25) + @if (!CustomStatesHelper.IsHexColor(customState.ButtonColor)) { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } else { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } @us.Timestamp.TimeConverterToString(Model.Department) @us.Note @@ -480,17 +481,18 @@ @foreach (var al in Model.ActionLogs) { - var customState = await CustomStatesHelper.GetCustomPersonnelStatus(Model.Department.DepartmentId, al); + var customState = await CustomStatesHelper.GetCustomPersonnelStatus(Model.Department.DepartmentId, al) ?? CustomStatesHelper.UnknownState(commonLocalizer["Unknown"]); + var linkSource = al.DestinationSource; @(await UserHelper.GetFullNameForUser(al.UserId)) - @if (al.ActionTypeId <= 25) + @if (!CustomStatesHelper.IsHexColor(customState.ButtonColor)) { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } else { - @customState.ButtonText + @customState.ButtonText@if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) { (@localizer["StatusLinkInferred"])} else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) { (@localizer["StatusLinkAuto"])} } @al.Timestamp.TimeConverterToString(Model.Department) @al.Note @@ -498,6 +500,14 @@ } + @{ + var anyAutoLinked = Model.UnitStates.Any(x => Resgrid.Model.CallStatusAttribution.IsInferred(x.DestinationSource) || Resgrid.Model.CallStatusAttribution.IsAutoLinked(x.DestinationSource)) + || Model.ActionLogs.Any(x => Resgrid.Model.CallStatusAttribution.IsInferred(x.DestinationSource) || Resgrid.Model.CallStatusAttribution.IsAutoLinked(x.DestinationSource)); + } + @if (anyAutoLinked) + { +

@localizer["StatusLinkLegend"]

+ } @if (Model.Station != null) diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index 8f4fd5874..7821f14b3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -367,7 +367,7 @@ { @Html.Raw("
") ; - @Html.Raw("
") + @Html.Raw("
" + @commonLocalizer["Name"] + "" + @commonLocalizer["Type"] + "" + @commonLocalizer["Status"] + "
") ; if (ungroupedUnits2 != null && ungroupedUnits2.Any()) @@ -418,7 +418,7 @@ ; } - @Html.Raw("
" + @commonLocalizer["Name"] + "" + @commonLocalizer["Type"] + "" + @commonLocalizer["Status"] + "
") + @Html.Raw("
" + @commonLocalizer["Name"] + "" + @commonLocalizer["Type"] + "" + @commonLocalizer["Status"] + "
") ; if (groupUnits != null && groupUnits.Any()) @@ -528,7 +528,7 @@ + id="setCallTemplateButton" disabled>@localizer["SetTemplateButton"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml index a7a5704b3..89f7353eb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml @@ -390,7 +390,7 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index 648a3498e..ff2466314 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -39,7 +39,7 @@
@if (ClaimsAuthorizationHelper.CanCreateCall() && Model.Call.State != 0) { - + } @if (ClaimsAuthorizationHelper.CanCreateCall()) { @@ -96,6 +96,7 @@
@@ -381,7 +382,8 @@
@foreach (var al in Model.ActionLogs) { - var customState = await CustomStatesHelper.GetCustomPersonnelStatus(Model.Department.DepartmentId, al); + var customState = await CustomStatesHelper.GetCustomPersonnelStatus(Model.Department.DepartmentId, al) ?? CustomStatesHelper.UnknownState(commonLocalizer["Unknown"]); + var linkSource = al.DestinationSource; var userInfo = Model.UserGroupRoles.FirstOrDefault(x => x.UserId == al.UserId); @@ -389,7 +391,7 @@ @al.Timestamp.TimeConverterToString(Model.Department)
" + @commonLocalizer["Name"] + "" + @commonLocalizer["Type"] + "" + @commonLocalizer["Status"] + "
- @if (al.ActionTypeId <= 25) + @if (!CustomStatesHelper.IsHexColor(customState.ButtonColor)) { @customState.ButtonText } @@ -397,6 +399,14 @@ { @customState.ButtonText } + @if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) + { + @localizer["StatusLinkInferred"] + } + else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) + { + @localizer["StatusLinkAuto"] + } @if (userInfo != null) @@ -427,6 +437,10 @@ }
+ @if (Model.ActionLogs.Any(x => Resgrid.Model.CallStatusAttribution.IsInferred(x.DestinationSource) || Resgrid.Model.CallStatusAttribution.IsAutoLinked(x.DestinationSource))) + { +

@localizer["StatusLinkLegend"]

+ }
@@ -442,12 +456,13 @@ @foreach (var us in Model.UnitStates) { - var customState = await CustomStatesHelper.GetCustomUnitState(us); + var customState = await CustomStatesHelper.GetCustomUnitState(us) ?? CustomStatesHelper.UnknownState(commonLocalizer["Unknown"]); + var linkSource = us.DestinationSource;
@us.Timestamp.TimeConverterToString(Model.Department) - @if (us.State <= 25) + @if (!CustomStatesHelper.IsHexColor(customState.ButtonColor)) { @customState.ButtonText } @@ -455,6 +470,14 @@ { @customState.ButtonText } + @if (Resgrid.Model.CallStatusAttribution.IsInferred(linkSource)) + { + @localizer["StatusLinkInferred"] + } + else if (Resgrid.Model.CallStatusAttribution.IsAutoLinked(linkSource)) + { + @localizer["StatusLinkAuto"] + } @us.Unit.Name @@ -472,6 +495,10 @@ }
+ @if (Model.UnitStates.Any(x => Resgrid.Model.CallStatusAttribution.IsInferred(x.DestinationSource) || Resgrid.Model.CallStatusAttribution.IsAutoLinked(x.DestinationSource))) + { +

@localizer["StatusLinkLegend"]

+ }
@@ -592,7 +619,7 @@ - + @@ -938,7 +965,7 @@ - + diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml index 483eac347..c93ff5d61 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml @@ -269,11 +269,15 @@ @foreach (var u in a.Units.OrderBy(u => u.Ordinal)) { - + }
@u.UnitNameSnapshot@(u.UnitNerisId ?? "-")@When(u.DispatchedOn)@When(u.EnrouteOn)@When(u.OnSceneOn)@When(u.StagingOn)@When(u.ClearedOn)@(u.ResponseMode ?? "-")@Source(u.TimesSourceKind)
@u.UnitNameSnapshot@(u.UnitNerisId ?? "-")@When(u.DispatchedOn)@When(u.EnrouteOn)@When(u.OnSceneOn)@When(u.StagingOn)@When(u.ClearedOn)@(u.ResponseMode ?? "-")@if (u.TimesSourceKind == (int)RmsSourceKind.Derived) { @Source(u.TimesSourceKind) } else { @Source(u.TimesSourceKind) }
+ @if (a.Units.Any(u => u.TimesSourceKind == (int)RmsSourceKind.Derived)) + { +

@localizer["UnitTimesDerivedHint"]

+ } }
diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml index 41e362837..24183f80e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml @@ -26,8 +26,9 @@ { var p = Model.ProvenanceFor(key); if (p == null) return Html.Raw(string.Empty); - var css = p == "Corrected" ? "label-warning" : p == "Derived" ? "label-default" : "label-info"; - return Html.Raw($"{localizer["Source" + p]}"); + var css = p == "Corrected" || p == "Inferred" ? "label-warning" : p == "Derived" || p == "AutoLinked" ? "label-default" : "label-info"; + var title = p == "Inferred" || p == "AutoLinked" ? localizer["UnitTimesDerivedHint"] : localizer["Provenance"]; + return Html.Raw($"{localizer["Source" + p]}"); }; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml index 866a6719d..4665f5805 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml @@ -9,7 +9,7 @@ ViewData["Title"] = localizer["Invoice"].Value + " #" + invoice.InvoiceNumber; ViewData["Subtitle"] = localizer["EditInvoiceIntro"].Value; var lines = (invoice.LineItems ?? new List()).OrderBy(x => x.SortOrder) - .Select(x => new { invoiceLineItemId = x.InvoiceLineItemId, callId = x.CallId, rateCardItemId = x.RateCardItemId, description = x.Description, quantity = x.Quantity, unitRate = x.UnitRate, taxable = x.Taxable }).ToList(); + .Select(x => new { invoiceLineItemId = x.InvoiceLineItemId, callId = x.CallId, rateCardItemId = x.RateCardItemId, description = x.Description, quantity = x.Quantity, unitRate = x.UnitRate, taxable = x.Taxable, timeSource = x.TimeSource }).ToList(); var jsonSettings = new JsonSerializerSettings { StringEscapeHandling = StringEscapeHandling.EscapeHtml }; } @@ -169,6 +169,17 @@ @section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml index 058a7bf6a..4185017e2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml @@ -156,6 +156,17 @@ @localizer["ViewReport"] + + + @localizer["CallUnitTimesReportName"] + + + @localizer["CallUnitTimesReportDescription"] + + + @localizer["ViewReport"] + + @localizer["PersonnelStaffingHistoryReportName"] diff --git a/Web/Resgrid.Web/Helpers/CustomStatesHelper.cs b/Web/Resgrid.Web/Helpers/CustomStatesHelper.cs index 2c15e2f4b..85a1b07b8 100644 --- a/Web/Resgrid.Web/Helpers/CustomStatesHelper.cs +++ b/Web/Resgrid.Web/Helpers/CustomStatesHelper.cs @@ -65,6 +65,23 @@ public static async Task GetCustomPersonnelStatus(int departm } } + /// + /// Placeholder for a status row whose status can no longer be resolved (e.g. a deleted custom status), so call + /// views and exports still list the row instead of failing to render. + /// + public static CustomStateDetail UnknownState(string text) + { + return new CustomStateDetail { ButtonText = text, ButtonColor = "label-default" }; + } + + /// + /// Built-in statuses carry either a CSS label class or a hex colour; custom statuses carry a hex colour. + /// + public static bool IsHexColor(string color) + { + return !string.IsNullOrWhiteSpace(color) && color.TrimStart().StartsWith("#"); + } + public static async Task GetCustomUnitState(UnitState state) { if (state.State <= 25) diff --git a/Web/Resgrid.Web/Helpers/ReferencedCallsHelper.cs b/Web/Resgrid.Web/Helpers/ReferencedCallsHelper.cs new file mode 100644 index 000000000..d69700482 --- /dev/null +++ b/Web/Resgrid.Web/Helpers/ReferencedCallsHelper.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Helpers +{ + public static class ReferencedCallsHelper + { + /// + /// Adds the department's closed calls that the given status rows point at to the active call list, so an events + /// report still names the call a status was set against after the call closed. + /// + public static async Task> AddReferencedCallsAsync(ICallsService callsService, int departmentId, List calls, IEnumerable destinationCallIds) + { + var result = calls != null ? new List(calls) : new List(); + var known = new HashSet(result.Select(x => x.CallId)); + + foreach (var callId in destinationCallIds.Where(x => x > 0).Distinct()) + { + if (known.Contains(callId)) + continue; + + var call = await callsService.GetCallByIdAsync(callId); + if (call != null && call.DepartmentId == departmentId) + { + result.Add(call); + known.Add(callId); + } + } + + return result; + } + } +} diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js index 6edffd836..434590629 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js @@ -439,18 +439,30 @@ $('#Call_Type').val(data.CallType); } - if (data.CallPriority && data.CallPriority >= 0) { + // Priority 0 is a real value (the default Low priority), so test the + // type rather than truthiness. + if (typeof data.CallPriority === 'number' && data.CallPriority >= 0) { $('#CallPriority').val(data.CallPriority); } + + // .val() raises no change event, so re-run the type/priority + // handler for the template's values. + checkForProtocols(); } }); } } addArchivedCall.fillCallTemplate = fillCallTemplate; + // Bound here rather than inline: the button renders disabled until this script has + // run, so an early click can't call into an undefined namespace (RESGRID-WEB-1MA). + $('#setCallTemplateButton').on('click', fillCallTemplate).prop('disabled', false); + // Type, priority and template changes can overlap; only the latest request's protocols are shown. + var protocolsRequest = 0; function checkForProtocols() { var callPriorityVal = $('#CallPriority').val(); var callTypeVal = $('#Call_Type').val(); + var request = ++protocolsRequest; $("#protocols tr").remove(); @@ -459,6 +471,9 @@ contentType: 'application/json', type: 'GET' }).done(function (data) { + if (request !== protocolsRequest) + return; + if (data) { resgrid.dispatch.addArchivedCall.protocolCount = 0; diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.callData.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.callData.js index 7b10b7684..5f30b7152 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.callData.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.callData.js @@ -24,6 +24,9 @@ var resgrid; }); } callData.addCallNote = addCallNote; + // Bound here rather than inline: the button renders disabled until this script has + // run, so an early click can't call into an undefined namespace (RESGRID-WEB-1MA). + $('#note-box-submit').on('click', addCallNote).prop('disabled', false); function getCallNotes() { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetCallNotes?callId=' + callId, diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js index 7c45a3dab..a85004c6d 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js @@ -442,18 +442,30 @@ var resgrid; $('#Call_Type').val(data.CallType); } - if (data.CallPriority && data.CallPriority >= 0) { + // Priority 0 is a real value (the default Low priority), so test the + // type rather than truthiness. + if (typeof data.CallPriority === 'number' && data.CallPriority >= 0) { $('#CallPriority').val(data.CallPriority); } + + // .val() raises no change event, so re-run the type/priority + // handler for the template's values. + checkForProtocols(); } }); } } editcall.fillCallTemplate = fillCallTemplate; + // Bound here rather than inline: the button renders disabled until this script has + // run, so an early click can't call into an undefined namespace (RESGRID-WEB-1MA). + $('#setCallTemplateButton').on('click', fillCallTemplate).prop('disabled', false); + // Type, priority and template changes can overlap; only the latest request's protocols are shown. + var protocolsRequest = 0; function checkForProtocols() { var callPriorityVal = $('#CallPriority').val(); var callTypeVal = $('#Call_Type').val(); + var request = ++protocolsRequest; $("#protocols tr").remove(); @@ -462,6 +474,9 @@ var resgrid; contentType: 'application/json', type: 'GET' }).done(function (data) { + if (request !== protocolsRequest) + return; + if (data) { resgrid.dispatch.editcall.protocolCount = 0; diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js index 0966bf214..aee963fad 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js @@ -339,17 +339,33 @@ var resgrid; $('#Call_Type').val(data.CallType); } - if (data.CallPriority && data.CallPriority >= 0) { + // Priority 0 is a real value (the default Low priority), so test the + // type rather than truthiness. + if (typeof data.CallPriority === 'number' && data.CallPriority >= 0) { $('#CallPriority').val(data.CallPriority); } $('input[name="Call.CheckInTimersEnabled"]').prop('checked', !!data.CheckInTimersEnabled); + + // .val() raises no change event, so the type/priority handlers never + // ran for a template. Re-run them here so the template's run card and + // protocols show up (and pre-check resources) as if picked by hand. + checkForProtocols(); + newcall.checkForRecommendations(); } }); } } newcall.fillCallTemplate = fillCallTemplate; + // Bound here rather than with inline handlers: the page is clickable while this + // script is still loading, and an inline call into resgrid.dispatch.newcall would + // throw until it has run (RESGRID-WEB-1MA). The button renders disabled until now. + $('#setCallTemplateButton').on('click', fillCallTemplate).prop('disabled', false); + $(document).on('change', '.check-all-units', function () { + checkAllUnits($(this).attr('data-unit-grid'), this); + }); + $('#personnelGrid').on('click', '#checkAllPersonnel', function () { $('#personnelGrid').find('tbody :checkbox').prop('checked', this.checked); }); @@ -448,9 +464,12 @@ var resgrid; $('#' + gridName).find(':checkbox').prop('checked', item.checked); } newcall.checkAllUnits = checkAllUnits; + // Type, priority and template changes can overlap; only the latest request's protocols are shown. + var protocolsRequest = 0; function checkForProtocols() { var callPriorityVal = $('#CallPriority').val(); var callTypeVal = $('#Call_Type').val(); + var request = ++protocolsRequest; $("#protocols tr").remove(); @@ -459,6 +478,9 @@ var resgrid; contentType: 'application/json', type: 'GET' }).done(function (data) { + if (request !== protocolsRequest) + return; + if (data) { resgrid.dispatch.newcall.protocolCount = 0; diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.viewcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.viewcall.js index ae7f0a449..15e0c59ca 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.viewcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.viewcall.js @@ -28,6 +28,9 @@ var resgrid; placeholder: '', theme: 'snow' }); + // addCallNote1 reads noteQuillDescription, so its button is enabled only once the + // editor exists. Bound here rather than inline (RESGRID-WEB-1MA). + $('#note-box-submit1').on('click', addCallNote1).prop('disabled', false); $(document).on('submit', '#newCallForm', function () { $('#Call_Notes').val(noteQuillDescription.root.innerHTML); @@ -121,6 +124,9 @@ var resgrid; }); } viewcall.addCallNote = addCallNote; + // Bound here rather than inline: the button renders disabled until this script has + // run, so an early click can't call into an undefined namespace (RESGRID-WEB-1MA). + $('#note-box-submit').on('click', addCallNote).prop('disabled', false); function addCallNote1() { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/AddCallNote', @@ -204,6 +210,8 @@ var resgrid; }); } viewcall.reOpenCall = reOpenCall; + // Bound here rather than inline (RESGRID-WEB-1MA); the button renders disabled until now. + $('#reOpenCallButton').on('click', reOpenCall).prop('disabled', false); })(viewcall = dispatch.viewcall || (dispatch.viewcall = {})); })(dispatch = resgrid.dispatch || (resgrid.dispatch = {})); })(resgrid || (resgrid = {})); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/units/resgrid.units.setstaffing.js b/Web/Resgrid.Web/wwwroot/js/app/internal/units/resgrid.units.setstaffing.js index 9d42248ae..e9feae960 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/units/resgrid.units.setstaffing.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/units/resgrid.units.setstaffing.js @@ -35,8 +35,15 @@ var resgrid; searchField: 'Name', options: [], create: false, + // Keep the "Not Occupied" option (empty value) as a real choice so an assigned seat can be + // cleared; without this Selectize drops it and only uses its text as the placeholder. + allowEmptyOption: true, render: { option: function (item, escape) { + if (!item.UserId) { + return '
' + escape(item.Name) + '
'; + } + return '
' + '' + '' + escape(item.Name) + '' + diff --git a/docs/architecture/checklists-p1-m1-implementation.md b/docs/architecture/checklists-p1-m1-implementation.md deleted file mode 100644 index e110e153d..000000000 --- a/docs/architecture/checklists-p1-m1-implementation.md +++ /dev/null @@ -1,49 +0,0 @@ -# Checklists P1-M1 implementation - -Status: implementation complete, pending deployment and the PostgreSQL environment check described below. This milestone is the free, on-demand web checklist workflow. P1-M2 scheduling, linked inventory targets and notifications; P1-M3 mobile/offline execution; P1-M4 reporting; and paid Readiness Pro workflows remain separate milestones. - -Subsequent planning amendment: the [Workflow events and ADP contract](readiness-workflows-adp-contract.md) adds P1-M1 follow-up acceptance checks for the existing event/catalog baseline before release. Its field classification, derived-data, serialization and policy-transition checks have not been claimed as executed by this implementation report. Later milestones must deliver Workflow and ADP coverage with their features. - -## Delivered behavior - -- Template-to-draft creation generates new section/item identities. The editor supports sections, ordering, instructions, ten answer types, numeric units/bounds, explicit passing values, required/critical items, N/A reasons, failure evidence and bounded conditions referencing earlier items. -- Saving a draft, publishing a new immutable version, retiring a definition and deleting an unpublished draft are separate operations. Runs pin their published version. Editing a draft cannot change the target picker for the active published version. -- Department, unit, group/station and personnel targets are authorized on the server. Equipment templates work on demand using a required equipment identifier and a responsible department/unit/group/person; linked inventory asset routing remains P1-M2. Site/building/room descriptions and optional reported coordinates are protected run content. -- Progress saves support resumption and optimistic revisions. Client-generated run IDs make repeated starts idempotent. Terminal retries return the same result only for the same answers, metadata and evidence checksums. Changed submitted content conflicts. -- Missing answers never count as passes. N/A requires permission and a reason. Applicable weights determine the score; zero applicable weight has no score. Critical failures override the score. Optional handover notes in starter templates do not reduce it. -- PNG/JPEG photo and signature evidence uses image identification, bounded dimensions, metadata stripping/re-encoding, a 10 MB limit, scanning, SHA-256 integrity checks and ADP-protected bytes. Only a clean scan is accepted. Duplicate uploads are idempotent; submitted evidence is immutable. Metadata authorization precedes blob retrieval. -- Independent witness submission requires a different active member with results permission and the relevant group scope. The witness attests to the immutable submitted payload. A shared URL does not grant access. Failed-item events occur on submission; the completion event waits for the required witness. -- History, numbered published versions, target snapshots, completed answers, print, JSON export and separately authorized image downloads are available. Normal historical read permissions continue to work after the rollout flag or department module is disabled. Group scope is captured with the run, so moving/deleting a unit does not silently reassign its old results to a different group. -- Definition/run changes write audit rows in the same transaction as the affected data. Completed/failed events enter the durable domain outbox in that transaction, with dispatch after commit. Workflow variables/sample data and ID-only Eventing notifications are registered. -- Permission issuance covers the shared claims path used by MVC identities, cookie principals and JWTs, both policy registrations, both helper copies, and the permission administration screen. The existing Records permission catalog retains its original defaults and membership. -- Neutral and all ten supported language resource sets cover the checklist views and editor labels: en, de, el, es, fr, it, pl, sv, uk and ar. The localization correction adds Arabic, removes English-only interface labels and condition-code instructions, and corrects terminology. Each dictionary has 174 entries. Stored response codes and user-authored text remain unchanged; translated condition selectors submit the original codes. -- GDPR export includes associated completion, answer and evidence metadata through the existing recursive ADP redaction/manifest pipeline. Evidence downloads use the authorized checklist endpoint. The existing SQL Server department-deletion path deletes checklist children before their parents. The platform's pre-existing lack of PostgreSQL department deletion remains unchanged. - -## Storage and identifiers - -`M0191_AddChecklistWorkflow` and its PostgreSQL twin create seven tables: ChecklistDefinitions, ChecklistDefinitionVersions, ChecklistOccurrences, ChecklistCompletions, ChecklistCompletionItems, ChecklistCompletionFiles and DepartmentChecklistSettings. The last table reserves the department settings aggregate used by P1-M2. - -Definitions and version schemas, target snapshots, completion metadata and typed answer payloads use a cataloged `Content` slot. Completion items remain relational rows keyed to their completion and stable item ID, with an indexed parent and a queryable failure marker. File bytes are a separate protected column. This reuses the existing dual-dialect, unit-of-work-aware repository plumbing rather than duplicating SQL across query configurations. - -Composite tenant foreign keys, unique published version numbers, one completion per occurrence and one answer per item enforce aggregate integrity. A department row lock serializes commands in the transaction; revisions detect stale edits. Historical versions cannot be updated through the repository. - -| Registry | Allocation | -|---|---| -| Migration | M0191; next physical migration M0192, within the existing readiness block | -| Permissions | ManageChecklists = 112; ViewChecklistResults = 113 | -| Workflow triggers | ChecklistCompleted = 67; ChecklistFailed = 68 | -| Eventing | ChecklistUpdated = 11 (IDs only) | -| ADP | Catalog version 14: seven Content slots and checklist file Data | -| Feature flag | Checklists.System, independent of Maintenance.WorkOrders | - -Both flags remain seeded off. Checklists invoke no Readiness Pro billing gate. The previously configured USD 150/month Stripe and EUR 195/month Paddle Readiness Pro mappings remain for P2's dedicated purchase/reconciliation workflow. - -## Validation and rollout - -Final build and focused test results are recorded in the review's P1-M1 completion section. Tests cover the web editor in headless Edge, conditional execution, escaped content, CSRF, concurrent saves, immutable versioning, idempotency, failure evidence, independent witnesses, authorization, ADP denial, audit rollback, workflow contracts and the existing readiness/billing boundaries. - -The real SQL Server fixture creates a uniquely named disposable database, applies M0191, rolls it down/up, checks tenant foreign keys, duplicate versions, blob-free list reads, transaction rollback and serialization of two writers. It deletes only its own database. The PostgreSQL fixture provides the same checks and is opt-in through `RESGRID_CHECKLIST_POSTGRES_TEST_CONNECTION`; Docker's Linux engine was unavailable during this work, so those checks remain an explicit environment validation gap. - -For rollout, apply migrations through M0191, complete the ADP catalog-14 upgrade for protected departments, configure the existing ClamAV scanner (`AttachmentScanningConfig.Enabled`, Host and Port), and enable Checklists.System plus the department's checklist module. A scanner that reports Skipped, Pending or Rejected cannot satisfy evidence requirements. Refresh sign-in claims after deploying the new permission resources. Checklists can be reviewed at `/User/Checklists` once enabled. - -Work is intentionally uncommitted. Suggested commit message: `feat(checklists): complete P1-M1 on-demand web workflow`. diff --git a/docs/architecture/readiness-pro-plan-review-2026-09-08.md b/docs/architecture/readiness-pro-plan-review-2026-09-08.md deleted file mode 100644 index 114120f35..000000000 --- a/docs/architecture/readiness-pro-plan-review-2026-09-08.md +++ /dev/null @@ -1,160 +0,0 @@ -# Checklists and Readiness Pro: implementation review - -Reviewed 2026-09-08 against `../int-Coordination/docs/architecture/checklists-maintenance-workorders-design.md`, the identifier registry, and the current Core checkout. - -## Decision - -The original plan is a strong starting point, but is not complete without the requirements below. With this amendment it is suitable for incremental implementation. This is design verification, not certification of operational readiness or a claim that the planned features have shipped. - -The [Workflow events and ADP contract](readiness-workflows-adp-contract.md), added 2026-09-08 at the user's request, is mandatory across all milestones. It supersedes older raw event/audit payload guidance and adds explicit P1-M1 follow-up verification before release. Event production and field/data-flow protection must be delivered with each feature, including models that may receive incidental PII/PHI. - -- **Checklists are free for every department**, including templates, authoring, scheduling, completion, failure evidence, notifications, history and checklist reports. They require neither a paid base plan nor Readiness Pro. -- **Readiness Pro** is the new **monthly** add-on covering maintenance, work requests, work orders, preventive maintenance, parts/labor, approvals and maintenance reports. It is a department subscription independent of the base plan's billing interval. User-confirmed pricing: **USD 150/month for US via Stripe; EUR 195/month for EU via Paddle**. Matching test/live monthly provider product/price identifiers and verified purchase/reconciliation flows are required before checkout opens; do not invent a free trial, annual option or activation from a pending checkout. -- `PlanAddonTypes.ReadinessPro = 3`; `PTT = 1` and `ADP = 2` are already used and remain unchanged. -- Independent feature flags: `Checklists.System` and `Maintenance.WorkOrders`. Both seed off. A flag controls rollout; the second flag does **not** confer a paid entitlement. Neither depends on the other. Maintenance may operate without Checklists; failed free checks remain saved and visible without the add-on. -- Reuse `MaintenanceDisabled`. Add `ChecklistsDisabled` to serialized `DepartmentModuleSettings` using new protobuf member 23; there is **no DepartmentModuleSettings table/column migration**. Apply settings, flags, department scope and permissions on the server, including workers/integrations, not only navigation. - -## Operational coverage and acceptance - -Every row is required before the corresponding milestone is considered complete. Suggested checklists are starting points to adapt to the organization's equipment, adopted standards, manufacturer procedures and jurisdiction; templates must not claim regulatory certification. - -| Users / workflow | Existing coverage | Required completion of the design | Acceptance milestone | -|---|---|---|---| -| Fire / EMS apparatus, boats, aviation and special operations | Rig checks, SCBA/PPE, unit and serialized equipment history | Pre-use/post-use/event checks; manufacturer-specific readings and units; critical defects override any aggregate passing score; quarantine and qualified return-to-service authorization | P1-M1/M2, P2-M2 | -| EMS bags, medications and biomedical equipment | Par quantities, AED checks, two signature items | Lot/serial/expiry and temperature excursions via inventory; calibrated instruments; independent authenticated witness identity for controlled counts, discrepancy escalation and restricted evidence. Two drawings from one user are not a dual attestation. Do not collect patient records in generic checks | P1-M1/M3, P2-M2 | -| SAR / wildland / volunteer organizations | Rope caches, personal packs, agency pre-use templates | Post-deployment rehabilitation and decontamination; retirement/inspection limits; no-shift scheduling; equipment checked out to individuals; pooled kit and borrowed-resource readiness | P1-M2/M3, P2-M2 | -| Emergency management / EOC / shelters | Department and station targets, readiness packet | EOC activation/handover/demobilization, shelter opening/accessibility, cache deployment/return, communications exercises, emergency power and continuity checks; site/group ownership, operational-period schedule and overdue escalation | P1-M1/M2/M4 | -| Industrial / construction / warehouses | Workplace audits, forklift pre-op, PM | Meter/odometer/hour/cycle-driven PM with calendar-or-meter whichever-first rules; condition thresholds; calibration/certification expiry; job hazards, isolation/permit references, qualified technicians, shift handover and independent release approval | P2-M1/M2/M3 | -| Businesses / facilities / campuses / fleets | Facility issues, vehicle inspections, costs | Site/building/room or free-text location without an inventory dependency; opening/closing/continuity checks, recurring vendor services, warranty/vendor/contact links, service documentation, cost centers and downtime windows | P1-M1/M2, P2-M1/M4 | -| Small organizations | Anyone in scope can complete, text parts fallback | Manual checks and work requests without shifts, inventory or a dedicated mechanic; simple defaults, accessible forms, printable/exportable evidence and clear ownership | P1-M1/M3, P2-M1 | -| Large / multi-site organizations | Role routing, digests, paging | Group/site-scoped access, bulk assignment/import with preview and row errors, technician queues, approval thresholds, cost currency, service-level targets by priority and business calendars, filtered/export limits | P1-M2/M4, P2-M1/M4 | - -## Required domain corrections - -### Checklists and evidence (free) - -1. Support explicit Pass, Fail and Not Applicable outcomes, with a configured reason for N/A. Missing answers must never become passes. Calculate score over applicable scored answers; zero applicable weight produces no score, never 100%. A critical failure makes the run fail regardless of score. Separate completed, passed, late, missed, skipped and exempt states in reports. -2. Item definitions need instructions, response type, stable identity, requiredness, criticality, allowed N/A, explicit pass semantics (including yes/no and selected options), numeric unit/range, evidence requirements and conditional visibility/requiredness. Never execute arbitrary expressions from definitions. Validate definition size/depth, unique item IDs, ranges, positive weights and valid references. -3. Published definitions are immutable snapshots. Draft editing/publishing/retiring are distinct. Copying a template produces a department-owned draft with new definition/section/item IDs; changing catalog content never rewrites a department definition. Pin all started runs and generated occurrences to the published version. Inactive/retired targets stop future generation without removing history. -4. Keep authenticated actor, target snapshot, server receive/submit time and client capture time separately. Device clocks and GPS are evidence, not authorization. Independent witness requirements use separate authorized users, timestamps and an explicit attestation. Restrict sensitive signatures/counts and account for the ADP protected-field catalog before persisting them. -5. Client GUIDs are scoped/validated for department and occurrence. An identical retry returns the original result; a changed payload under the same completed ID returns a conflict. Use optimistic concurrency for drafts/progress, one terminal completion per occurrence, uniqueness in both databases, and transactional answer/occurrence/completion/audit writes. Emit downstream effects through the existing durable outbox after commit. -6. Offline runs preserve published definitions, target/assignment snapshots and attachment queues. Membership revoked before sync blocks the mutation without discarding the local work. Reject stale conflicting submissions explicitly; do not silently overwrite another user's completion. Scan and authorize uploads before they count as required evidence; apply size/type limits and existing storage/protected-data rules. -7. Define recurrence in department local time with UTC storage, a documented DST gap/fold rule, month-end clamping, leap-year behavior and schedule effective dates. Materialized rows include target in their occurrence uniqueness key when one schedule expands to multiple targets. Grace windows, late completion, excused skips, reassignment, asset retirement and worker outage catch-up preserve what was actually due. Report denominator rules must be explicit. -8. Notification deduplication is per recipient/channel/occurrence or digest window. Resolve current asset ownership at send time, honor preferences/quiet hours, support an explicitly configured emergency exception, and prevent worker retries from duplicating alerts. A module/rollout pause freezes new work/notifications without manufacturing missed checks for the paused period. -9. Failure notes, photos, critical defect indicators and checklist alerts remain free. Work order creation is a separate authorized paid effect; a failed billing lookup must never roll back a valid free completion. Paid effects need unique source occurrence/item keys and retryable outbox processing. - -### Maintenance and work orders (Readiness Pro) - -1. Add work-request triage outcomes (accepted, rejected with reason, duplicate linked to canonical order), reopen with reason, structured hold/cancel reasons, assignment acceptance, multiple contributors and restricted external vendor details. Centralize the transition matrix and enforce field/role preconditions at the service boundary. Sequence human numbers atomically per department/year, with a unique index. -2. Model completion separately from verification/closure. Closure requires resolution/cause, applicable test/inspection evidence and authorized verification for safety-critical work. For hazardous work, record procedure/version, permit/isolation references and authorized personnel. Application checkboxes do not execute or replace physical energy-isolation procedures. -3. **Remove automatic unconditional restoration of the previous unit/asset state.** Track every active defect/maintenance hold and its source, reason and release authorization. Closing one order cannot clear another open hold, a later dispatcher status change, retirement, or a manual safety restriction. Use conditional state updates and a qualified return-to-service step. Billing expiry or disabling a flag must never clear a hold. -4. PM supports calendar, usage and condition triggers, units, source/timestamp of readings, meter replacement/reset, interval baselines, due-soon and overdue, fixed-vs-completion-based scheduling and blackout/service windows. Generate at most one work order per schedule/target/cycle, even across concurrent workers. Record rescheduling/deferral approvals, without erasing original due dates. -5. Parts flows include reservation/issue/consume/unused return, serial/lot/expiry when available and idempotent ledger links. Correct consumed parts with a linked inventory reversal, never deletion or an unrelated Adjust. Record labor time, rate snapshots, vendor charges, estimated/approved/actual cost, currency and cost center using decimal quantities/money. Keep free-text fallback and indicate when stock was not posted. -6. Add task steps/checklists on a work order, skills/role eligibility, attachments, vendor/warranty/reference links, downtime intervals, response/repair SLAs, priority escalation, customer/requester updates and assignment history. Reports distinguish elapsed downtime, active repair time, waiting time, PM compliance, repeated failures, backlog aging and costs. -7. Historical access/export must survive subscription cancellation, module disablement and feature rollout pause, under normal authorization and retention rules. Define a narrow safety-release operation for existing holds after expiry; it must not authorize new maintenance work. Commercial suspension does not delete or rewrite evidence. Plan separate read/export and create/update authorization methods when those data surfaces ship. - -## Billing and access contract - -- Trust reconciled department-scoped entitlements with exact add-on IDs, effective start and exclusive paid-through end. Missing/null/malformed billing responses, wrong department/type, future/expired dates and billing transport failures deny new paid operations. Do not rely on list count or feature flag alone. -- Core's generic `SubscriptionsService` helpers can return synthetic `SYSTEM`/forever entitlements when Billing API is unconfigured. Readiness Pro must reject this behavior explicitly; do not change PTT/ADP behavior as part of this feature. -- Readiness Pro renews monthly even if the base plan is annual. Do not use `PlanAddon.GetEndDateFromNow()`'s generic 7-day grace/annual-plan fallback to grant paid access. A successful reconciled payment supplies actual monthly period boundaries; checkout creation alone grants nothing. -- Cancellation at period end preserves access through the already paid interval. Immediate cancellation/revocation shortens that interval. Billing webhook/API reconciliation must distinguish these cases, verify signatures, deduplicate/replay safely, handle out-of-order events and invalidate entitlement caches. No new implicit grace period is approved in this amendment. -- One department-level subscription, without invented per-seat pricing. Buying/managing it requires existing department billing authorization. Test/live Stripe and Paddle mappings must be isolated. No zero-cost placeholder product or guessed provider identifiers. Readiness Pro is not offered for purchase until the external billing implementation is verified. -- Acceptance: all combinations of both flags, both module settings, free/paid base plan, no addon, PTT-only, ADP-only, active/future/expired/cancel-at-end Readiness Pro and unconfigured/unavailable billing. Checklists must make **zero billing calls**. Maintenance-only operation must work with the Checklists flag off. Recheck entitlement at writes/worker effects, not only session login. - -## Integration and identifier reconciliation - -- Live migrations end at M0188 in both dialects. Use **M0189** for the initial readiness flag seed. The registry currently reserves M0189-M0195 for this plan; subsequent migrations take the next physical number and must recheck the registry at authoring time. Do not reuse the original M0122-M0128/M0131-M0137 references. -- Current registry reservations: checklist/work-order permissions **112-115**, workflow triggers **67-73**, workers **63-66**. These are reserved, not implemented by the initial slice. Do not use the plan's stale values. Extend allocation tests to pin addon 3 and check duplicate addon values. -- Services use the existing Autofac registration/module pattern; current services/controllers support constructor injection. Do not introduce another service locator or persistence framework. SQL remains dual Dapper/FluentMigrator. -- Inventory, Unit, Responder, billing and coordination repositories are separate deliverables. Inventory absence must not prevent unit/personnel/site checklists or manual maintenance requests. Validate soft references against the owning department, preserve historical asset location/serial snapshots and do not infer call-time equipment from today's location. -- Reuse RMS's immutable PDF + evidence-manifest capture contract, source authorization and checksums. Plan ADP field ownership, purge/export/legal-hold semantics, access audit, classification and attachment scanning before checklist/work-order persistence. Do not create legacy Logs after Records cutover. Hydrant/prevention inspections already owned by RMS stay there; readiness checks link to them without duplicating violations or official inspections. -- Standard references need edition metadata and local review dates. The old plan's NFPA 1911 chapter references must not imply the current consolidated NFPA 1910 is identical, or that a short starter template implements a standard in full. - -## Delivery and release gates - -**Initial implementation slice:** independent flag seeds and keys; addon identity/monthly interval semantics; server access service; serialized checklist module toggle; free catalog with sector coverage; authenticated web catalog and v4 catalog/access endpoints; focused billing/gating/catalog/API tests. Seeded flags remain off. This slice does not provide persisted checklist execution or a purchasable maintenance product. - -**Current state:** P1-M1 implementation is complete and uncommitted: definition builder, immutable publication/versioning, authorized on-demand web runs, evidence, independent witnesses, history/export, permissions, ADP, audit transactions and durable workflow events. See the [implementation report](checklists-p1-m1-implementation.md) and completion validation below. Rollout flags remain off; PostgreSQL runtime verification is still pending. - -**Additional release gate:** the subsequent [Workflow/ADP amendment](readiness-workflows-adp-contract.md#milestone-acceptance-gates) requires a field-by-field and serialized-sink audit of this baseline, including derived scores/outcomes, safe Workflow projections and redaction metadata. Those new checks are planned, not covered by the earlier passing test counts. Complete the P1-M1 follow-up gaps before release and the corresponding checks in each later milestone. - -**Next milestone:** P1-M2 scheduling/targets/notifications/calendar, then P1-M3 offline mobile and P1-M4 reports. These are not part of the completed P1-M1 scope. Implement the expanded P2 milestones after free Checklists has shipped. Meter/condition PM, approval and safety-release requirements belong to Phase 2 GA, not a stretch backlog. - -Required proofs beyond existing milestone tests: concurrent edit/submit/worker races on both databases; cross-department object IDs and uploads; critical-failure and N/A score cases; end-to-end cancellation/failure/recovery; two simultaneous asset holds plus an intervening dispatch status change; historical asset moves; verified immutable records after template edits/retirement; outage catch-up/DST/leap days; large-department paging/digests/export bounds; database up/down/up on disposable databases; browser accessibility and both apps offline-to-online. Do not claim these pass until executed. - -## Sources checked for the review - -- [USFA/FEMA: NIMS resource management](https://www.usfa.fema.gov/a-z/nims/managing-resources.html): preparedness, resource tracking through demobilization and reporting support the EM coverage additions. -- [OSHA recommended safety and health practices](https://www.osha.gov/shpguidelines/docs/OSHA_SHP_Recommended_Practices.pdf): documented inspections and verifying corrective actions support auditable failure/closure workflows. -- [OSHA powered industrial trucks, 1910.178](https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.178) and [NIOSH daily inspections](https://www.cdc.gov/niosh/docs/wp-solutions/2022-100/): pre-use/shift examination and unsafe-equipment handling support event checks and explicit safety release. -- [OSHA hazardous energy control, 1910.147](https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.147): authorized-person and verification requirements support procedure references and role-controlled maintenance approval. -- [NFPA publications catalog](https://link.nfpa.org/all-publications/655/2012): lists NFPA 1910 (2024) for in-service emergency vehicles; use edition-specific references reviewed by the adopting organization. - -## Initial slice implementation and verification (2026-09-08) - -Implemented in Core, left uncommitted: - -- M0189 flag seeds in SQL Server and PostgreSQL; independent keys, both off. Down deliberately preserves operator-managed rows because a guarded Up cannot establish ownership of existing keys. -- Readiness Pro add-on 3 and monthly interval estimation independent of the base plan; configured USD 150 Stripe / EUR 195 Paddle monthly offers. No provider product or payment row is fabricated, and the API explicitly reports checkout unavailable. -- Autofac-registered `ReadinessAccessService`, rejecting missing billing configuration, wrong department/addon/type, missing/future/expired dates, synthetic SYSTEM/forever payments, null payloads and billing exceptions. Cancellation remains active through the reconciled paid-through date. Free checklist access never invokes billing. -- Serialized `ChecklistsDisabled` protobuf member 23, department settings controls and gated navigation. Existing MaintenanceDisabled is reused. -- 28 immutable starter templates covering the requested sectors; stable section/item GUIDs, critical-check metadata, failure-note requirements and witness metadata. Authenticated web gallery/preview plus v4 `Checklists/GetChecklistTemplates`, `Checklists/GetChecklistTemplate` and `Readiness/GetAccess`. Server service gates apply to direct catalog reads. Neutral English UI resources provide fallback; translations and localized template content remain for P1 completion. - -Validation executed: - -- `dotnet build Resgrid.sln --no-restore --verbosity quiet`: **passed**, 0 errors, 43 existing compatibility/obsolete API warnings. This includes compiled Razor views. -- Focused tests in `ReadinessAccessServiceTests`, `ChecklistTemplateServiceTests`, `ReadinessApiTests` and `IdentifierAllocationTests`: **54 passed**, 0 failed. Includes both migration assemblies' numbering/parity checks, the preserved addon IDs, invalid/unpaid entitlement cases, independent flags/modules, catalog structure/search and department-scoped API contracts. -- `git diff --check`: passed. -- **Not executed:** SQL Server/PostgreSQL Up/Down/Up against live databases (no isolated test connections configured; Docker daemon unavailable), authenticated browser smoke tests, mobile checks or billing-provider checkout/reconciliation. No live database migrations or feature activation were performed. - -At the end of this initial slice, the remaining P1-M1 builder/versioned persistence/on-demand execution was next. It is now implemented as recorded below. Paid work-order lifecycle, PM, integrations and purchase/reconciliation remain P2; the initial gate/catalog slice is not a GA declaration. - -## Provider mapping follow-up (2026-09-08) - -The user supplied these production mappings for the previously confirmed monthly prices: - -| Provider / region | Monthly price | Product ID | Price ID | -|---|---|---|---| -| Stripe / US | USD 150 | `prod_VDtkPNAa2qNBx3` | `price_0UDRwaqJFDZJcnkVnYP8bAcd` | -| Paddle / EU | EUR 195 | `pro_01m20xwmzpnkxzp7mm7nwwxp7p` | `pri_01m20xy5x54j0sp4mcydcm4q6m` | - -- M0190 (both dialects) seeds the Stripe PlanAddon row: ID `8a82f517-13db-4950-a514-d990248a67e6`, AddonType 3, Cost 150, ExternalId above, no base PlanId and empty TestExternalId. An existing Readiness Pro catalog is preserved. This is the Stripe/USD catalog amount; Paddle/EUR uses its own price mapping and EUR 195 offer. -- Paddle price selection follows the existing PaymentProviderConfig convention: `PaddleReadinessProAddon`, `PaddleReadinessProAddonTest`, `GetPaddleReadinessProAddonPriceId()`. -- Test/sandbox IDs are not supplied and remain unset. Readiness Pro's Stripe and Paddle selectors never fall back from test mode to production. Existing PTT and ADP key-selection behavior is unchanged. -- Legacy BuyAddon GET/POST and the PTT-specific subscription service methods reject Readiness Pro, preventing the new catalog row from opening the wrong purchase flow. Dedicated checkout remains unavailable until P2-M1 purchase/reconciliation is implemented and verified. -- Provider account metadata (monthly interval, currency, amount, product ownership) still needs checking in the dedicated checkout implementation. The supplied IDs have been recorded, not queried or modified at Stripe/Paddle. No live migration or purchase was executed. -- M0190 consumed the second migration in the existing readiness reservation. P1-M1 subsequently consumed M0191; the next physical migration is now M0192. Recheck before authoring further migrations. - -Provider mapping validation: full solution build passed (0 errors, 41 existing warnings); 75 focused readiness, mapping, allocation, payment-configuration and ADP billing-authorization tests passed. Database migration round-trips and live provider checks remain unexecuted. Changes are uncommitted. - -## P1-M1 completion (2026-09-08) - -The free on-demand web workflow is implemented. The [implementation report](checklists-p1-m1-implementation.md) records the delivered behavior, persistence choices, deployment prerequisites and remaining phase boundaries. This completion supersedes the earlier initial-slice status; it does not declare all of Phase 1 or Readiness Pro shipped. - -- Full solution build: `dotnet build Resgrid.sln --no-restore --verbosity quiet` passed with **0 errors and 6,379 warnings**. Warnings remain in the solution, including the database fixture's use of the existing obsolete migration-source test pattern. -- Focused regression run: **226 total, 222 passed, 4 skipped, 0 failed**. Coverage includes checklist validation/services/authorization, readiness and billing boundaries, identifier allocations, ADP catalog/GDPR export, Records permission/claim regressions, workflow contracts and browser scripts. -- SQL Server: four real database checks passed on a dedicated disposable LocalDB instance, including M0191 Up/Down/Up and repeated Up, tenant foreign keys, duplicate versions, blob-free metadata lists, transaction rollback and two-writer serialization. Each fixture database is removed after the test. -- Browser: the checklist editor/run scripts passed in headless Edge, covering stable item identities and ordering, escaped content, CSRF, conditional answer clearing, serialized saves, conflict preservation and edits made during an in-flight save. Existing browser script tests also passed. A deployed authenticated application walkthrough was not performed. -- PostgreSQL: the corresponding four real database checks were skipped because no test connection was configured and Docker's Linux engine was unavailable. Both migration projects compile and allocation/parity tests pass; this does not substitute for PostgreSQL execution. -- `git diff --check` passed. Resource XML was validated across neutral and nine language files. Starter-template content localization and later-phase mobile/reporting work are not claimed complete. - -M0191 creates the seven checklist tables in both dialects. Implemented allocations are permissions **112-113**, workflow triggers **67-68**, `EventingTypes.ChecklistUpdated = 11` and **ADP catalog 14**. Workers **63-66**, remaining triggers **69-73** and work-order permissions **114-115** remain reserved for later milestones. The next physical migration is **M0192**, within the existing readiness block; no subsequent block moves. - -Both feature flags remain off and no production migration, deployment, purchase or provider-account mutation was performed. Checklists have no billing dependency. Readiness Pro remains USD 150/month via Stripe and EUR 195/month via Paddle, with the supplied production IDs preserved and dedicated checkout/reconciliation pending P2-M1. - -Suggested commit message: `feat(checklists): complete P1-M1 on-demand web workflow`. Work is left uncommitted for human review. - -## Localization correction (2026-09-08) - -The supported-locale registry includes Arabic, which the initial P1-M1 resources omitted. The checklist resource family now includes the neutral dictionary and all ten supported languages: English, German, Greek, Spanish (Latin America), French, Italian, Polish, Swedish, Ukrainian and Arabic. Each has 174 matching keys with real translations, including interface choices, status names, permission labels, confirmations, signature instructions, accessibility labels and progress messages. German passing-answer terminology, Greek authentication wording, Swedish performer terminology and Ukrainian phrasing were corrected. - -The condition editor displays translated choices and preserves the existing stored response codes. It no longer asks users to type English boolean/pass-fail codes. Completion history localizes those codes, and user-authored names/options remain verbatim. Shared spellings that are correct in both languages, product names such as Readiness Pro and format names such as JSON remain intentional. This correction covers the dictionaries and their interface consumers; starter-template content and server validation message localization remain separate from those dictionaries. - -Validation: the affected test project and its web/Razor dependencies build with zero errors. All 15 focused tests pass, including one compiled-resource/placeholder/format-argument check per supported locale and all browser scripts. The new browser check exercises the editor and runner in every supported language, preserving condition/answer codes and user-authored text. The project memory now requires supported-locale coverage and genuine translations instead of English placeholders. Changes remain uncommitted. - -## Workflow and ADP planning amendment (2026-09-08) - -The user requires Workflow engine events and ADP coverage wherever readiness data can contain PII/PHI. The [delivery contract](readiness-workflows-adp-contract.md) now defines event timing and trigger mappings, transaction/outbox and consumer deduplication rules, pre-serialization redaction, protected-model/data-flow inventory, worker/source-reference behavior, and acceptance checks for every milestone. Checklists remain free, Readiness Pro remains a separate monthly product, and durable department ADP state continues to govern protection independently of billing and rollout flags. - -The amendment was checked against the current ADP plan and the P1-M1 event/catalog implementation. It changes documentation only; it does not claim implementation or runtime verification of the new acceptance gates. No new numeric allocation, migration, feature activation or deployment is included. diff --git a/docs/architecture/readiness-workflows-adp-contract.md b/docs/architecture/readiness-workflows-adp-contract.md deleted file mode 100644 index fe39c0887..000000000 --- a/docs/architecture/readiness-workflows-adp-contract.md +++ /dev/null @@ -1,83 +0,0 @@ -# Readiness: Workflow events and ADP contract - -Updated 2026-09-08 at the user's request. This is a required delivery contract for free Checklists and paid Readiness Pro, including web, v4 API, mobile, workers, integrations and reports. It amends the [readiness plan](../../../int-Coordination/docs/architecture/checklists-maintenance-workorders-design.md) and [implementation review](readiness-pro-plan-review-2026-09-08.md). The [ADP plan](../../../int-Coordination/docs/architecture/department-protected-data-implementation-plan.md), especially sections 3.4, 5, 8, 9 and 22.1, governs protected-data behavior. Where older readiness prose suggests raw model broadcasts or plaintext audit snapshots, this contract takes precedence. - -## Current implementation and remaining proof - -P1-M1 already emits ChecklistCompleted and ChecklistFailed through the transactional domain outbox and registers Workflow variables/context/sample data. ADP catalog 14 covers Content on the seven checklist tables and ChecklistCompletionFiles.Data. Interactive services use protected read/write services, and Eventing broadcasts a completion identifier. This is the implementation baseline, not evidence that every requirement below is already satisfied. - -Before releasing P1-M1, complete the field/disclosure inventory below against the actual models and every serialized sink. In particular, review the existing queryable Score, Passed, IsFailure, target/actor/witness IDs and event projections; a small payload is not automatically non-sensitive. Verify ADP redaction metadata and handling of inferred sensitive outcomes, audit payloads, derived files and protected-data lifecycle transitions. Record gaps as P1-M1 follow-up work. Scheduling, mobile/offline, reporting and all Phase 2 integrations must satisfy the corresponding gates when implemented. This amendment changes the plan; it does not claim those checks have run or deploy further code. - -## Workflow event contract - -Workflow support is a service-level requirement. Every entry point for a supported transition uses the same domain command and event path; controller-only notifications do not satisfy it. A checklist failure remains saved and emits its free event even when Readiness Pro is absent. Each paid action invoked by a Workflow rechecks Readiness Pro, module/flag state, department scope and ordinary authorization at execution time. Events do not grant access to the referenced object. - -The existing trigger reservation is 67-73, as recorded in the [identifier registry](../../../int-Coordination/docs/architecture/identifier-allocation-registry.md). Values 67-68 are implemented; the remaining mappings below consume that reservation when their milestones ship. Recheck the registry and physical enums before authoring. Do not use the old 63-69 values elsewhere in the original plan. - -| Workflow trigger | Required emission boundary | Delivery milestone | -|---|---|---| -| ChecklistCompleted = 67 | Once the completion reaches its final submitted state; after an independent witness attests when required. Completion and passing are distinct: a completed run may fail. | P1-M1; reused by P1-M3 | -| ChecklistFailed = 68 | One logical event per failed item on accepted initial submission, including a submission awaiting its witness. A witness/retry does not emit the same failure again. The current envelope event name is ChecklistItemFailed. | P1-M1; paid consumers in P2-M2 | -| ChecklistMissed = 69 | Once an occurrence actually transitions to missed after its due/grace window. Worker replays and module pauses must not manufacture additional missed transitions. | P1-M2 | -| WorkOrderCreated = 70 | After the new work request/order is committed, whether created manually, through the API, from a failed check or from preventive maintenance. Include a typed source and opaque source reference. | P2-M1; reused in P2-M2/M3 | -| WorkOrderStatusChanged = 71 | On each accepted lifecycle transition, with old/new state and aggregate revision. This covers triage, in-progress, waiting/on-hold, completion, closure, cancellation and reopening when supported. | P2-M1 onward | -| WorkOrderAssigned = 72 | On assignment, reassignment or unassignment; identify the change with reviewed routing identifiers, never copied names or contact details. | P2-M1 onward | -| WorkOrderOverdue = 73 | On transition into overdue for the applicable due-date/revision episode, not on every scheduler scan. Rescheduling and re-entry have explicit episode/deduplication semantics. | P2-M3 | - -Additional meaningful transitions must be specified alongside the feature: checklist publication/schedule changes and excused skips; approval requested/decided; safety hold placed/released; cost-affecting parts/labor changes; meter/condition thresholds becoming due. A lifecycle state change can use the existing status trigger where it describes the transition accurately. Otherwise, define a separate named domain event and Workflow trigger, then reserve its numeric identifier before implementation. This document allocates no additional numbers. Do not silently omit automation support for those features or overload a status event with a change that did not change status. - -For every emitted event: - -1. Persist the business mutation, audit record and outbox entry in one unit-of-work transaction. Dispatch only after commit. Rollback emits nothing; an unavailable bus leaves durable pending work. Do not send to Workflow or SignalR from inside a transaction before the outbox commit. -2. Use a stable event identity, department, aggregate ID/type/revision, UTC occurrence time and correlation/causation identifiers. Preserve that identity through delivery retries. Delivery is at least once; consumers deduplicate by event and effect identity. Paid work-order generation also deduplicates its source completion/item and policy so one failure cannot produce multiple orders or holds. -3. Use a versioned allowlisted projection, not serialized domain entities. Only reviewed routing/lifecycle data belongs in clear fields. Subject names, free text, selected answer text, notes, witness statements, filenames, coordinates and attachment bytes are excluded from the safe payload. Review numeric results and person-linked outcomes before allowing them; omit or redact protected derivatives as required by the ADP catalog. -4. Register the trigger through the existing WorkflowEventProvider/outbox listener, WorkflowTriggerEventType, WorkflowTemplateVariableCatalog, WorkflowTemplateContextBuilder and WorkflowSampleDataGenerator; include designer labels/descriptions and localized UI resources. Tests pin trigger IDs, payload schema and variable parity. Previews and examples use synthetic data only. -5. When ADP is enforced, build the safe projection **before any serialization**. Protected scalar variables use the exact shared REDACTED sentinel and structured is_redacted, redacted_fields and catalog-version metadata. Omit binaries. Do not substitute false, zero or empty text for a redacted value, which could trigger a mistaken condition. The designer identifies protected/unavailable variables and explains how existing definitions behave on enrollment. -6. Apply that projection to the outbox, bus messages, Workflow input/event JSON, queue retries, dead letters, execution history, previews, webhooks, scripts, AI/custom HTTP actions, logs and telemetry. Never persist plaintext and attempt to scrub it afterward. URLs contain opaque identifiers and require normal authorization; no grant, secret, filename, narrative or protected value belongs in a URL. -7. Workflows never obtain a user grant or decrypt protected fields, even when a human has a valid grant or a notification channel permits protected content. Re-evaluate current durable ADP state when older queued work is dispatched/replayed; enrollment cannot release a previously queued plaintext payload. Use the platform's controlled migration/redaction policy for retained queues/history. - -SignalR/Eventing is a separate refresh mechanism. Keep it to reviewed identifiers and tenant-authorized subscription scopes; it does not replace Workflow events. Notification workers default to generic sign-in messages and use the existing per-channel egress policy. They must not copy protected names or findings into a push preview, email subject, SMS, voice script or provider metadata. - -## ADP field and data-flow inventory - -Users may enter PII/PHI into an apparently operational field, a custom answer, an uploaded photo or an invoice. A warning not to enter patient data is useful guidance, not a protection boundary. Readiness is not a patient-record workflow; nevertheless its flexible input and copied evidence must be protected according to their possible contents. - -Each milestone must maintain a reviewed catalog manifest listing model/table/property or protected JSON slot, stable field ID, storage kind/capacity, Pii/Phi/Sensitive classification, owning-department path, read/write permissions, UI reveal behavior, retention owner, search behavior and egress rules. Use existing ADP classifications and services. Explicitly document reviewed plaintext exclusions. For mixed/unbounded content, protect the entire slot and assess potential PHI; do not rely on detecting names or diagnoses after entry. Classifications and field IDs cannot silently change after deployment; follow the catalog-version migration process. - -| Model/data surface | Potentially sensitive input or copies | Required coverage | -|---|---|---| -| ChecklistDefinition and immutable versions | Names, descriptions, instructions, section/item names, option labels, conditions, custom schema JSON and any copied template text after editing | Protect Content as a whole, including historical versions. A published snapshot retains its protection and cannot be rewritten during an edit or left plaintext during a catalog upgrade. | -| ChecklistSchedule, ChecklistOccurrence and DepartmentChecklistSettings | Assignment/target display snapshots, user-configurable notification text, schedule notes, exemption/skip reasons, custom configuration | Protect free text and snapshots. Keep only reviewed recurrence/routing fields queryable; person-linked schedules still require scoped access. Existing settings Content is already cataloged; future structured fields need explicit review. | -| ChecklistCompletion and ChecklistCompletionItem | Free text, selected-option values, readings/dates that may describe a person, failure/handover notes, location descriptions/GPS, witness statements, copied target identity | Protect the completion/answer Content slots. Review Score, Passed and IsFailure as derived data, especially personnel-related checks, instead of assuming every number/boolean is safe. | -| ChecklistCompletionFile and WorkOrderFile | Image/document bytes, signatures, original filenames/captions, embedded metadata, extracted text, thumbnails, scan/moderation findings | Protect binary content and sensitive metadata at every copy. Strip unnecessary image metadata; use existing size/type/scanning controls. Scanning alone does not remove PII/PHI. Authorize the parent and ADP access before loading/downloading bytes. | -| WorkOrder, requests, WorkOrderActivity and approval/release records | Titles, descriptions, comments, resolution/cancellation reasons, location, reporter/vendor contact details, hazards/permit references, source-check snapshots, approval and safety-release statements | Catalog every sensitive text/JSON slot before its first production write. Protect appended activity and approval history as well as the current row. A non-editable history record still needs ADP. | -| WorkOrderLabor and WorkOrderPart | Technician notes/identity snapshots, individual rates/pay-related information, free-text descriptions, vendor/contact details, invoice/receipt data and copied inventory information | Review individual financial/personnel data separately from aggregate costs. Protect sensitive columns and snapshots; do not expose them through generic assignment/cost events. Use source-system authorization for linked records. | -| WorkOrderRecurrence and future meter/condition PM data | Template titles/instructions, technician/vendor data, condition notes, service documents and protected source snapshots | Protect templates and evidence; validate which typed trigger thresholds are safe for unattended evaluation. Generated orders preserve protected-source references without copying plaintext into a job payload. | -| Reports, readiness packets, exports, calendars, search and caches | Rendered PDF/CSV/JSON, manifest labels, summaries, per-person scores, calendar titles, search snippets, extracted text and cached DTOs | Derived data inherits source protection. No plaintext protected content in shared caches, search indexes, report jobs or calendar/event projections. Interactive release requires current authorization/grant; unattended output uses safe/redacted projections. | -| Audit, Workflow/notification jobs, telemetry, moderation and support surfaces | Before/after JSON, request/validation excerpts, GPS, filenames, comments, diagnostic payloads and copied attachments | Keep ordinary audit and diagnostics value-free (IDs, operation, revision, state/result codes). If an audit/evidence system must retain sensitive content, use its cataloged ADP storage and authorized read path. Never dump a model or protected request body into logs. | - -Opaque IDs, tenant/foreign keys, row versions and necessary lifecycle/timestamp fields may remain plaintext under the ADP architecture after review. They still require tenant/role/group authorization and data minimization. They are not anonymous and cannot automatically be expanded into names or combined with sensitive outcomes in a downstream projection. - -## ADP implementation requirements - -- Reuse IProtectedReadService, IProtectedWriteService, the protected-data broker/grant context, ProtectedFieldCatalog, AdpTableBindings and the existing binary binding/storage patterns. Register new service/repository types in all applicable Autofac modules. Include encryption envelope capacity and required protection markers in both migration dialects, catalog/binding parity, backfill/enrollment verification, rotation and recovery checks. No new bespoke encryption or bypass repository path. -- Authorize the resource first, then validate a current tenant/user/application/session-bound grant for protected reads and writes. Apply the same policy in web, API, downloads, imports and integrations. Grant expiry/revocation must conceal protected views and stop dependent operations; never fall back to plaintext. Redacted edit round-trips must preserve unseen values or require re-verification, not save REDACTED as customer content. -- Durable department ADP state is the data-safety authority. Losing Readiness Pro, disabling either readiness flag/module, or a billing/enrollment-service failure never decrypts, deletes or disables protection on existing records. ADP and Readiness Pro are separate products. Free Checklists remain free; protected departments receive ADP behavior without an added Readiness Pro requirement. For departments not enrolled, use ordinary platform controls and do not claim ADP encryption is active. -- Workflow, scheduler, notification, export, integration and other unattended workers do not borrow/store/refresh a user's grant and cannot decrypt protected data. Design checklist occurrence and PM generation around immutable protected-source references plus reviewed routing metadata. Copying a ciphertext envelope to a different row is not valid because its associated-data binding changes. If a feature cannot operate through references and safe metadata, it needs a separately authorized attended operation or an explicitly approved protected-workload design before shipping; there is no implicit worker bypass. Referenced immutable sources remain retained while generated records or legal holds depend on them. -- API/mobile views expose clear protected/redacted state and the shared grant-renewal flow. Offline persistence and queued uploads use the approved ADP secure-local-storage policy; no protected plaintext in ordinary local storage, crash logs, notifications or sync queues. Recheck membership, grants and current department policy at sync. Preserve recoverable local work securely when sync is denied, and prevent expired grants from being replayed. -- Every file derivative, report and finalized RMS readiness artifact follows the source authorization, protected-storage and manifest/checksum contract. The GDPR background export has no decrypt grant: recursively redact protected values and include the structured redaction manifest. An attended authorized export is a separate flow. Apply retention, legal holds, subject export and department deletion to every new table, file, source reference and derived artifact in both supported database/storage paths; unresolved platform gaps are explicit release dependencies. - -## Milestone acceptance gates - -| Milestone | Workflow and ADP proof required in addition to functional tests | -|---|---| -| P1-M1 follow-up | Inventory the existing seven-table catalog, routing/derived scalar exclusions and every serialized sink. Verify transactional completion/failure events, witness timing, safe variables/redaction metadata, no-grant/expired/wrong-tenant denial and sensitive-data absence from audit/outbox/history/logs. Track remaining gaps before release. | -| P1-M2 | Missed/skip/schedule-event contracts; deduplication across retries/DST/pauses; protected assignments and reasons; generic digests and calendar projections; workers generate from safe metadata/source references with no user grant. | -| P1-M3 | Web/API/mobile have identical field/tenant/grant enforcement; protected offline data and attachments remain secure through expiry/revocation/reconnect; replay creates one logical completion/event/effect. | -| P1-M4 | Protected source content does not leak through reports, CSV/PDF exports, readiness manifests, calendars, analytics, cache keys or search. Authorized interactive and redacted unattended exports are tested separately. | -| P2-M1 | Work-order model/attachment/activity/labor/parts catalog and migrations precede writes; creation/state/assignment events work from every entry point; Workflow actions cannot bypass billing, ADP or ordinary permissions. | -| P2-M2 | Failure-to-order and inventory/hold effects are idempotent; sensitive findings are referenced or protected at the destination; approval/hold/release event contracts are explicit; one system's access does not authorize another system's protected content. | -| P2-M3 | PM recurrence/condition data is classified; unattended materialization needs no protected plaintext; created/overdue/threshold events and their safe projections survive retries and policy changes. | -| P2-M4 | Repeat disclosure, restore/upgrade, retention/hold/deletion and export tests on both databases and storage paths, including disabled modules, expired Readiness Pro and enforced ADP. No open sensitive-data gap is treated as reporting polish. | - -Use distinctive synthetic PII/PHI canaries in free text, selected options, witness notes, coordinates, filenames and attachments. Verify they are encrypted at cataloged storage and absent from outbox/bus/Workflow persistence, retries/dead letters, logs, caches, indexes, generic notifications and unattended exports. Check exact REDACTED values and field/catalog metadata, not just that a name is absent. Exercise enrollment/upgrade and policy changes between enqueue and dispatch, revoked grants, cross-department object/file references, transaction rollback, repeated delivery and subscriber failure. Record executed proof separately from planned coverage; do not put real customer PII/PHI into tests, fixtures or external memory.