diff --git a/Core/Resgrid.Config/NerisConfig.cs b/Core/Resgrid.Config/NerisConfig.cs
new file mode 100644
index 00000000..7338978f
--- /dev/null
+++ b/Core/Resgrid.Config/NerisConfig.cs
@@ -0,0 +1,39 @@
+namespace Resgrid.Config
+{
+ ///
+ /// NERIS integration (RMS plan section 5.5, RMS-2). Submission is off unless a department has an enabled
+ /// RmsNerisProfile AND this switch is on; Records is useful without NERIS (plan decision 21).
+ /// Environment keys: RESGRID:NerisConfig:Enabled, :BaseUrl, :SandboxBaseUrl, :TimeoutSeconds, :MaxAttempts.
+ ///
+ public static class NerisConfig
+ {
+ /// Master switch for outbound NERIS calls in every process.
+ public static bool Enabled = false;
+
+ /// Production API root of the pinned contract.
+ public static string BaseUrl = "https://api.neris.fsri.org/v1";
+
+ /// Sandbox root used by profiles whose Environment is sandbox; empty means "same as BaseUrl".
+ public static string SandboxBaseUrl = "";
+
+ /// Contract version the provider was generated against (Providers/Resgrid.Providers.Neris/Contract).
+ public static string ContractVersion = "1.4.78";
+
+ public static int TimeoutSeconds = 30;
+
+ /// Delivery attempts before a submission is marked Failed (trigger 111).
+ public static int MaxAttempts = 5;
+
+ /// Base backoff between attempts in minutes; doubled per attempt.
+ public static int RetryBackoffMinutes = 5;
+
+ /// Submissions one worker sweep claims.
+ public static int BatchSize = 25;
+
+ /// Lease held by a worker while it talks to the destination.
+ public static int LeaseSeconds = 300;
+
+ /// How often a submission awaiting the destination's review is polled for status.
+ public static int StatusPollMinutes = 30;
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
index 014bbf03..955b3830 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
@@ -278,4 +278,7 @@
تاريخ الإرسال المجدول
+
+ تقرير الحادث
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
index 049c909c..6b790dd3 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
@@ -845,4 +845,7 @@
Geplantes Alarmierungsdatum
+
+ Einsatzbericht
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
index bb9914bd..80a223f3 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
@@ -748,4 +748,7 @@
Προγραμματισμένη Ημερομηνία Αποστολής
+
+ Αναφορά συμβάντος
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
index 7e75d5ea..d37f729e 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
@@ -748,4 +748,7 @@
Scheduled Dispatch Date
+
+ Incident report
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
index fdd54328..cadc7671 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
@@ -894,4 +894,7 @@
Fecha de despacho programada
+
+ Informe de incidente
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
index 7c8b65f2..fa72b9a9 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
@@ -845,4 +845,7 @@
Date d'envoi planifiée
+
+ Rapport d'incident
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
index df3f925d..218c0323 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
@@ -845,4 +845,7 @@
Data di invio programmata
+
+ Rapporto di incidente
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
index 19aa4c7d..b9bcb404 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
@@ -845,4 +845,7 @@
Zaplanowana data wysyłki
+
+ Raport ze zdarzenia
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
index cd2d8b84..0da08d12 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
@@ -845,4 +845,7 @@
Schemalagt larmdatum
+
+ Insatsrapport
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
index bae02ba7..82a680b6 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
@@ -845,4 +845,7 @@
Запланована дата відправки
+
+ Звіт про інцидент
+
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
index 3f436a5c..37e4bb73 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx
@@ -213,4 +213,134 @@
الإجماليات
المالك
المجموعة
+ تقارير الحوادث
+ تقارير حوادث NERIS
+ تقرير حادث واحد معتمد لكل نداء. تبدأ التقارير من النداء المُرسل، وتحمل مصدر كل قيمة معبأة مسبقًا، ويتم التحقق منها وفق عقد NERIS المثبت قبل التصديق عليها وإرسالها.
+ تقرير الحادث
+ تعديل تقرير الحادث
+ البدء من النداء
+ بدء تقرير الحادث
+ اختر نداءً
+ لا توجد نداءات نشطة؛ افتح نداءً مغلقًا من الإرسال لبدء تقريره.
+ بدأ تقرير الحادث من النداء.
+ هذا النداء له تقرير حادث بالفعل.
+ رقم الحادث
+ معرّف حادث NERIS
+ الإرسال
+ الإرسالات
+ حالة الإرسال
+ لم يتم إرسال هذه المراجعة.
+ المحاولات
+ حالة الوجهة
+ المشكلات
+ في قائمة الانتظار
+ أُرسل
+ اكتمل
+ الحمولة
+ إرسال إلى NERIS
+ تصحيح وإعادة الإرسال
+ تحقق
+ تحقق مع NERIS
+ مشكلات التحقق
+ لا توجد مشكلات تحقق مفتوحة.
+ اكتمل التحقق مع {0} مشكلة (مشكلات).
+ الخطورة
+ القاعدة
+ الرسالة
+ المصدر
+ أصلح الأخطاء أدناه قبل الإنهاء.
+ إرسال NERIS غير مفعّل لهذا القسم. تبقى التقارير المكتملة محلية حتى يقوم مسؤول بإعداد ملف NERIS.
+ إرسال NERIS متوقف على مستوى النظام؛ يمكن إنشاء التقارير وإكمالها لكن لا يمكن تسليمها.
+ لم يتم إعداد ملف NERIS.
+ رفض NERIS هذا التقرير. صحّح الحقول وأعد الإرسال؛ سيتم إنشاء مراجعة جديدة ومفتاح تكرار جديد.
+ هذا التقرير لدى NERIS. ينتظر التعديل حتى يرد الطرف المستقبل.
+ قبل NERIS هذا التقرير.
+ أوقات الإرسال
+ إنشاء النداء
+ الرد على النداء
+ أول وصول
+ انتهاء الحادث
+ مركز الإرسال
+ رمز المحدد
+ رمز حادث الإرسال
+ النتيجة
+ وجود أشخاص
+ عدد النازحين
+ الحيوانات المنقذة
+ المعدّلات الخاصة
+ أنواع الحادث
+ أساسي
+ نوع NERIS
+ العنوان
+ الرقم
+ الشارع
+ البلدية
+ المقاطعة
+ الولاية
+ الرمز البريدي
+ الدولة
+ نوع المكان
+ استخدام الموقع
+ الشارع المتقاطع
+ الإحداثيات
+ معرّف وحدة NERIS
+ الطاقم
+ في الانتظار
+ أُلغي أثناء الطريق
+ انتهى
+ وضع الاستجابة
+ المساعدة المتبادلة
+ الاتجاه
+ نوع المساعدة
+ الجهة
+ معرّف NERIS للجهة
+ جهة غير إطفائية
+ الإجراءات والتكتيكات
+ التكتيك
+ الوحدة
+ الوقت
+ العوائق
+ النتيجة
+ مصدر كل قيمة معبأة مسبقًا. تحتفظ التصحيحات بالقيمة الأصلية بجانب القيمة المصححة.
+ الحقيقة
+ القيمة المصدرية
+ القيمة الحالية
+ صُحّح بواسطة
+ الإرسال
+ التطبيق
+ مشتق
+ مصحّح
+ حفظ وتحقق
+ التصديقات
+ وقّعه
+ وُقّع
+ إرسال للمراجعة
+ إعادة للتصحيح
+ موعد المراجعة
+ إعدادات NERIS
+ هوية القسم في NERIS وبيانات الاعتماد والبيئة، بالإضافة إلى ربط أنواع النداءات المحلية بأنواع حوادث NERIS. تُخزَّن بيانات الاعتماد مشفرة لكل قسم ولا تُعرض مرة أخرى.
+ معرّف كيان NERIS
+ اسم الكيان
+ البيئة
+ تجاوز عنوان API الأساسي
+ نوع المنحة
+ اسم المستخدم
+ كلمة المرور
+ معرّف العميل
+ سر العميل
+ بيانات الاعتماد مخزنة. اترك الحقول فارغة للاحتفاظ بها.
+ لا توجد بيانات اعتماد مخزنة.
+ إرسال تلقائي عند إكمال التقرير
+ تفعيل إرسال NERIS لهذا القسم
+ إصدار العقد
+ آخر رمز صادر
+ آخر اتصال ناجح
+ آخر خطأ
+ ربط أنواع النداءات
+ اربط كل نوع نداء إرسال بنوع حادث NERIS الذي يعبّئه مسبقًا. الأنواع غير المربوطة تترك نوع الحادث ليختاره المؤلف.
+ نوع النداء
+ غير مربوط
+ قائمة انتظار الإرسال
+ تم حفظ إعدادات NERIS.
+ تمت إضافة الإرسال إلى قائمة انتظار NERIS.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
index 3a32fdc2..54b39584 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx
@@ -213,4 +213,134 @@
Summen
Eigentümer
Gruppe
+ Einsatzberichte
+ NERIS-Einsatzberichte
+ Ein maßgeblicher Einsatzbericht pro Einsatz. Berichte beginnen beim disponierten Einsatz, führen die Quelle jedes vorausgefüllten Werts mit und werden vor Bestätigung und Übermittlung gegen den festgelegten NERIS-Vertrag geprüft.
+ Einsatzbericht
+ Einsatzbericht bearbeiten
+ Aus Einsatz starten
+ Einsatzbericht starten
+ Einsatz auswählen
+ Keine aktiven Einsätze; öffnen Sie einen abgeschlossenen Einsatz in der Disposition, um seinen Bericht zu starten.
+ Einsatzbericht aus dem Einsatz gestartet.
+ Für diesen Einsatz gibt es bereits einen Einsatzbericht.
+ Einsatznummer
+ NERIS-Einsatz-ID
+ Übermittlung
+ Übermittlungen
+ Übermittlungsstatus
+ Diese Revision wurde nicht übermittelt.
+ Versuche
+ Zielstatus
+ Probleme
+ Eingereiht
+ Gesendet
+ Abgeschlossen
+ Nutzdaten
+ An NERIS übermitteln
+ Korrigieren und erneut übermitteln
+ Prüfen
+ Mit NERIS prüfen
+ Prüfprobleme
+ Keine offenen Prüfprobleme.
+ Prüfung abgeschlossen mit {0} Problem(en).
+ Schweregrad
+ Regel
+ Meldung
+ Quelle
+ Beheben Sie die folgenden Fehler vor dem Abschließen.
+ Die NERIS-Übermittlung ist für diese Abteilung nicht aktiviert. Abgeschlossene Berichte bleiben lokal, bis ein Administrator das NERIS-Profil einrichtet.
+ Die NERIS-Übermittlung ist systemweit abgeschaltet; Berichte können erstellt und abgeschlossen, aber nicht übermittelt werden.
+ Das NERIS-Profil ist nicht eingerichtet.
+ NERIS hat diesen Bericht abgelehnt. Korrigieren Sie die Felder und übermitteln Sie erneut; eine neue Revision und ein neuer Idempotenzschlüssel werden erzeugt.
+ Dieser Bericht liegt bei NERIS. Die Bearbeitung wartet auf die Antwort des Ziels.
+ NERIS hat diesen Bericht akzeptiert.
+ Dispositionszeiten
+ Einsatz erstellt
+ Anruf angenommen
+ Erstes Eintreffen
+ Einsatz beendet
+ Leitstelle
+ Determinantencode
+ Dispositions-Einsatzcode
+ Ergebnis
+ Personen anwesend
+ Vertriebene Personen
+ Gerettete Tiere
+ Besondere Kennzeichen
+ Einsatzarten
+ Primär
+ NERIS-Typ
+ Adresse
+ Hausnummer
+ Straße
+ Gemeinde
+ Landkreis
+ Bundesland
+ Postleitzahl
+ Land
+ Ortstyp
+ Nutzung
+ Querstraße
+ Koordinaten
+ NERIS-Einheiten-ID
+ Besatzung
+ Bereitstellung
+ Unterwegs abgebrochen
+ Frei
+ Fahrmodus
+ Nachbarschaftshilfe
+ Richtung
+ Hilfeart
+ Organisation
+ NERIS-ID der Organisation
+ Nicht-Feuerwehr
+ Maßnahmen und Taktik
+ Maßnahme
+ Einheit
+ Zeit
+ Hindernisse
+ Ergebnis
+ Woher jeder vorausgefüllte Wert stammt. Korrekturen behalten das Original neben dem korrigierten Wert.
+ Fakt
+ Quellwert
+ Aktueller Wert
+ Korrigiert von
+ Disposition
+ Anwendung
+ Abgeleitet
+ Korrigiert
+ Speichern und prüfen
+ Bestätigungen
+ Unterzeichnet von
+ Unterzeichnet
+ Zur Prüfung einreichen
+ Zur Korrektur zurückgeben
+ Prüfung fällig
+ NERIS-Einstellungen
+ NERIS-Identität, Zugangsdaten und Umgebung der Abteilung sowie die Zuordnung lokaler Einsatzarten zu NERIS-Einsatzarten. Zugangsdaten werden pro Abteilung verschlüsselt gespeichert und nie wieder angezeigt.
+ NERIS-Entitäts-ID
+ Entitätsname
+ Umgebung
+ API-Basis-URL überschreiben
+ Grant-Typ
+ Benutzername
+ Passwort
+ Client-ID
+ Client-Secret
+ Zugangsdaten sind gespeichert. Felder leer lassen, um sie zu behalten.
+ Keine Zugangsdaten gespeichert.
+ Beim Abschließen eines Berichts automatisch übermitteln
+ NERIS-Übermittlung für diese Abteilung aktivieren
+ Vertragsversion
+ Letztes Token ausgestellt
+ Letzter erfolgreicher Aufruf
+ Letzter Fehler
+ Zuordnung der Einsatzarten
+ Ordnen Sie jede Einsatzart der NERIS-Einsatzart zu, die sie vorausfüllt. Nicht zugeordnete Arten überlassen die Wahl dem Verfasser.
+ Einsatzart
+ Nicht zugeordnet
+ Übermittlungswarteschlange
+ NERIS-Einstellungen gespeichert.
+ Übermittlung an NERIS eingereiht.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
index 0f610ad3..9eefd3e3 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx
@@ -213,4 +213,134 @@
Σύνολα
Κάτοχος
Ομάδα
+ Αναφορές συμβάντων
+ Αναφορές συμβάντων NERIS
+ Μία έγκυρη αναφορά συμβάντος ανά κλήση. Οι αναφορές ξεκινούν από την κλήση αποστολής, φέρουν την πηγή κάθε προσυμπληρωμένης τιμής και επικυρώνονται με το καθορισμένο συμβόλαιο NERIS πριν από τη βεβαίωση και την υποβολή.
+ Αναφορά συμβάντος
+ Επεξεργασία αναφοράς συμβάντος
+ Έναρξη από κλήση
+ Έναρξη αναφοράς συμβάντος
+ Επιλέξτε κλήση
+ Δεν υπάρχουν ενεργές κλήσεις. Ανοίξτε μια κλειστή κλήση από την Αποστολή για να ξεκινήσετε την αναφορά της.
+ Η αναφορά συμβάντος ξεκίνησε από την κλήση.
+ Αυτή η κλήση έχει ήδη αναφορά συμβάντος.
+ Αριθμός συμβάντος
+ Αναγνωριστικό συμβάντος NERIS
+ Υποβολή
+ Υποβολές
+ Κατάσταση υποβολής
+ Αυτή η αναθεώρηση δεν έχει υποβληθεί.
+ Προσπάθειες
+ Κατάσταση προορισμού
+ Ζητήματα
+ Σε ουρά
+ Απεστάλη
+ Ολοκληρώθηκε
+ Ωφέλιμο φορτίο
+ Υποβολή στο NERIS
+ Διόρθωση και επανυποβολή
+ Επικύρωση
+ Επικύρωση με NERIS
+ Ζητήματα επικύρωσης
+ Δεν υπάρχουν εκκρεμή ζητήματα επικύρωσης.
+ Η επικύρωση ολοκληρώθηκε με {0} ζήτημα(τα).
+ Σοβαρότητα
+ Κανόνας
+ Μήνυμα
+ Πηγή
+ Διορθώστε τα παρακάτω σφάλματα πριν την οριστικοποίηση.
+ Η υποβολή στο NERIS δεν είναι ενεργή για αυτό το τμήμα. Οι οριστικοποιημένες αναφορές παραμένουν τοπικές μέχρι ένας διαχειριστής να ρυθμίσει το προφίλ NERIS.
+ Η υποβολή στο NERIS είναι απενεργοποιημένη σε επίπεδο συστήματος. Οι αναφορές μπορούν να συνταχθούν και να οριστικοποιηθούν αλλά όχι να παραδοθούν.
+ Το προφίλ NERIS δεν έχει ρυθμιστεί.
+ Το NERIS απέρριψε αυτή την αναφορά. Διορθώστε τα πεδία και επανυποβάλετε· δημιουργούνται νέα αναθεώρηση και νέο κλειδί αδρανοποίησης.
+ Αυτή η αναφορά βρίσκεται στο NERIS. Η επεξεργασία αναμένει την απάντηση του προορισμού.
+ Το NERIS αποδέχθηκε αυτή την αναφορά.
+ Χρόνοι αποστολής
+ Δημιουργία κλήσης
+ Απάντηση κλήσης
+ Πρώτη άφιξη
+ Λήξη συμβάντος
+ Κέντρο αποστολής
+ Κωδικός καθοριστή
+ Κωδικός συμβάντος αποστολής
+ Έκβαση
+ Παρόντα άτομα
+ Εκτοπισμένα άτομα
+ Διασωθέντα ζώα
+ Ειδικοί τροποποιητές
+ Τύποι συμβάντος
+ Κύριος
+ Τύπος NERIS
+ Διεύθυνση
+ Αριθμός
+ Οδός
+ Δήμος
+ Νομός
+ Πολιτεία
+ Ταχυδρομικός κώδικας
+ Χώρα
+ Τύπος τόπου
+ Χρήση τοποθεσίας
+ Κάθετη οδός
+ Συντεταγμένες
+ Αναγνωριστικό μονάδας NERIS
+ Στελέχωση
+ Σε αναμονή
+ Ακύρωση καθ' οδόν
+ Ελεύθερο
+ Τρόπος απόκρισης
+ Αμοιβαία βοήθεια
+ Κατεύθυνση
+ Τύπος βοήθειας
+ Φορέας
+ Αναγνωριστικό NERIS φορέα
+ Μη πυροσβεστικός φορέας
+ Ενέργειες και τακτικές
+ Τακτική
+ Μονάδα
+ Ώρα
+ Εμπόδια
+ Αποτέλεσμα
+ Από πού προήλθε κάθε προσυμπληρωμένη τιμή. Οι διορθώσεις διατηρούν το αρχικό δίπλα στη διορθωμένη τιμή.
+ Στοιχείο
+ Τιμή πηγής
+ Τρέχουσα τιμή
+ Διορθώθηκε από
+ Αποστολή
+ Εφαρμογή
+ Παράγωγο
+ Διορθωμένο
+ Αποθήκευση και επικύρωση
+ Βεβαιώσεις
+ Υπογράφηκε από
+ Υπογράφηκε
+ Υποβολή για έλεγχο
+ Επιστροφή για διόρθωση
+ Προθεσμία ελέγχου
+ Ρυθμίσεις NERIS
+ Η ταυτότητα, τα διαπιστευτήρια και το περιβάλλον NERIS του τμήματος, καθώς και η αντιστοίχιση τοπικών τύπων κλήσης σε τύπους συμβάντος NERIS. Τα διαπιστευτήρια αποθηκεύονται κρυπτογραφημένα ανά τμήμα και δεν εμφανίζονται ξανά.
+ Αναγνωριστικό οντότητας NERIS
+ Όνομα οντότητας
+ Περιβάλλον
+ Παράκαμψη βασικού URL API
+ Τύπος εξουσιοδότησης
+ Όνομα χρήστη
+ Κωδικός πρόσβασης
+ Αναγνωριστικό πελάτη
+ Μυστικό πελάτη
+ Υπάρχουν αποθηκευμένα διαπιστευτήρια. Αφήστε τα πεδία κενά για να τα διατηρήσετε.
+ Δεν υπάρχουν αποθηκευμένα διαπιστευτήρια.
+ Αυτόματη υποβολή κατά την οριστικοποίηση αναφοράς
+ Ενεργοποίηση υποβολής NERIS για αυτό το τμήμα
+ Έκδοση συμβολαίου
+ Τελευταίο διακριτικό
+ Τελευταία επιτυχής κλήση
+ Τελευταίο σφάλμα
+ Αντιστοίχιση τύπων κλήσης
+ Αντιστοιχίστε κάθε τύπο κλήσης αποστολής στον τύπο συμβάντος NERIS που προσυμπληρώνει. Οι μη αντιστοιχισμένοι τύποι αφήνουν την επιλογή στον συντάκτη.
+ Τύπος κλήσης
+ Μη αντιστοιχισμένο
+ Ουρά υποβολών
+ Οι ρυθμίσεις NERIS αποθηκεύτηκαν.
+ Η υποβολή μπήκε στην ουρά για το NERIS.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
index 4cf0dcd8..6a41a5d3 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx
@@ -213,4 +213,134 @@
Totals
Owner
Group
+ Incident reports
+ NERIS incident reports
+ One authoritative incident report per call. Reports start from the dispatched call, carry the source of every prefilled value, and are validated against the pinned NERIS contract before they are attested and submitted.
+ Incident report
+ Edit incident report
+ Start from call
+ Start incident report
+ Select a call
+ No active calls; open a closed call from Dispatch to start its report.
+ Incident report started from the call.
+ This call already has an incident report.
+ Incident number
+ NERIS incident ID
+ Submission
+ Submissions
+ Submission state
+ This revision has not been submitted.
+ Attempts
+ Destination status
+ Issues
+ Queued
+ Sent
+ Completed
+ Payload
+ Submit to NERIS
+ Correct and resubmit
+ Validate
+ Validate with NERIS
+ Validation issues
+ No open validation issues.
+ Validation completed with {0} issue(s).
+ Severity
+ Rule
+ Message
+ Source
+ Fix the errors below before finalizing.
+ NERIS submission is not enabled for this department. Finalized reports stay local until an administrator configures the NERIS profile.
+ NERIS submission is switched off system-wide; reports can be authored and finalized but not delivered.
+ The NERIS profile is not configured.
+ NERIS rejected this report. Correct the fields and resubmit; a new revision and a new idempotency key are created.
+ This report is with NERIS. Editing waits until the destination answers.
+ NERIS accepted this report.
+ Dispatch times
+ Call created
+ Call answered
+ First arrival
+ Incident cleared
+ Dispatch center
+ Determinant code
+ Dispatch incident code
+ Disposition
+ People present
+ People displaced
+ Animals rescued
+ Special modifiers
+ Incident types
+ Primary
+ NERIS type
+ Address
+ Number
+ Street
+ Municipality
+ County
+ State
+ Postal code
+ Country
+ Place type
+ Location use
+ Cross street
+ Coordinates
+ NERIS unit ID
+ Staffing
+ Staged
+ Canceled en route
+ Cleared
+ Response mode
+ Mutual aid
+ Direction
+ Aid type
+ Agency
+ Agency NERIS ID
+ Non-fire agency
+ Actions and tactics
+ Tactic
+ Unit
+ Time
+ Impediments
+ Outcome
+ Where each prefilled value came from. Corrections keep the original beside the corrected value.
+ Fact
+ Source value
+ Current value
+ Corrected by
+ Dispatch
+ App
+ Derived
+ Corrected
+ Save and validate
+ Attestations
+ Signed by
+ Signed
+ Submit for review
+ Return for correction
+ Review due
+ NERIS settings
+ The department's NERIS identity, credential and environment, plus the crosswalk from local call types to NERIS incident types. Credentials are stored encrypted per department and are never shown again.
+ NERIS entity ID
+ Entity name
+ Environment
+ API base URL override
+ Grant type
+ Username
+ Password
+ Client ID
+ Client secret
+ A credential is stored. Leave the fields blank to keep it.
+ No credential is stored.
+ Submit automatically when a report is finalized
+ Enable NERIS submission for this department
+ Contract version
+ Last token issued
+ Last successful call
+ Last error
+ Call type crosswalk
+ Map each dispatch call type to the NERIS incident type it prefills. Unmapped types leave the incident type for the author to choose.
+ Call type
+ Unmapped
+ Submission queue
+ NERIS settings saved.
+ Submission queued for NERIS.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
index fae66b68..6bdab4fe 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx
@@ -213,4 +213,134 @@
Totales
Propietario
Grupo
+ Informes de incidentes
+ Informes de incidentes NERIS
+ Un informe de incidente autorizado por llamada. Los informes parten de la llamada despachada, conservan el origen de cada valor precargado y se validan contra el contrato NERIS fijado antes de certificarse y enviarse.
+ Informe de incidente
+ Editar informe de incidente
+ Iniciar desde la llamada
+ Iniciar informe de incidente
+ Seleccione una llamada
+ No hay llamadas activas; abra una llamada cerrada desde Despacho para iniciar su informe.
+ Informe de incidente iniciado desde la llamada.
+ Esta llamada ya tiene un informe de incidente.
+ Número de incidente
+ ID de incidente NERIS
+ Envío
+ Envíos
+ Estado del envío
+ Esta revisión no se ha enviado.
+ Intentos
+ Estado en destino
+ Problemas
+ En cola
+ Enviado
+ Completado
+ Carga útil
+ Enviar a NERIS
+ Corregir y reenviar
+ Validar
+ Validar con NERIS
+ Problemas de validación
+ No hay problemas de validación pendientes.
+ Validación completada con {0} problema(s).
+ Gravedad
+ Regla
+ Mensaje
+ Origen
+ Corrija los errores siguientes antes de finalizar.
+ El envío a NERIS no está habilitado para este departamento. Los informes finalizados permanecen locales hasta que un administrador configure el perfil NERIS.
+ El envío a NERIS está desactivado en todo el sistema; los informes pueden redactarse y finalizarse pero no entregarse.
+ El perfil NERIS no está configurado.
+ NERIS rechazó este informe. Corrija los campos y reenvíe; se crean una nueva revisión y una nueva clave de idempotencia.
+ Este informe está en NERIS. La edición espera la respuesta del destino.
+ NERIS aceptó este informe.
+ Tiempos de despacho
+ Llamada creada
+ Llamada contestada
+ Primera llegada
+ Incidente despejado
+ Centro de despacho
+ Código determinante
+ Código de incidente de despacho
+ Resolución
+ Personas presentes
+ Personas desplazadas
+ Animales rescatados
+ Modificadores especiales
+ Tipos de incidente
+ Principal
+ Tipo NERIS
+ Dirección
+ Número
+ Calle
+ Municipio
+ Condado
+ Estado
+ Código postal
+ País
+ Tipo de lugar
+ Uso del lugar
+ Calle transversal
+ Coordenadas
+ ID de unidad NERIS
+ Dotación
+ En espera
+ Cancelado en ruta
+ Liberado
+ Modo de respuesta
+ Ayuda mutua
+ Dirección
+ Tipo de ayuda
+ Agencia
+ ID NERIS de la agencia
+ Agencia no bomberil
+ Acciones y tácticas
+ Táctica
+ Unidad
+ Hora
+ Impedimentos
+ Resultado
+ De dónde proviene cada valor precargado. Las correcciones conservan el original junto al valor corregido.
+ Dato
+ Valor de origen
+ Valor actual
+ Corregido por
+ Despacho
+ Aplicación
+ Derivado
+ Corregido
+ Guardar y validar
+ Certificaciones
+ Firmado por
+ Firmado
+ Enviar a revisión
+ Devolver para corrección
+ Revisión pendiente
+ Configuración NERIS
+ La identidad, credencial y entorno NERIS del departamento, más la correspondencia de tipos de llamada locales a tipos de incidente NERIS. Las credenciales se guardan cifradas por departamento y no vuelven a mostrarse.
+ ID de entidad NERIS
+ Nombre de la entidad
+ Entorno
+ Sobrescritura de URL base de la API
+ Tipo de concesión
+ Usuario
+ Contraseña
+ ID de cliente
+ Secreto de cliente
+ Hay una credencial guardada. Deje los campos en blanco para conservarla.
+ No hay credencial guardada.
+ Enviar automáticamente al finalizar un informe
+ Habilitar el envío a NERIS para este departamento
+ Versión del contrato
+ Último token emitido
+ Última llamada correcta
+ Último error
+ Correspondencia de tipos de llamada
+ Asocie cada tipo de llamada de despacho al tipo de incidente NERIS que precarga. Los tipos sin asociar dejan la elección al autor.
+ Tipo de llamada
+ Sin asociar
+ Cola de envíos
+ Configuración NERIS guardada.
+ Envío en cola para NERIS.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
index bdb79387..72b4cf68 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx
@@ -213,4 +213,134 @@
Totaux
Propriétaire
Groupe
+ Rapports d'incident
+ Rapports d'incident NERIS
+ Un rapport d'incident faisant foi par appel. Les rapports partent de l'appel dispatché, conservent la source de chaque valeur préremplie et sont validés selon le contrat NERIS épinglé avant attestation et envoi.
+ Rapport d'incident
+ Modifier le rapport d'incident
+ Démarrer depuis l'appel
+ Démarrer le rapport d'incident
+ Sélectionner un appel
+ Aucun appel actif ; ouvrez un appel clôturé depuis la Régulation pour démarrer son rapport.
+ Rapport d'incident démarré depuis l'appel.
+ Cet appel a déjà un rapport d'incident.
+ Numéro d'incident
+ ID d'incident NERIS
+ Envoi
+ Envois
+ État de l'envoi
+ Cette révision n'a pas été envoyée.
+ Tentatives
+ Statut de la destination
+ Problèmes
+ En file
+ Envoyé
+ Terminé
+ Charge utile
+ Envoyer à NERIS
+ Corriger et renvoyer
+ Valider
+ Valider avec NERIS
+ Problèmes de validation
+ Aucun problème de validation ouvert.
+ Validation terminée avec {0} problème(s).
+ Gravité
+ Règle
+ Libellé
+ Origine
+ Corrigez les erreurs ci-dessous avant de finaliser.
+ L'envoi à NERIS n'est pas activé pour ce service. Les rapports finalisés restent locaux jusqu'à ce qu'un administrateur configure le profil NERIS.
+ L'envoi à NERIS est désactivé à l'échelle du système ; les rapports peuvent être rédigés et finalisés mais pas transmis.
+ Le profil NERIS n'est pas configuré.
+ NERIS a rejeté ce rapport. Corrigez les champs et renvoyez ; une nouvelle révision et une nouvelle clé d'idempotence sont créées.
+ Ce rapport est chez NERIS. La modification attend la réponse de la destination.
+ NERIS a accepté ce rapport.
+ Horaires de régulation
+ Appel créé
+ Appel décroché
+ Première arrivée
+ Incident terminé
+ Centre de régulation
+ Code déterminant
+ Code d'incident de régulation
+ Issue
+ Personnes présentes
+ Personnes déplacées
+ Animaux secourus
+ Modificateurs spéciaux
+ Types d'incident
+ Principal
+ Type NERIS
+ Adresse
+ Numéro
+ Rue
+ Commune
+ Comté
+ État
+ Code postal
+ Pays
+ Type de lieu
+ Usage du lieu
+ Rue transversale
+ Coordonnées
+ ID d'unité NERIS
+ Effectif
+ En attente
+ Annulé en route
+ Libéré
+ Mode de réponse
+ Entraide
+ Sens
+ Type d'aide
+ Organisme
+ ID NERIS de l'organisme
+ Organisme hors pompiers
+ Actions et tactiques
+ Tactique
+ Unité
+ Heure
+ Obstacles
+ Résultat
+ D'où provient chaque valeur préremplie. Les corrections conservent l'original à côté de la valeur corrigée.
+ Donnée
+ Valeur source
+ Valeur actuelle
+ Corrigé par
+ Régulation
+ Application
+ Dérivé
+ Corrigé
+ Enregistrer et valider
+ Attestations signées
+ Signé par
+ Signé
+ Soumettre pour relecture
+ Renvoyer pour correction
+ Relecture due
+ Paramètres NERIS
+ L'identité, l'identifiant et l'environnement NERIS du service, plus la correspondance des types d'appel locaux vers les types d'incident NERIS. Les identifiants sont chiffrés par service et ne sont jamais réaffichés.
+ ID d'entité NERIS
+ Nom de l'entité
+ Environnement
+ Remplacement de l'URL de base de l'API
+ Type d'octroi
+ Nom d'utilisateur
+ Mot de passe
+ ID client
+ Secret client
+ Un identifiant est enregistré. Laissez les champs vides pour le conserver.
+ Aucun identifiant enregistré.
+ Envoyer automatiquement à la finalisation d'un rapport
+ Activer l'envoi à NERIS pour ce service
+ Version du contrat
+ Dernier jeton émis
+ Dernier appel réussi
+ Dernière erreur
+ Correspondance des types d'appel
+ Associez chaque type d'appel au type d'incident NERIS qu'il préremplit. Les types non associés laissent le choix à l'auteur.
+ Type d'appel
+ Non associé
+ File d'envoi
+ Paramètres NERIS enregistrés.
+ Envoi mis en file pour NERIS.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
index d2c8ad12..36704af2 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx
@@ -213,4 +213,134 @@
Totali
Proprietario
Gruppo
+ Rapporti di incidente
+ Rapporti di incidente NERIS
+ Un rapporto di incidente autorevole per chiamata. I rapporti partono dalla chiamata inviata, conservano l'origine di ogni valore precompilato e vengono convalidati con il contratto NERIS fissato prima di attestazione e invio.
+ Rapporto di incidente
+ Modifica rapporto di incidente
+ Avvia dalla chiamata
+ Avvia rapporto di incidente
+ Seleziona una chiamata
+ Nessuna chiamata attiva; apri una chiamata chiusa dal Dispatch per avviarne il rapporto.
+ Rapporto di incidente avviato dalla chiamata.
+ Questa chiamata ha già un rapporto di incidente.
+ Numero incidente
+ ID incidente NERIS
+ Invio
+ Invii
+ Stato invio
+ Questa revisione non è stata inviata.
+ Tentativi
+ Stato destinazione
+ Problemi
+ In coda
+ Inviato
+ Completato
+ Contenuto inviato
+ Invia a NERIS
+ Correggi e reinvia
+ Convalida
+ Convalida con NERIS
+ Problemi di convalida
+ Nessun problema di convalida aperto.
+ Convalida completata con {0} problema/i.
+ Gravità
+ Regola
+ Messaggio
+ Origine
+ Correggi gli errori seguenti prima di finalizzare.
+ L'invio a NERIS non è abilitato per questo dipartimento. I rapporti finalizzati restano locali finché un amministratore non configura il profilo NERIS.
+ L'invio a NERIS è disattivato a livello di sistema; i rapporti possono essere redatti e finalizzati ma non consegnati.
+ Il profilo NERIS non è configurato.
+ NERIS ha respinto questo rapporto. Correggi i campi e reinvia; vengono create una nuova revisione e una nuova chiave di idempotenza.
+ Questo rapporto è presso NERIS. La modifica attende la risposta della destinazione.
+ NERIS ha accettato questo rapporto.
+ Tempi di dispatch
+ Chiamata creata
+ Chiamata risposta
+ Primo arrivo
+ Incidente concluso
+ Centrale operativa
+ Codice determinante
+ Codice incidente dispatch
+ Esito
+ Persone presenti
+ Persone sfollate
+ Animali salvati
+ Modificatori speciali
+ Tipi di incidente
+ Principale
+ Tipo NERIS
+ Indirizzo
+ Numero civico
+ Via
+ Comune
+ Provincia
+ Stato
+ CAP
+ Paese
+ Tipo di luogo
+ Uso del luogo
+ Via trasversale
+ Coordinate
+ ID unità NERIS
+ Personale
+ In attesa
+ Annullato in viaggio
+ Libero
+ Modalità di risposta
+ Mutuo soccorso
+ Direzione
+ Tipo di aiuto
+ Ente
+ ID NERIS dell'ente
+ Ente non VVF
+ Azioni e tattiche
+ Tattica
+ Unità
+ Ora
+ Impedimenti
+ Esito
+ Da dove proviene ogni valore precompilato. Le correzioni conservano l'originale accanto al valore corretto.
+ Dato
+ Valore di origine
+ Valore attuale
+ Corretto da
+ Centrale operativa
+ Applicazione
+ Derivato
+ Corretto
+ Salva e convalida
+ Attestazioni
+ Firmato da
+ Firmato
+ Invia per revisione
+ Restituisci per correzione
+ Revisione entro
+ Impostazioni NERIS
+ Identità, credenziale e ambiente NERIS del dipartimento, più la corrispondenza dai tipi di chiamata locali ai tipi di incidente NERIS. Le credenziali sono cifrate per dipartimento e non vengono più mostrate.
+ ID entità NERIS
+ Nome entità
+ Ambiente
+ Override URL base API
+ Tipo di grant
+ Nome utente
+ Parola chiave
+ ID client
+ Segreto client
+ È memorizzata una credenziale. Lascia i campi vuoti per conservarla.
+ Nessuna credenziale memorizzata.
+ Invia automaticamente alla finalizzazione di un rapporto
+ Abilita l'invio a NERIS per questo dipartimento
+ Versione contratto
+ Ultimo token emesso
+ Ultima chiamata riuscita
+ Ultimo errore
+ Corrispondenza tipi di chiamata
+ Associa ogni tipo di chiamata al tipo di incidente NERIS che precompila. I tipi non associati lasciano la scelta all'autore.
+ Tipo di chiamata
+ Non associato
+ Coda di invio
+ Impostazioni NERIS salvate.
+ Invio in coda per NERIS.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
index a08b75f7..b4300514 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx
@@ -213,4 +213,134 @@
Razem
Właściciel
Grupa
+ Raporty ze zdarzeń
+ Raporty ze zdarzeń NERIS
+ Jeden wiążący raport ze zdarzenia na wezwanie. Raporty zaczynają się od zadysponowanego wezwania, zachowują źródło każdej wstępnie wypełnionej wartości i są sprawdzane względem ustalonego kontraktu NERIS przed poświadczeniem i wysłaniem.
+ Raport ze zdarzenia
+ Edytuj raport ze zdarzenia
+ Rozpocznij z wezwania
+ Rozpocznij raport ze zdarzenia
+ Wybierz wezwanie
+ Brak aktywnych wezwań; otwórz zamknięte wezwanie w Dyspozytorni, aby rozpocząć jego raport.
+ Raport ze zdarzenia rozpoczęty z wezwania.
+ To wezwanie ma już raport ze zdarzenia.
+ Numer zdarzenia
+ ID zdarzenia NERIS
+ Wysyłka
+ Wysyłki
+ Stan wysyłki
+ Ta rewizja nie została wysłana.
+ Próby
+ Stan w miejscu docelowym
+ Problemy
+ W kolejce
+ Wysłano
+ Zakończono
+ Ładunek
+ Wyślij do NERIS
+ Popraw i wyślij ponownie
+ Sprawdź
+ Sprawdź z NERIS
+ Problemy walidacji
+ Brak otwartych problemów walidacji.
+ Walidacja zakończona: {0} problem(y).
+ Waga
+ Reguła
+ Komunikat
+ Źródło
+ Popraw poniższe błędy przed finalizacją.
+ Wysyłka do NERIS nie jest włączona dla tej jednostki. Sfinalizowane raporty pozostają lokalne, dopóki administrator nie skonfiguruje profilu NERIS.
+ Wysyłka do NERIS jest wyłączona w całym systemie; raporty można tworzyć i finalizować, ale nie dostarczać.
+ Profil NERIS nie jest skonfigurowany.
+ NERIS odrzucił ten raport. Popraw pola i wyślij ponownie; powstaną nowa rewizja i nowy klucz idempotencji.
+ Ten raport jest w NERIS. Edycja czeka na odpowiedź odbiorcy.
+ NERIS przyjął ten raport.
+ Czasy dysponowania
+ Utworzenie wezwania
+ Odebranie zgłoszenia
+ Pierwsze przybycie
+ Zakończenie zdarzenia
+ Centrum dyspozytorskie
+ Kod determinanta
+ Kod zdarzenia z dyspozytorni
+ Rozstrzygnięcie
+ Obecność osób
+ Osoby wysiedlone
+ Uratowane zwierzęta
+ Modyfikatory specjalne
+ Typy zdarzenia
+ Główny
+ Typ NERIS
+ Adres
+ Numer
+ Ulica
+ Gmina
+ Powiat
+ Stan
+ Kod pocztowy
+ Kraj
+ Typ miejsca
+ Przeznaczenie miejsca
+ Ulica poprzeczna
+ Współrzędne
+ ID jednostki NERIS
+ Obsada
+ W gotowości
+ Odwołany w drodze
+ Zwolniony
+ Tryb odpowiedzi
+ Pomoc wzajemna
+ Kierunek
+ Rodzaj pomocy
+ Agencja
+ ID NERIS agencji
+ Agencja spoza straży
+ Działania i taktyka
+ Taktyka
+ Jednostka
+ Czas
+ Utrudnienia
+ Wynik
+ Skąd pochodzi każda wstępnie wypełniona wartość. Poprawki zachowują oryginał obok poprawionej wartości.
+ Fakt
+ Wartość źródłowa
+ Wartość bieżąca
+ Poprawione przez
+ Dyspozytornia
+ Aplikacja
+ Pochodne
+ Poprawione
+ Zapisz i sprawdź
+ Poświadczenia
+ Podpisane przez
+ Podpisano
+ Prześlij do przeglądu
+ Zwróć do poprawy
+ Termin przeglądu
+ Ustawienia NERIS
+ Tożsamość, poświadczenie i środowisko NERIS jednostki oraz mapowanie lokalnych typów wezwań na typy zdarzeń NERIS. Poświadczenia są szyfrowane per jednostka i nigdy nie są ponownie wyświetlane.
+ ID podmiotu NERIS
+ Nazwa podmiotu
+ Środowisko
+ Nadpisanie bazowego URL API
+ Typ grantu
+ Nazwa użytkownika
+ Hasło
+ ID klienta
+ Sekret klienta
+ Poświadczenie jest zapisane. Pozostaw pola puste, aby je zachować.
+ Brak zapisanego poświadczenia.
+ Wysyłaj automatycznie po sfinalizowaniu raportu
+ Włącz wysyłkę do NERIS dla tej jednostki
+ Wersja kontraktu
+ Ostatni wydany token
+ Ostatnie udane wywołanie
+ Ostatni błąd
+ Mapowanie typów wezwań
+ Przypisz każdy typ wezwania do typu zdarzenia NERIS, który wstępnie wypełnia. Nieprzypisane typy pozostawiają wybór autorowi.
+ Typ wezwania
+ Nieprzypisany
+ Kolejka wysyłek
+ Zapisano ustawienia NERIS.
+ Wysyłka do NERIS w kolejce.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
index aee86ee6..c3ecbd1c 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx
@@ -213,4 +213,134 @@
Totalt
Ägare
Grupp
+ Insatsrapporter
+ NERIS-insatsrapporter
+ En auktoritativ insatsrapport per larm. Rapporter utgår från det utlarmade ärendet, bär källan till varje förifyllt värde och valideras mot det låsta NERIS-kontraktet innan de attesteras och skickas.
+ Insatsrapport
+ Redigera insatsrapport
+ Starta från larm
+ Starta insatsrapport
+ Välj ett larm
+ Inga aktiva larm; öppna ett avslutat larm från Utlarmning för att starta dess rapport.
+ Insatsrapport startad från larmet.
+ Detta larm har redan en insatsrapport.
+ Insatsnummer
+ NERIS-insats-ID
+ Inlämning
+ Inlämningar
+ Inlämningsstatus
+ Denna revision har inte skickats.
+ Försök
+ Mottagarstatus
+ Problem
+ Köad
+ Skickad
+ Slutförd
+ Nyttolast
+ Skicka till NERIS
+ Rätta och skicka igen
+ Validera
+ Validera med NERIS
+ Valideringsproblem
+ Inga öppna valideringsproblem.
+ Validering klar med {0} problem.
+ Allvarlighet
+ Regel
+ Meddelande
+ Källa
+ Åtgärda felen nedan innan du slutför.
+ NERIS-inlämning är inte aktiverad för denna organisation. Slutförda rapporter stannar lokalt tills en administratör konfigurerar NERIS-profilen.
+ NERIS-inlämning är avstängd i hela systemet; rapporter kan skrivas och slutföras men inte levereras.
+ NERIS-profilen är inte konfigurerad.
+ NERIS avvisade denna rapport. Rätta fälten och skicka igen; en ny revision och en ny idempotensnyckel skapas.
+ Denna rapport ligger hos NERIS. Redigering väntar tills mottagaren svarar.
+ NERIS godkände denna rapport.
+ Utlarmningstider
+ Larm skapat
+ Samtal besvarat
+ Första ankomst
+ Insats avslutad
+ Larmcentral
+ Determinantkod
+ Utlarmningskod
+ Utfall
+ Personer närvarande
+ Evakuerade personer
+ Räddade djur
+ Särskilda modifierare
+ Insatstyper
+ Primär
+ NERIS-typ
+ Adress
+ Nummer
+ Gata
+ Kommun
+ Län
+ Delstat
+ Postnummer
+ Land
+ Platstyp
+ Användning
+ Tvärgata
+ Koordinater
+ NERIS-enhets-ID
+ Bemanning
+ Uppställd
+ Avbruten under färd
+ Klar
+ Körsätt
+ Ömsesidig hjälp
+ Riktning
+ Hjälptyp
+ Organisation
+ Organisationens NERIS-ID
+ Icke-räddningstjänst
+ Åtgärder och taktik
+ Taktik
+ Enhet
+ Tid
+ Hinder
+ Resultat
+ Var varje förifyllt värde kom ifrån. Rättelser behåller originalet bredvid det rättade värdet.
+ Faktum
+ Källvärde
+ Aktuellt värde
+ Rättat av
+ Utlarmning
+ Applikation
+ Härlett
+ Rättat
+ Spara och validera
+ Attesteringar
+ Signerat av
+ Signerat
+ Skicka för granskning
+ Returnera för rättelse
+ Granskning senast
+ NERIS-inställningar
+ Organisationens NERIS-identitet, inloggning och miljö samt mappningen från lokala larmtyper till NERIS-insatstyper. Inloggningsuppgifter lagras krypterade per organisation och visas aldrig igen.
+ NERIS-entitets-ID
+ Entitetsnamn
+ Miljö
+ Åsidosätt API-bas-URL
+ Grant-typ
+ Användarnamn
+ Lösenord
+ Klient-ID
+ Klienthemlighet
+ En inloggning är lagrad. Lämna fälten tomma för att behålla den.
+ Ingen inloggning är lagrad.
+ Skicka automatiskt när en rapport slutförs
+ Aktivera NERIS-inlämning för denna organisation
+ Kontraktsversion
+ Senaste token utfärdad
+ Senaste lyckade anrop
+ Senaste fel
+ Mappning av larmtyper
+ Mappa varje larmtyp till den NERIS-insatstyp den förifyller. Omappade typer lämnar valet till författaren.
+ Larmtyp
+ Omappad
+ Inlämningskö
+ NERIS-inställningar sparade.
+ Inlämning köad för NERIS.
diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
index d4b8c31f..375ce00d 100644
--- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx
@@ -213,4 +213,134 @@
Разом
Власник
Група
+ Звіти про інциденти
+ Звіти про інциденти NERIS
+ Один авторитетний звіт про інцидент на виклик. Звіти починаються з диспетчеризованого виклику, зберігають джерело кожного попередньо заповненого значення та перевіряються за закріпленим контрактом NERIS перед засвідченням і поданням.
+ Звіт про інцидент
+ Редагувати звіт про інцидент
+ Почати з виклику
+ Почати звіт про інцидент
+ Виберіть виклик
+ Немає активних викликів; відкрийте закритий виклик у Диспетчеризації, щоб почати його звіт.
+ Звіт про інцидент розпочато з виклику.
+ Цей виклик уже має звіт про інцидент.
+ Номер інциденту
+ Ідентифікатор інциденту NERIS
+ Подання
+ Подання
+ Стан подання
+ Цю редакцію не подано.
+ Спроби
+ Стан призначення
+ Проблеми
+ У черзі
+ Надіслано
+ Завершено
+ Корисне навантаження
+ Подати до NERIS
+ Виправити та подати повторно
+ Перевірити
+ Перевірити з NERIS
+ Проблеми перевірки
+ Немає відкритих проблем перевірки.
+ Перевірку завершено, проблем: {0}.
+ Серйозність
+ Правило
+ Повідомлення
+ Джерело
+ Виправте помилки нижче перед завершенням.
+ Подання до NERIS не ввімкнено для цього підрозділу. Завершені звіти залишаються локальними, доки адміністратор не налаштує профіль NERIS.
+ Подання до NERIS вимкнено в усій системі; звіти можна створювати та завершувати, але не доставляти.
+ Профіль NERIS не налаштовано.
+ NERIS відхилив цей звіт. Виправте поля та подайте повторно; буде створено нову редакцію та новий ключ ідемпотентності.
+ Цей звіт перебуває в NERIS. Редагування очікує відповіді призначення.
+ NERIS прийняв цей звіт.
+ Часи диспетчеризації
+ Виклик створено
+ Виклик прийнято
+ Перше прибуття
+ Інцидент завершено
+ Диспетчерський центр
+ Код детермінанти
+ Код інциденту диспетчеризації
+ Результат
+ Присутні люди
+ Переміщені особи
+ Врятовані тварини
+ Спеціальні модифікатори
+ Типи інциденту
+ Основний
+ Тип NERIS
+ Адреса
+ Номер
+ Вулиця
+ Муніципалітет
+ Округ
+ Штат
+ Поштовий індекс
+ Країна
+ Тип місця
+ Використання місця
+ Перехресна вулиця
+ Координати
+ Ідентифікатор підрозділу NERIS
+ Укомплектування
+ Очікування
+ Скасовано в дорозі
+ Звільнено
+ Режим реагування
+ Взаємодопомога
+ Напрям
+ Тип допомоги
+ Організація
+ NERIS-ідентифікатор організації
+ Непожежна організація
+ Дії та тактика
+ Тактика
+ Підрозділ
+ Час
+ Перешкоди
+ Результат
+ Звідки походить кожне попередньо заповнене значення. Виправлення зберігають оригінал поруч із виправленим значенням.
+ Факт
+ Значення джерела
+ Поточне значення
+ Виправив
+ Диспетчеризація
+ Застосунок
+ Похідне
+ Виправлено
+ Зберегти та перевірити
+ Засвідчення
+ Підписав
+ Підписано
+ Надіслати на перевірку
+ Повернути на виправлення
+ Термін перевірки
+ Налаштування NERIS
+ Ідентичність, облікові дані та середовище NERIS підрозділу, а також відповідність локальних типів викликів типам інцидентів NERIS. Облікові дані зберігаються зашифрованими для кожного підрозділу й більше не показуються.
+ Ідентифікатор суб'єкта NERIS
+ Назва суб'єкта
+ Середовище
+ Заміна базового URL API
+ Тип гранту
+ Ім'я користувача
+ Пароль
+ Ідентифікатор клієнта
+ Секрет клієнта
+ Облікові дані збережено. Залиште поля порожніми, щоб зберегти їх.
+ Облікові дані не збережено.
+ Подавати автоматично після завершення звіту
+ Увімкнути подання до NERIS для цього підрозділу
+ Версія контракту
+ Останній виданий токен
+ Останній успішний виклик
+ Остання помилка
+ Відповідність типів викликів
+ Зіставте кожен тип виклику з типом інциденту NERIS, який він попередньо заповнює. Незіставлені типи залишають вибір автору.
+ Тип виклику
+ Не зіставлено
+ Черга подань
+ Налаштування NERIS збережено.
+ Подання поставлено в чергу для NERIS.
diff --git a/Core/Resgrid.Model/Providers/INerisProviders.cs b/Core/Resgrid.Model/Providers/INerisProviders.cs
new file mode 100644
index 00000000..5f0ffdc6
--- /dev/null
+++ b/Core/Resgrid.Model/Providers/INerisProviders.cs
@@ -0,0 +1,79 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Providers
+{
+ /// Department NERIS profile, credential, value sets and crosswalks (RMS plan section 5.5).
+ public interface INerisProfileService
+ {
+ Task GetProfileAsync(int departmentId);
+
+ /// Saves the profile; a non-null credential replaces the stored one (encrypted per department), null keeps it.
+ Task SaveProfileAsync(RmsNerisProfile profile, NerisCredential credential, string userId, CancellationToken cancellationToken = default);
+
+ /// Decrypts the stored credential for one call; never cached, never logged.
+ Task GetCredentialAsync(RmsNerisProfile profile);
+
+ /// True when NerisConfig.Enabled, the profile is enabled, and it carries an entity ID and a credential.
+ Task IsSubmissionEnabledAsync(int departmentId);
+
+ /// A value set of the pinned contract, from the embedded snapshot (seeded to RmsNerisValueSets on first use).
+ NerisValueSet GetValueSet(string setKey);
+
+ IReadOnlyList ValueSetKeys { get; }
+
+ string ContractVersion { get; }
+
+ Task EnsureValueSetsSeededAsync(CancellationToken cancellationToken = default);
+
+ /// The department's mapped NERIS code for a local code, or null when unmapped.
+ Task ResolveCrosswalkAsync(int departmentId, string setKey, string localSource, string localCode);
+
+ Task> GetCrosswalksAsync(int departmentId);
+
+ Task SaveCrosswalkAsync(int departmentId, string userId, string setKey, string localSource, string localCode, string nerisCode, CancellationToken cancellationToken = default);
+
+ /// Removes a department mapping so the local code is unmapped again; false when there was none.
+ Task RemoveCrosswalkAsync(int departmentId, string setKey, string localSource, string localCode, CancellationToken cancellationToken = default);
+ }
+
+ /// Pure aggregate-to-payload mapping against the pinned contract; original codes ride beside mapped ones.
+ public interface INerisMappingService
+ {
+ /// The exact IncidentPayload JSON the destination receives (the immutable submission artifact).
+ string BuildIncidentPayloadJson(NerisIncidentSnapshot snapshot, RmsNerisProfile profile);
+ }
+
+ /// Local (offline) validation against the pinned contract's requiredness, value sets and time sequence; remote validation through the client.
+ public interface INerisValidationService
+ {
+ List ValidateLocal(NerisIncidentSnapshot snapshot, RmsNerisProfile profile);
+
+ Task> ValidateRemoteAsync(RmsNerisProfile profile, string payloadJson, CancellationToken cancellationToken = default);
+ }
+
+ /// Low-level NERIS API boundary (token, validate, create, update, status, history).
+ public interface INerisApiClient
+ {
+ Task ValidateAsync(RmsNerisProfile profile, NerisCredential credential, string payloadJson, CancellationToken cancellationToken = default);
+
+ Task CreateIncidentAsync(RmsNerisProfile profile, NerisCredential credential, string payloadJson, CancellationToken cancellationToken = default);
+
+ Task UpdateIncidentAsync(RmsNerisProfile profile, NerisCredential credential, string nerisIncidentId, string payloadJson, CancellationToken cancellationToken = default);
+
+ Task GetStatusAsync(RmsNerisProfile profile, NerisCredential credential, string nerisIncidentId, CancellationToken cancellationToken = default);
+ }
+
+ ///
+ /// Orchestrates one submission attempt: create on first delivery, update when the destination already holds the
+ /// incident, status poll while the destination reviews. Never holds a database transaction (plan 5.3); the
+ /// worker persists the outcome afterwards.
+ ///
+ public interface INerisSubmissionService
+ {
+ Task DeliverAsync(RmsNerisProfile profile, RmsSubmission submission, string existingNerisIncidentId, CancellationToken cancellationToken = default);
+
+ Task CheckStatusAsync(RmsNerisProfile profile, string nerisIncidentId, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Records/IncidentReportContracts.cs b/Core/Resgrid.Model/Records/IncidentReportContracts.cs
new file mode 100644
index 00000000..cffe2943
--- /dev/null
+++ b/Core/Resgrid.Model/Records/IncidentReportContracts.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ /// The incident report aggregate as the Web and the mapper read it: header plus the working-draft child rows (or a revision's copies).
+ public class IncidentReportAggregate
+ {
+ public RmsIncidentReport Report { get; set; }
+ public RmsLocation Location { get; set; }
+ public List Types { get; set; } = new List();
+ public List Units { get; set; } = new List();
+ public List Aids { get; set; } = new List();
+ public List Tactics { get; set; } = new List();
+ public RmsNarrative Narrative { get; set; }
+ public List Facts { get; set; } = new List();
+ public List Issues { get; set; } = new List();
+ public List Submissions { get; set; } = new List();
+ public List Signatures { get; set; } = new List();
+ public List Revisions { get; set; } = new List();
+ public List GroupScope { get; set; } = new List();
+
+ public RmsRecordState State => (RmsRecordState)(Report?.State ?? 0);
+ public bool HasBlockingIssues => Issues.Exists(i => i.Severity == (int)RmsValidationSeverity.Error);
+ public string SpecialModifierCodes => Report?.SpecialModifiersCsv ?? string.Empty;
+ }
+
+ public class IncidentTypeInput
+ {
+ public string TypeCode { get; set; }
+ public bool IsPrimary { get; set; }
+ }
+
+ public class IncidentUnitResponseInput
+ {
+ public int? UnitId { get; set; }
+ public string UnitNerisId { get; set; }
+ public string ReportedUnitId { get; set; }
+ public int? Staffing { get; set; }
+ public bool UnableToDispatch { get; set; }
+ public DateTime? DispatchedOn { get; set; }
+ public DateTime? EnrouteOn { get; set; }
+ public DateTime? OnSceneOn { get; set; }
+ public DateTime? CanceledEnrouteOn { get; set; }
+ public DateTime? StagingOn { get; set; }
+ public DateTime? ClearedOn { get; set; }
+ public string ResponseMode { get; set; }
+ public string TransportMode { get; set; }
+ }
+
+ public class IncidentAidInput
+ {
+ public string Direction { get; set; }
+ public string AidType { get; set; }
+ public string CounterpartNerisId { get; set; }
+ public string CounterpartName { get; set; }
+ public bool IsNonFireDepartment { get; set; }
+ public string NonFdType { get; set; }
+ }
+
+ public class IncidentTacticInput
+ {
+ public string TacticCode { get; set; }
+ public int? ActorUnitId { get; set; }
+ public DateTime? OccurredOn { get; set; }
+ }
+
+ public class IncidentLocationInput
+ {
+ public string AddressText { get; set; }
+ public string Number { get; set; }
+ public string NumberPrefix { get; set; }
+ public string NumberSuffix { get; set; }
+ public string Street { get; set; }
+ public string UnitValue { get; set; }
+ public string Municipality { get; set; }
+ public string County { get; set; }
+ public string State { get; set; }
+ public string PostalCode { get; set; }
+ public string Country { get; set; }
+ public string PlaceType { get; set; }
+ public string LocationUse { get; set; }
+ public string CrossStreet1 { get; set; }
+ public string CrossStreet2 { get; set; }
+ public decimal? Latitude { get; set; }
+ public decimal? Longitude { get; set; }
+ public string Jurisdiction { get; set; }
+ }
+
+ ///
+ /// A draft save. Every list replaces the draft rows wholesale (the working draft is the only mutable state);
+ /// prefilled values that the author changes keep their provenance row with the corrected value recorded.
+ ///
+ public class IncidentReportDraftInput
+ {
+ public string IncidentNumber { get; set; }
+ public DateTime? CallCreatedOn { get; set; }
+ public DateTime? CallAnsweredOn { get; set; }
+ public DateTime? CallArrivalOn { get; set; }
+ public DateTime? IncidentClearedOn { get; set; }
+ public string DispatchCenterId { get; set; }
+ public string DeterminantCode { get; set; }
+ public string DispatchIncidentCode { get; set; }
+ public string Disposition { get; set; }
+ public bool? PeoplePresent { get; set; }
+ public int? DisplacementCount { get; set; }
+ public int? AnimalsRescued { get; set; }
+ public List SpecialModifiers { get; set; } = new List();
+ public int? StationGroupId { get; set; }
+ public IncidentLocationInput Location { get; set; }
+ public List Types { get; set; } = new List();
+ public List Units { get; set; } = new List();
+ public List Aids { get; set; } = new List();
+ public List Tactics { get; set; } = new List();
+ public string Narrative { get; set; }
+ public string ImpedimentNarrative { get; set; }
+ public string OutcomeNarrative { get; set; }
+ public string SupplementalJson { get; set; }
+ public RmsOriginClient OriginClient { get; set; } = RmsOriginClient.Web;
+ }
+
+ /// Stable provenance keys for the prefilled facts (RmsSourceFact.FactKey).
+ public static class NerisFactKeys
+ {
+ public const string CallCreate = "dispatch.call_create";
+ public const string CallAnswered = "dispatch.call_answered";
+ public const string CallArrival = "dispatch.call_arrival";
+ public const string IncidentClear = "dispatch.incident_clear";
+ public const string IncidentNumber = "dispatch.incident_number";
+ public const string IncidentCode = "dispatch.incident_code";
+ public const string Location = "base.location";
+ public const string Point = "base.point";
+ public const string IncidentType = "incident_types.primary";
+ public static string UnitTime(int unitId, string field) => $"unit.{unitId}.{field}";
+ }
+}
diff --git a/Core/Resgrid.Model/Records/IncidentReportValidationException.cs b/Core/Resgrid.Model/Records/IncidentReportValidationException.cs
new file mode 100644
index 00000000..4504a222
--- /dev/null
+++ b/Core/Resgrid.Model/Records/IncidentReportValidationException.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model
+{
+ /// Finalize/resubmit refused because local validation found errors; the issues are stored on the report for the author.
+ public class IncidentReportValidationException : InvalidOperationException
+ {
+ public IncidentReportValidationException(string reportId, IReadOnlyList issues)
+ : base($"Incident report {reportId} has {issues?.Count ?? 0} validation error(s) and cannot be signed: " +
+ string.Join("; ", (issues ?? Array.Empty()).Take(5).Select(i => $"{i.FieldPath ?? i.RuleKey}: {i.Message}")))
+ {
+ ReportId = reportId;
+ Issues = issues ?? Array.Empty();
+ }
+
+ public string ReportId { get; }
+ public IReadOnlyList Issues { get; }
+ }
+}
diff --git a/Core/Resgrid.Model/Records/NerisContracts.cs b/Core/Resgrid.Model/Records/NerisContracts.cs
new file mode 100644
index 00000000..289eb703
--- /dev/null
+++ b/Core/Resgrid.Model/Records/NerisContracts.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Everything the NERIS mapping needs from one incident report, read once and handed to a pure mapper
+ /// (RMS plan section 5.5). Built by the incident report service from the aggregate; the mapper never
+ /// touches a repository.
+ ///
+ public class NerisIncidentSnapshot
+ {
+ public RmsIncidentReport Report { get; set; }
+ public RmsLocation Location { get; set; }
+ public List Types { get; set; } = new List();
+ public List Units { get; set; } = new List();
+ public List Aids { get; set; } = new List();
+ public List Tactics { get; set; } = new List();
+ public RmsNarrative Narrative { get; set; }
+ public List Facts { get; set; } = new List();
+ /// Dispatch comments (call notes) carried into dispatch.comments; header text only, never restricted content.
+ public List DispatchComments { get; set; } = new List();
+ /// NERIS special modifiers (MCI, declared disaster ...), value-set codes.
+ public List SpecialModifiers { get; set; } = new List();
+ }
+
+ public class NerisDispatchComment
+ {
+ public DateTime? Timestamp { get; set; }
+ public string Comment { get; set; }
+ }
+
+ /// Department integration credential, decrypted only inside the provider for the duration of a call.
+ public class NerisCredential
+ {
+ public string ClientId { get; set; }
+ public string ClientSecret { get; set; }
+ public string Username { get; set; }
+ public string Password { get; set; }
+ }
+
+ public class NerisValueSet
+ {
+ public string SetKey { get; set; }
+ public string SchemaName { get; set; }
+ public IReadOnlyList Codes { get; set; } = new List();
+ }
+
+ public enum NerisOutcomeKind
+ {
+ /// The destination created the incident (first submission).
+ Created = 1,
+ /// The destination accepted an update to an existing incident.
+ Updated = 2,
+ /// The destination's status shows the incident approved/accepted.
+ Accepted = 3,
+ /// The destination rejected the payload (validation 422 or a REJECTED status).
+ Rejected = 4,
+ /// Network, throttling or 5xx: retry with the same idempotency key.
+ Transient = 5,
+ /// Configuration or authentication problem an operator must fix; no automatic retry.
+ Fatal = 6,
+ /// Created at the destination but still awaiting its review outcome.
+ Pending = 7
+ }
+
+ /// One exchange with the destination, reduced to what the submission row and the workflow triggers may see.
+ public class NerisSubmissionOutcome
+ {
+ public NerisOutcomeKind Kind { get; set; }
+ public int? StatusCode { get; set; }
+ public string ExternalId { get; set; }
+ public string ExternalStatus { get; set; }
+ /// Verbatim response body for the immutable artifact.
+ public string ResponseJson { get; set; }
+ /// Normalized, non-sensitive error codes and field paths.
+ public List Errors { get; set; } = new List();
+ public string Message { get; set; }
+ }
+
+ public class NerisSubmissionError
+ {
+ public string Code { get; set; }
+ public string FieldPath { get; set; }
+ public string Message { get; set; }
+ }
+
+ public class NerisIncidentStatus
+ {
+ public string NerisIncidentId { get; set; }
+ public string Status { get; set; }
+ public DateTime? LastModified { get; set; }
+ public string CreatedBy { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Records/RecordsApiContracts.cs b/Core/Resgrid.Model/Records/RecordsApiContracts.cs
new file mode 100644
index 00000000..b208705f
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RecordsApiContracts.cs
@@ -0,0 +1,265 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model
+{
+ ///
+ /// The v4 Records client contract (RMS plan sections 5.3, 5.4, 5.9.1): a versioned capability manifest, ETag
+ /// conflicts that name the changed field paths, scoped idempotency for creates and commands, a delta cursor with
+ /// tombstones, and resumable, checksummed attachment sessions. Web and the field apps share this contract.
+ ///
+ public static class RecordsApiContract
+ {
+ /// Bumped when a field or rule type is added that older clients cannot render; the manifest carries it.
+ public const string Version = "records.v1";
+
+ /// The minimum capability every locked Logs-parity definition needs; department definitions (RMS-1B designer) derive their own.
+ public const string LockedDefinitionCapability = "records.v1";
+
+ public const string ETagHeader = "ETag";
+ public const string IfMatchHeader = "If-Match";
+ public const string IdempotencyKeyHeader = "Idempotency-Key";
+ public const string ClientHeader = "X-Resgrid-Client";
+
+ /// Weak ETag from a row version: W/"7".
+ public static string ToETag(long rowVersion) => $"W/\"{rowVersion}\"";
+
+ /// Parses W/"7", "7" or 7; null when absent or malformed.
+ public static long? ParseETag(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return null;
+ var text = value.Trim();
+ if (text.StartsWith("W/", StringComparison.OrdinalIgnoreCase))
+ text = text.Substring(2);
+ text = text.Trim('"', ' ');
+ return long.TryParse(text, out var parsed) && parsed >= 0 ? parsed : (long?)null;
+ }
+ }
+
+ /// One field of a locked definition as the manifest describes it.
+ public class RecordFieldDescriptor
+ {
+ public string Key { get; set; }
+ public string Section { get; set; }
+ /// text, longtext, datetime, int, user, unit, bool.
+ public string Type { get; set; }
+ public bool Required { get; set; }
+ public bool RequiredToFinalize { get; set; }
+ /// Restricted-section field: withheld without RecordRestricted_View, never in search or summaries.
+ public bool Restricted { get; set; }
+ }
+
+ /// A published definition as the capability manifest lists it (plan section 5.4).
+ public class RecordDefinitionDescriptor
+ {
+ public string Key { get; set; }
+ public int Version { get; set; }
+ public string Name { get; set; }
+ /// RmsOperationalRecordType for the locked Logs-parity definitions; null for the NERIS incident report.
+ public int? RecordType { get; set; }
+ public string RecordKind { get; set; }
+ public int LifecyclePreset { get; set; }
+ public string LifecyclePresetName { get; set; }
+ public string Cardinality { get; set; }
+ public bool Restricted { get; set; }
+ public string NumberPrefix { get; set; }
+ public bool RequiresCall { get; set; }
+ public bool SupportsParticipants { get; set; }
+ public bool SupportsUnits { get; set; }
+ public bool SupportsAttachments { get; set; }
+ public string MinimumClientCapability { get; set; }
+ public bool Locked { get; set; } = true;
+ public List Fields { get; set; } = new List();
+ }
+
+ ///
+ /// The locked definition catalog the manifest publishes. Field applicability mirrors the Records authoring form:
+ /// a narrative on every type, the type-specific columns, and the Coroner restricted section.
+ ///
+ public static class RecordDefinitionCatalog
+ {
+ public static List Describe()
+ {
+ var list = new List();
+ foreach (var kv in RmsDefinitionKeys.LockedTypes)
+ {
+ var type = kv.Value;
+ var restricted = RmsDefinitionKeys.RestrictedClass.Contains(kv.Key);
+ var descriptor = new RecordDefinitionDescriptor
+ {
+ Key = kv.Key,
+ Version = RmsDefinitionKeys.LockedDefinitionVersion,
+ Name = type.ToString(),
+ RecordType = (int)type,
+ RecordKind = RmsRecordKind.Operational.ToString(),
+ LifecyclePreset = (int)RmsDefinitionKeys.LockedDefaultPreset,
+ LifecyclePresetName = RmsDefinitionKeys.LockedDefaultPreset.ToString(),
+ Cardinality = RmsDefinitionKeys.CardinalityFor(kv.Key).ToString(),
+ Restricted = restricted,
+ NumberPrefix = RmsDefinitionKeys.DefaultNumberPrefix(kv.Key),
+ RequiresCall = false,
+ SupportsParticipants = type != RmsOperationalRecordType.UnitActivity,
+ SupportsUnits = true,
+ SupportsAttachments = true,
+ MinimumClientCapability = RecordsApiContract.LockedDefinitionCapability
+ };
+ descriptor.Fields.AddRange(FieldsFor(type));
+ list.Add(descriptor);
+ }
+
+ list.Add(new RecordDefinitionDescriptor
+ {
+ Key = RmsDefinitionKeys.NerisIncidentReport,
+ Version = RmsDefinitionKeys.LockedDefinitionVersion,
+ Name = "NERIS incident report",
+ RecordType = null,
+ RecordKind = RmsRecordKind.IncidentReport.ToString(),
+ LifecyclePreset = (int)RmsDefinitionKeys.LockedDefaultPreset,
+ LifecyclePresetName = RmsDefinitionKeys.LockedDefaultPreset.ToString(),
+ Cardinality = RmsDefinitionKeys.CardinalityFor(RmsDefinitionKeys.NerisIncidentReport).ToString(),
+ Restricted = false,
+ NumberPrefix = "INC",
+ RequiresCall = true,
+ SupportsParticipants = false,
+ SupportsUnits = true,
+ SupportsAttachments = false,
+ MinimumClientCapability = RecordsApiContract.LockedDefinitionCapability,
+ Fields = new List
+ {
+ F("IncidentNumber", "Dispatch", "text", true, true), F("CallCreatedOn", "Dispatch", "datetime", true, true), F("CallAnsweredOn", "Dispatch", "datetime", true, true),
+ F("CallArrivalOn", "Dispatch", "datetime", false, true), F("IncidentClearedOn", "Dispatch", "datetime"), F("DispatchIncidentCode", "Dispatch", "text"),
+ F("Location", "Location", "location", true, true), F("Types", "Incident", "codes", true, true), F("Units", "Units", "unit-responses", true, true),
+ F("Aids", "Incident", "aids"), F("Tactics", "Incident", "tactics"), F("Narrative", "Narrative", "longtext"), F("SpecialModifiers", "Incident", "codes")
+ }
+ });
+
+ return list;
+ }
+
+ public static List FieldsFor(RmsOperationalRecordType type)
+ {
+ var fields = new List
+ {
+ F("StartedOn", "Record", "datetime"), F("EndedOn", "Record", "datetime"), F("StationGroupId", "Record", "group"), F("ExternalId", "Record", "text"),
+ F("Narrative", "Details", "longtext", false, true)
+ };
+ switch (type)
+ {
+ case RmsOperationalRecordType.Training:
+ fields.AddRange(new[] { F("Course", "Details", "text"), F("CourseCode", "Details", "text"), F("Instructors", "Details", "text") });
+ break;
+ case RmsOperationalRecordType.Meeting:
+ fields.AddRange(new[] { F("Type", "Details", "text"), F("Facilitator", "Details", "text") });
+ break;
+ case RmsOperationalRecordType.Run:
+ case RmsOperationalRecordType.Callback:
+ fields.AddRange(new[] { F("CallId", "Record", "call"), F("InitialReport", "Details", "text"), F("Cause", "Details", "text"), F("InvestigatedByUserId", "Details", "user"), F("OtherAgencies", "Details", "text"), F("OtherUnits", "Details", "text") });
+ break;
+ case RmsOperationalRecordType.UnitActivity:
+ fields.AddRange(new[] { F("UnitId", "Details", "unit", true, true), F("ActivityOn", "Details", "datetime", false, true) });
+ break;
+ case RmsOperationalRecordType.Coroner:
+ fields.AddRange(new[]
+ {
+ F("CaseNumber", "Restricted", "text", false, false, true), F("PronouncedDeceasedBy", "Restricted", "text", false, false, true), F("BodyLocation", "Restricted", "text", false, false, true),
+ F("Destination", "Restricted", "text", false, false, true), F("OtherPersonnel", "Restricted", "text", false, false, true), F("ContactName", "Restricted", "text"), F("ContactNumber", "Restricted", "text")
+ });
+ break;
+ }
+ if (type != RmsOperationalRecordType.Coroner)
+ fields.AddRange(new[] { F("Location", "Details", "text"), F("OtherPersonnel", "Details", "text") });
+ return fields;
+ }
+
+ private static RecordFieldDescriptor F(string key, string section, string type, bool required = false, bool requiredToFinalize = false, bool restricted = false)
+ {
+ return new RecordFieldDescriptor { Key = key, Section = section, Type = type, Required = required, RequiredToFinalize = requiredToFinalize, Restricted = restricted };
+ }
+ }
+
+ /// Draft save refused because the client's row version is stale: what changed, so the client reconciles deliberately (plan section 5.3).
+ public class RecordDraftConflict
+ {
+ public string RecordId { get; set; }
+ public long ExpectedRowVersion { get; set; }
+ public long CurrentRowVersion { get; set; }
+ public RmsRecordState CurrentState { get; set; }
+ public string CurrentRevisionId { get; set; }
+ public List ChangedFieldPaths { get; set; } = new List();
+ }
+
+ public enum RecordUploadSessionState
+ {
+ Open = 1,
+ Completed = 2,
+ Aborted = 3
+ }
+
+ /// A resumable attachment upload (plan section 5.3): declared size and SHA-256 up front, chunks in order, finalize only after checksum, hygiene and scanning pass.
+ public class RecordAttachmentUploadSession
+ {
+ public string UploadId { get; set; }
+ public int DepartmentId { get; set; }
+ public string RecordId { get; set; }
+ public string UserId { get; set; }
+ public string FileName { get; set; }
+ public string ContentType { get; set; }
+ public long DeclaredSize { get; set; }
+ /// Lower-case hex SHA-256 of the whole file, declared by the client and verified at completion.
+ public string Sha256 { get; set; }
+ public long ReceivedBytes { get; set; }
+ public int ChunkSize { get; set; }
+ public int ChunkCount { get; set; }
+ public RecordUploadSessionState State { get; set; } = RecordUploadSessionState.Open;
+ public string AttachmentId { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ExpiresOn { get; set; }
+ public bool IsComplete => ReceivedBytes >= DeclaredSize;
+ }
+
+ public class RecordUploadSessionException : InvalidOperationException
+ {
+ public RecordUploadSessionException(string code, string message) : base(message)
+ {
+ Code = code;
+ }
+
+ /// not_found, expired, closed, bad_offset, too_large, checksum_mismatch, incomplete, rejected.
+ public string Code { get; }
+ }
+}
+
+namespace Resgrid.Model.Services
+{
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ /// Short-lived, keyed state behind the v4 Records contract (upload sessions, command idempotency). Redis when caching is on; process memory otherwise.
+ public interface IRecordsApiStateStore
+ {
+ Task GetAsync(string key);
+ Task SetAsync(string key, string value, TimeSpan timeToLive);
+ Task RemoveAsync(string key);
+ }
+
+ /// Resumable, checksummed attachment sessions for Records (plan section 5.3).
+ public interface IRecordAttachmentUploadService
+ {
+ int ChunkSize { get; }
+ Task BeginAsync(int departmentId, string userId, string recordId, string fileName, string contentType, long declaredSize, string sha256);
+ Task GetAsync(int departmentId, string userId, string uploadId);
+ Task AppendAsync(int departmentId, string userId, string uploadId, long offset, byte[] data);
+ /// Verifies size and SHA-256, then stores the attachment through the same hygiene/scanner path as a direct upload.
+ Task CompleteAsync(int departmentId, string userId, string uploadId, string description, CancellationToken cancellationToken = default);
+ Task AbortAsync(int departmentId, string userId, string uploadId);
+ }
+
+ /// Scoped idempotency for v4 Records commands: the same (department, user, key) replays the first outcome instead of re-running the transition.
+ public interface IRecordsApiIdempotencyService
+ {
+ Task TryGetRecordIdAsync(int departmentId, string userId, string idempotencyKey);
+ Task RememberAsync(int departmentId, string userId, string idempotencyKey, string recordId);
+ }
+}
diff --git a/Core/Resgrid.Model/Records/RmsIncidentReport.cs b/Core/Resgrid.Model/Records/RmsIncidentReport.cs
new file mode 100644
index 00000000..2aacdcd9
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RmsIncidentReport.cs
@@ -0,0 +1,499 @@
+using System;
+using System.Collections.Generic;
+using ProtoBuf;
+
+namespace Resgrid.Model
+{
+ ///
+ /// NERIS incident report aggregate root (RMS plan sections 4.2, 5.2, RMS-2; registry M0164). One per
+ /// (DepartmentId, CallId, ReportingEntityId, DefinitionKey) — the SingleAuthoritative cardinality of
+ /// section 5.2.1: one official incident report per responding agency, never one per company. Shares the
+ /// lifecycle vocabulary of RmsOperationalRecord (RmsRecordState, RmsLifecycle) and the reference shape
+ /// (DepartmentId, RecordId, RecordKind = IncidentReport, RevisionId) that RmsRevision, RmsAccessAudit,
+ /// RmsSubmission and RmsSignature key on. Times live on the typed rows below; every prefilled value's
+ /// provenance is an RmsSourceFact.
+ ///
+ [ProtoContract]
+ public class RmsIncidentReport : IEntity
+ {
+ [ProtoMember(1)]
+ public string RmsIncidentReportId { get; set; }
+
+ [ProtoMember(2)]
+ public int DepartmentId { get; set; }
+
+ [ProtoMember(3)]
+ public string ProtectionId { get; set; }
+
+ [ProtoMember(4)]
+ public int CallId { get; set; }
+
+ /// The department's NERIS entity ID at creation, or a department placeholder before a profile exists.
+ [ProtoMember(5)]
+ public string ReportingEntityId { get; set; }
+
+ [ProtoMember(6)]
+ public string DefinitionKey { get; set; }
+
+ [ProtoMember(7)]
+ public int DefinitionVersion { get; set; }
+
+ /// Pinned NERIS contract version (value sets and payload shape) this report was authored against.
+ [ProtoMember(8)]
+ public string ProfileVersion { get; set; }
+
+ [ProtoMember(9)]
+ public int LifecyclePreset { get; set; }
+
+ /// , including the reporting-destination states.
+ [ProtoMember(10)]
+ public int State { get; set; }
+
+ [ProtoMember(11)]
+ public string RecordNumber { get; set; }
+
+ [ProtoMember(12)]
+ public string DraftReference { get; set; }
+
+ /// Department incident number sent to NERIS (Call.Number unless the author overrides it).
+ [ProtoMember(13)]
+ public string IncidentNumber { get; set; }
+
+ [ProtoMember(14)]
+ public string DisplaySummary { get; set; }
+
+ [ProtoMember(15)]
+ public int? StationGroupId { get; set; }
+
+ [ProtoMember(16)]
+ public string AuthorUserId { get; set; }
+
+ [ProtoMember(17)]
+ public string OwnerUserId { get; set; }
+
+ [ProtoMember(18)]
+ public string ReviewerUserId { get; set; }
+
+ [ProtoMember(19)]
+ public string ApproverUserId { get; set; }
+
+ [ProtoMember(20)]
+ public DateTime? ReviewDueOn { get; set; }
+
+ [ProtoMember(21)]
+ public DateTime? SubmittedForReviewOn { get; set; }
+
+ [ProtoMember(22)]
+ public DateTime? ReturnedOn { get; set; }
+
+ [ProtoMember(23)]
+ public string ReturnReasonCode { get; set; }
+
+ [ProtoMember(24)]
+ public string ReturnReasonText { get; set; }
+
+ [ProtoMember(25)]
+ public int ReturnCount { get; set; }
+
+ [ProtoMember(26)]
+ public DateTime? ApprovedOn { get; set; }
+
+ [ProtoMember(27)]
+ public DateTime? FinalizedOn { get; set; }
+
+ [ProtoMember(28)]
+ public string FinalizedByUserId { get; set; }
+
+ [ProtoMember(29)]
+ public string CurrentRevisionId { get; set; }
+
+ [ProtoMember(30)]
+ public int RevisionCount { get; set; }
+
+ [ProtoMember(31)]
+ public string AmendsRevisionId { get; set; }
+
+ [ProtoMember(32)]
+ public DateTime? VoidedOn { get; set; }
+
+ [ProtoMember(33)]
+ public string VoidedByUserId { get; set; }
+
+ [ProtoMember(34)]
+ public string VoidReasonCode { get; set; }
+
+ [ProtoMember(35)]
+ public string VoidReasonText { get; set; }
+
+ [ProtoMember(36)]
+ public DateTime? CancelledOn { get; set; }
+
+ [ProtoMember(37)]
+ public string CancelledByUserId { get; set; }
+
+ /// NERIS-assigned incident ID once the first create succeeded; later submissions update it in place.
+ [ProtoMember(38)]
+ public string NerisIncidentId { get; set; }
+
+ [ProtoMember(39)]
+ public string LastSubmissionId { get; set; }
+
+ /// of the latest submission, denormalized for queues.
+ [ProtoMember(40)]
+ public int? LastSubmissionState { get; set; }
+
+ [ProtoMember(41)]
+ public DateTime? LastSubmittedOn { get; set; }
+
+ [ProtoMember(42)]
+ public DateTime? AcceptedOn { get; set; }
+
+ [ProtoMember(43)]
+ public DateTime? RejectedOn { get; set; }
+
+ /// Normalized, non-sensitive summary of the last rejection (codes and field paths, never payloads).
+ [ProtoMember(44)]
+ public string RejectionSummary { get; set; }
+
+ // Dispatch-level facts (NERIS dispatch.*): prefilled from the Call with provenance rows, editable in Draft.
+ [ProtoMember(53)]
+ public DateTime? CallCreatedOn { get; set; }
+
+ [ProtoMember(54)]
+ public DateTime? CallAnsweredOn { get; set; }
+
+ /// First unit on scene (NERIS call_arrival).
+ [ProtoMember(55)]
+ public DateTime? CallArrivalOn { get; set; }
+
+ [ProtoMember(56)]
+ public DateTime? IncidentClearedOn { get; set; }
+
+ [ProtoMember(57)]
+ public string DispatchCenterId { get; set; }
+
+ [ProtoMember(58)]
+ public string DeterminantCode { get; set; }
+
+ /// The CAD/Resgrid incident code as dispatched (Call.Type), kept verbatim beside the mapped incident type.
+ [ProtoMember(59)]
+ public string DispatchIncidentCode { get; set; }
+
+ [ProtoMember(60)]
+ public string Disposition { get; set; }
+
+ [ProtoMember(61)]
+ public bool? PeoplePresent { get; set; }
+
+ [ProtoMember(62)]
+ public int? DisplacementCount { get; set; }
+
+ [ProtoMember(63)]
+ public int? AnimalsRescued { get; set; }
+
+ /// Comma-separated NERIS special_modifiers codes.
+ [ProtoMember(64)]
+ public string SpecialModifiersCsv { get; set; }
+
+ [ProtoMember(45)]
+ public string IdempotencyKey { get; set; }
+
+ [ProtoMember(46)]
+ public int OriginClient { get; set; }
+
+ [ProtoMember(47)]
+ public DateTime CreatedOn { get; set; }
+
+ [ProtoMember(48)]
+ public string CreatedByUserId { get; set; }
+
+ [ProtoMember(49)]
+ public DateTime ModifiedOn { get; set; }
+
+ [ProtoMember(50)]
+ public string ModifiedByUserId { get; set; }
+
+ [ProtoMember(51)]
+ public long RowVersion { get; set; }
+
+ [ProtoMember(52)]
+ public DateTime? DeletedOn { get; set; }
+
+ public string RecordId => RmsIncidentReportId;
+
+ public RmsRecordKind RecordKind => RmsRecordKind.IncidentReport;
+
+ public object IdValue
+ {
+ get => RmsIncidentReportId;
+ set => RmsIncidentReportId = (string)value;
+ }
+
+ public string TableName => "RmsIncidentReports";
+
+ public string IdName => "RmsIncidentReportId";
+
+ public int IdType => 1;
+
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "RecordId", "RecordKind" };
+ }
+
+ ///
+ /// Provenance ledger for every prefilled value (plan section 4.2 "Timestamp authority"): where the value came
+ /// from, what it was when imported, and what the author changed it to. Typed rows hold the current value;
+ /// this row keeps the SourceKind and the original so provenance is never lost.
+ ///
+ public class RmsSourceFact : IEntity
+ {
+ public string RmsSourceFactId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ /// Null for the working draft; the revision copy on finalize.
+ public string RevisionId { get; set; }
+ /// Stable key such as dispatch.call_create, unit.{unitId}.on_scene, location.address.
+ public string FactKey { get; set; }
+ /// .
+ public int SourceKind { get; set; }
+ public string SourceSystem { get; set; }
+ public string SourceEntityType { get; set; }
+ public string SourceEntityId { get; set; }
+ /// The value as imported (ISO-8601 for times), retained verbatim.
+ public string SourceValue { get; set; }
+ /// The value currently on the typed row; differs from SourceValue once the author edited it.
+ public string CurrentValue { get; set; }
+ public DateTime? SourceTime { get; set; }
+ public DateTime ImportedOn { get; set; }
+ public DateTime? CorrectedOn { get; set; }
+ public string CorrectedByUserId { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsSourceFactId; set => RmsSourceFactId = (string)value; }
+ public string TableName => "RmsSourceFacts";
+ public string IdName => "RmsSourceFactId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// Unit response on an incident report (NERIS unit_responses), with per-unit times and their provenance.
+ public class RmsUnitResponse : IEntity
+ {
+ public string RmsUnitResponseId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ public int? UnitId { get; set; }
+ public string UnitNameSnapshot { get; set; }
+ public string UnitTypeSnapshot { get; set; }
+ public string UnitNerisId { get; set; }
+ public int? StationGroupIdSnapshot { get; set; }
+ public int? Staffing { get; set; }
+ public bool UnableToDispatch { get; set; }
+ public DateTime? DispatchedOn { get; set; }
+ public DateTime? EnrouteOn { get; set; }
+ public DateTime? OnSceneOn { get; set; }
+ public DateTime? CanceledEnrouteOn { get; set; }
+ public DateTime? StagingOn { get; set; }
+ public DateTime? ClearedOn { get; set; }
+ /// NERIS response_mode: EMERGENT or NON_EMERGENT.
+ public string ResponseMode { get; set; }
+ public string TransportMode { get; set; }
+ /// of the prefilled times.
+ public int TimesSourceKind { get; set; }
+ public string Disposition { get; set; }
+ public int Ordinal { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsUnitResponseId; set => RmsUnitResponseId = (string)value; }
+ public string TableName => "RmsUnitResponses";
+ public string IdName => "RmsUnitResponseId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// Versioned NERIS incident type assignment; LocalCode keeps the original Resgrid call type beside the mapped value.
+ public class RmsIncidentType : IEntity
+ {
+ public string RmsIncidentTypeId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ /// NERIS incident_type value, e.g. FIRE||STRUCTURE_FIRE||...
+ public string TypeCode { get; set; }
+ public bool IsPrimary { get; set; }
+ public string LocalCode { get; set; }
+ public string ValueSetVersion { get; set; }
+ public int Ordinal { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsIncidentTypeId; set => RmsIncidentTypeId = (string)value; }
+ public string TableName => "RmsIncidentTypes";
+ public string IdName => "RmsIncidentTypeId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// Action/tactic taken on the incident (NERIS actions_tactics), with actor, time, and provenance.
+ public class RmsActionTactic : IEntity
+ {
+ public string RmsActionTacticId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ public string TacticCode { get; set; }
+ public int? ActorUnitId { get; set; }
+ public DateTime? OccurredOn { get; set; }
+ public int SourceKind { get; set; }
+ public string Outcome { get; set; }
+ public int Ordinal { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsActionTacticId; set => RmsActionTacticId = (string)value; }
+ public string TableName => "RmsActionTactics";
+ public string IdName => "RmsActionTacticId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// Aid given or received on the single report (plan section 5.2.1: never a fabricated counterpart report).
+ public class RmsAid : IEntity
+ {
+ public string RmsAidId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ /// GIVEN or RECEIVED.
+ public string Direction { get; set; }
+ /// ACTING_AS_AID, IN_LIEU_AID or SUPPORT_AID.
+ public string AidType { get; set; }
+ public string CounterpartNerisId { get; set; }
+ public string CounterpartName { get; set; }
+ public bool IsNonFireDepartment { get; set; }
+ public string NonFdType { get; set; }
+ public int Ordinal { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsAidId; set => RmsAidId = (string)value; }
+ public string TableName => "RmsAids";
+ public string IdName => "RmsAidId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// Structured incident location snapshot (NERIS base.location) plus the geopoint.
+ public class RmsLocation : IEntity
+ {
+ public string RmsLocationId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ public string AddressText { get; set; }
+ public string Number { get; set; }
+ public string NumberPrefix { get; set; }
+ public string NumberSuffix { get; set; }
+ public string Street { get; set; }
+ public string UnitValue { get; set; }
+ public string Municipality { get; set; }
+ public string County { get; set; }
+ public string State { get; set; }
+ public string PostalCode { get; set; }
+ public string Country { get; set; }
+ public string PlaceType { get; set; }
+ public string LocationUse { get; set; }
+ public string CrossStreet1 { get; set; }
+ public string CrossStreet2 { get; set; }
+ public decimal? Latitude { get; set; }
+ public decimal? Longitude { get; set; }
+ public string Jurisdiction { get; set; }
+ public int SourceKind { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsLocationId; set => RmsLocationId = (string)value; }
+ public string TableName => "RmsLocations";
+ public string IdName => "RmsLocationId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ /// Authored narrative sections; a protected-candidate holder, so it carries the inert envelope columns.
+ public class RmsNarrative : IEntity
+ {
+ public string RmsNarrativeId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ public string Narrative { get; set; }
+ public string ImpedimentNarrative { get; set; }
+ public string OutcomeNarrative { get; set; }
+ /// Supplemental department questions captured on the report; never enters the submission payload.
+ public string SupplementalJson { get; set; }
+ public bool IsProtected { get; set; }
+ public string ProtectedEnvelope { get; set; }
+ public int ProtectedCatalogVersion { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsNarrativeId; set => RmsNarrativeId = (string)value; }
+ public string TableName => "RmsNarratives";
+ public string IdName => "RmsNarrativeId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ public enum RmsValidationSeverity
+ {
+ Error = 1,
+ Warning = 2,
+ Info = 3
+ }
+
+ public enum RmsValidationSource
+ {
+ Local = 1,
+ Destination = 2
+ }
+
+ /// One validation finding against a profile/rule version; replaced on every validation run.
+ public class RmsValidationIssue : IEntity
+ {
+ public string RmsValidationIssueId { get; set; }
+ public int DepartmentId { get; set; }
+ public string RecordId { get; set; }
+ public string RevisionId { get; set; }
+ public string ProfileVersion { get; set; }
+ public string RuleKey { get; set; }
+ /// .
+ public int Severity { get; set; }
+ public string FieldPath { get; set; }
+ public string Message { get; set; }
+ /// .
+ public int Source { get; set; }
+ public DateTime? ResolvedOn { get; set; }
+ public DateTime CreatedOn { get; set; }
+
+ public object IdValue { get => RmsValidationIssueId; set => RmsValidationIssueId = (string)value; }
+ public string TableName => "RmsValidationIssues";
+ public string IdName => "RmsValidationIssueId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/Records/RmsLifecycle.cs b/Core/Resgrid.Model/Records/RmsLifecycle.cs
index 554642b5..296fcc62 100644
--- a/Core/Resgrid.Model/Records/RmsLifecycle.cs
+++ b/Core/Resgrid.Model/Records/RmsLifecycle.cs
@@ -106,6 +106,9 @@ public static bool IsEditable(RmsRecordState state)
case RmsRecordState.Amended: return WorkflowTriggerEventType.RecordAmended;
case RmsRecordState.Voided: return WorkflowTriggerEventType.RecordVoided;
case RmsRecordState.Cancelled: return WorkflowTriggerEventType.RecordCancelled;
+ case RmsRecordState.Submitted: return WorkflowTriggerEventType.RecordSubmissionQueued;
+ case RmsRecordState.Accepted: return WorkflowTriggerEventType.RecordSubmissionAccepted;
+ case RmsRecordState.Rejected: return WorkflowTriggerEventType.RecordSubmissionRejected;
default: return null;
}
}
diff --git a/Core/Resgrid.Model/Records/RmsNerisProfile.cs b/Core/Resgrid.Model/Records/RmsNerisProfile.cs
new file mode 100644
index 00000000..bfe20117
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RmsNerisProfile.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ public static class NerisEnvironments
+ {
+ public const string Production = "production";
+ public const string Sandbox = "sandbox";
+ }
+
+ public static class NerisGrantTypes
+ {
+ public const string ClientCredentials = "client_credentials";
+ public const string Password = "password";
+ }
+
+ ///
+ /// A department's NERIS reporting profile (registry M0166): its NERIS entity ID, environment, and integration
+ /// credential (encrypted per department through IEncryptionService). Records is useful without one (plan
+ /// decision 21); the profile only turns on submission.
+ ///
+ public class RmsNerisProfile : IEntity
+ {
+ public string RmsNerisProfileId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ /// NERIS entity ID (neris_id_entity) the department files under.
+ public string NerisEntityId { get; set; }
+ public string EntityName { get; set; }
+ /// .
+ public string Environment { get; set; }
+ /// Optional base URL override (sandbox or a test double); the config default otherwise.
+ public string BaseUrlOverride { get; set; }
+ /// .
+ public string GrantType { get; set; }
+ /// Department-encrypted JSON: {"client_id","client_secret"} or {"username","password"}.
+ public string EncryptedCredentialJson { get; set; }
+ /// Pinned contract version the department maps against.
+ public string ContractVersion { get; set; }
+ public bool AutoSubmitOnFinalize { get; set; }
+ public bool IsEnabled { get; set; }
+ public DateTime? LastTokenIssuedOn { get; set; }
+ public DateTime? LastSuccessfulCallOn { get; set; }
+ public string LastError { get; set; }
+ public string UpdatedByUserId { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public bool HasCredential => !string.IsNullOrWhiteSpace(EncryptedCredentialJson);
+
+ public object IdValue { get => RmsNerisProfileId; set => RmsNerisProfileId = (string)value; }
+ public string TableName => "RmsNerisProfiles";
+ public string IdName => "RmsNerisProfileId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "HasCredential" };
+ }
+
+ /// One code of one NERIS value set at one contract version (seeded from the pinned snapshot; global reference data).
+ public class RmsNerisValueSetEntry : IEntity
+ {
+ public int RmsNerisValueSetEntryId { get; set; }
+ public string ContractVersion { get; set; }
+ /// Snapshot set key, e.g. incident_type, action_tactic, aid_type.
+ public string SetKey { get; set; }
+ public string Code { get; set; }
+ public string Label { get; set; }
+ public string ParentCode { get; set; }
+ public int SortOrder { get; set; }
+ public bool IsRetired { get; set; }
+ public DateTime CreatedOn { get; set; }
+
+ public object IdValue { get => RmsNerisValueSetEntryId; set => RmsNerisValueSetEntryId = (int)value; }
+ public string TableName => "RmsNerisValueSets";
+ public string IdName => "RmsNerisValueSetEntryId";
+ public int IdType => 0;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ public static class NerisCrosswalkSources
+ {
+ public const string CallType = "CallType";
+ public const string CallPriority = "CallPriority";
+ public const string UnitType = "UnitType";
+ }
+
+ /// Department-owned crosswalk from a Resgrid/CAD code to a NERIS value; the original code is always kept beside the mapped one.
+ public class RmsNerisCrosswalk : IEntity
+ {
+ public string RmsNerisCrosswalkId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string ContractVersion { get; set; }
+ public string SetKey { get; set; }
+ /// .
+ public string LocalSource { get; set; }
+ public string LocalCode { get; set; }
+ public string NerisCode { get; set; }
+ public bool IsDefault { get; set; }
+ public string CreatedByUserId { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+ public DateTime? DeletedOn { get; set; }
+
+ public object IdValue { get => RmsNerisCrosswalkId; set => RmsNerisCrosswalkId = (string)value; }
+ public string TableName => "RmsNerisCrosswalks";
+ public string IdName => "RmsNerisCrosswalkId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/Records/RmsSubmission.cs b/Core/Resgrid.Model/Records/RmsSubmission.cs
new file mode 100644
index 00000000..523175cb
--- /dev/null
+++ b/Core/Resgrid.Model/Records/RmsSubmission.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ public enum RmsSubmissionState
+ {
+ Queued = 0,
+ InFlight = 1,
+ Accepted = 2,
+ Rejected = 3,
+ /// Delivery exhausted its retries or needs an operator; the record is not silently dropped.
+ Failed = 4,
+ /// A newer revision issued a new idempotency key; this submission is history only.
+ Superseded = 5,
+ /// Created at the destination and awaiting its review outcome (NERIS SUBMITTED / PENDING_APPROVAL).
+ AwaitingDestination = 6
+ }
+
+ public static class RmsSubmissionDestinations
+ {
+ public const string Neris = "NERIS";
+ }
+
+ ///
+ /// One outbound exchange with a reporting destination (plan section 5.3: every exact payload and response is
+ /// an immutable, checksummed artifact; retries reuse the idempotency key; an amendment issues a new one). Worker
+ /// 41 (RmsSubmissionCommand) claims Queued/AwaitingDestination rows with a lease and never holds a database
+ /// transaction across the destination call.
+ ///
+ public class RmsSubmission : IEntity
+ {
+ public string RmsSubmissionId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ /// .
+ public int RecordKind { get; set; }
+ public string RevisionId { get; set; }
+ public string Destination { get; set; }
+ public string DestinationVersion { get; set; }
+ /// Scoped idempotency key: one per (record, revision); reused on every retry.
+ public string IdempotencyKey { get; set; }
+ /// .
+ public int State { get; set; }
+ public int Attempts { get; set; }
+ public int MaxAttempts { get; set; }
+ public DateTime? NextAttemptOn { get; set; }
+ public string LeaseOwner { get; set; }
+ public DateTime? LeaseExpiresOn { get; set; }
+ /// The exact outbound payload, immutable once queued.
+ public string PayloadJson { get; set; }
+ public string PayloadChecksum { get; set; }
+ /// The last destination response body, stored verbatim for audit; never surfaced to workflows or notifications.
+ public string ResponseJson { get; set; }
+ public string ResponseChecksum { get; set; }
+ public int? ResponseStatusCode { get; set; }
+ /// Destination identifier (NERIS incident ID).
+ public string ExternalId { get; set; }
+ public string ExternalStatus { get; set; }
+ /// Normalized, non-sensitive error codes and field paths; what workflows and notifications may see.
+ public string ErrorSummary { get; set; }
+ public DateTime QueuedOn { get; set; }
+ public DateTime? SentOn { get; set; }
+ public DateTime? CompletedOn { get; set; }
+ public string CreatedByUserId { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsSubmissionId; set => RmsSubmissionId = (string)value; }
+ public string TableName => "RmsSubmissions";
+ public string IdName => "RmsSubmissionId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ public enum RmsSignatureIntent
+ {
+ Attestation = 1,
+ Review = 2,
+ Approval = 3
+ }
+
+ public enum RmsSignatureMethod
+ {
+ WebAttestation = 1
+ }
+
+ /// Officer attestation captured at finalize: signer, role, intent, statement version, method, time, and the revision checksum it binds to.
+ public class RmsSignature : IEntity
+ {
+ public string RmsSignatureId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ProtectionId { get; set; }
+ public string RecordId { get; set; }
+ public int RecordKind { get; set; }
+ public string RevisionId { get; set; }
+ public string SignerUserId { get; set; }
+ public string SignerNameSnapshot { get; set; }
+ public string SignerRoleSnapshot { get; set; }
+ /// .
+ public int Intent { get; set; }
+ public string StatementVersion { get; set; }
+ public string StatementText { get; set; }
+ /// .
+ public int Method { get; set; }
+ public DateTime SignedOn { get; set; }
+ public string IpAddress { get; set; }
+ /// SHA-256 of the revision snapshot the signature covers.
+ public string ArtifactChecksum { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public long RowVersion { get; set; }
+
+ public object IdValue { get => RmsSignatureId; set => RmsSignatureId = (string)value; }
+ public string TableName => "RmsSignatures";
+ public string IdName => "RmsSignatureId";
+ public int IdType => 1;
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
new file mode 100644
index 00000000..ae9c2a20
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ /// Paging/filter for the incident report queue (Records Index for RecordKind = IncidentReport).
+ public sealed class RmsIncidentReportQuery
+ {
+ public IList States { get; set; }
+ public int? Year { get; set; }
+ public int? CallId { get; set; }
+ public string OwnerUserId { get; set; }
+ public int? StationGroupId { get; set; }
+ public int Skip { get; set; }
+ public int Take { get; set; } = 50;
+ }
+
+ public interface IRmsIncidentReportsRepository : IRepository
+ {
+ Task GetByIdForDepartmentAsync(int departmentId, string reportId);
+ /// The department's own report for a Call (SingleAuthoritative, plan 5.2.1); null when none exists.
+ Task GetByCallAsync(int departmentId, int callId, string reportingEntityId);
+ Task> GetByCallAnyEntityAsync(int departmentId, int callId);
+ Task GetByIdempotencyKeyAsync(int departmentId, string idempotencyKey);
+ Task GetByNerisIncidentIdAsync(int departmentId, string nerisIncidentId);
+ Task> QueryAsync(int departmentId, RmsIncidentReportQuery query);
+ Task CountAsync(int departmentId, RmsIncidentReportQuery query);
+ Task> GetYearsAsync(int departmentId);
+ Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix);
+ Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default);
+ }
+
+ /// Shared contract of every per-report child row set: a working draft (RevisionId null) and immutable revision copies.
+ public interface IRmsIncidentChildRepository : IRepository where T : class, IEntity
+ {
+ Task> GetForRecordAsync(int departmentId, string recordId, string revisionId);
+ Task DeleteDraftForRecordAsync(int departmentId, string recordId, CancellationToken cancellationToken = default);
+ }
+
+ public interface IRmsSourceFactsRepository : IRmsIncidentChildRepository { }
+ public interface IRmsUnitResponsesRepository : IRmsIncidentChildRepository { }
+ public interface IRmsIncidentTypesRepository : IRmsIncidentChildRepository { }
+ public interface IRmsActionTacticsRepository : IRmsIncidentChildRepository { }
+ public interface IRmsAidsRepository : IRmsIncidentChildRepository { }
+ public interface IRmsLocationsRepository : IRmsIncidentChildRepository { }
+ public interface IRmsNarrativesRepository : IRmsIncidentChildRepository { }
+
+ public interface IRmsValidationIssuesRepository : IRepository
+ {
+ Task> GetForRecordAsync(int departmentId, string recordId);
+ /// Replaces every open issue of one source for the record (a validation run is a whole answer, not a delta).
+ Task ReplaceForRecordAsync(int departmentId, string recordId, RmsValidationSource source, IEnumerable issues, CancellationToken cancellationToken = default);
+ }
+
+ public interface IRmsSubmissionsRepository : IRepository
+ {
+ Task GetByIdForDepartmentAsync(int departmentId, string submissionId);
+ Task> GetForRecordAsync(int departmentId, string recordId);
+ Task GetByIdempotencyKeyAsync(string idempotencyKey);
+ /// Leases due Queued/AwaitingDestination submissions across departments for one worker sweep (plan 5.3: no transaction across the destination call).
+ Task> ClaimDueBatchAsync(string leaseOwner, TimeSpan leaseDuration, int batchSize, DateTime utcNow, CancellationToken cancellationToken = default);
+ Task CountByStateAsync(int state);
+ /// Marks every non-terminal submission of the record Superseded (a new revision issued a new idempotency key).
+ Task SupersedeOpenForRecordAsync(int departmentId, string recordId, string exceptSubmissionId, DateTime utcNow, CancellationToken cancellationToken = default);
+ }
+
+ public interface IRmsSignaturesRepository : IRepository
+ {
+ Task> GetForRecordAsync(int departmentId, string recordId);
+ Task GetForRevisionAsync(int departmentId, string revisionId, RmsSignatureIntent intent);
+ }
+
+ public interface IRmsNerisProfilesRepository : IRepository
+ {
+ Task GetByDepartmentIdAsync(int departmentId);
+ }
+
+ public interface IRmsNerisValueSetsRepository : IRepository
+ {
+ Task> GetSetAsync(string contractVersion, string setKey);
+ Task CountForVersionAsync(string contractVersion);
+ Task ExistsAsync(string contractVersion, string setKey, string code);
+ }
+
+ public interface IRmsNerisCrosswalksRepository : IRepository
+ {
+ Task> GetForDepartmentAsync(int departmentId, string contractVersion);
+ Task GetAsync(int departmentId, string contractVersion, string setKey, string localSource, string localCode);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IIncidentReportsService.cs b/Core/Resgrid.Model/Services/IIncidentReportsService.cs
new file mode 100644
index 00000000..7348ccd7
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IIncidentReportsService.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Repositories;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// NERIS incident report lifecycle (RMS plan sections 4.2, 5.2.1, 5.3; RMS-2): start from a Call with source-
+ /// aware prefill, draft saves, local/destination validation, review, finalization with attestation and an
+ /// immutable revision, submission queueing, correction after rejection, amendment, void and cancel. One
+ /// report per responding entity per Call; a second start returns the existing report.
+ ///
+ public interface IIncidentReportsService
+ {
+ Task StartFromCallAsync(int departmentId, string userId, int callId, RmsOriginClient origin = RmsOriginClient.Web, CancellationToken cancellationToken = default);
+
+ Task GetAsync(int departmentId, string reportId, bool includeHistory = false);
+
+ Task GetForCallAsync(int departmentId, int callId);
+
+ Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, CancellationToken cancellationToken = default);
+
+ /// Runs local validation (and the destination's validate endpoint when asked and configured) and stores the issues on the report.
+ Task> ValidateAsync(int departmentId, string reportId, bool includeDestination, CancellationToken cancellationToken = default);
+
+ Task SubmitForReviewAsync(int departmentId, string userId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default);
+
+ Task ReturnForCorrectionAsync(int departmentId, string userId, string reportId, string reasonCode, string reasonText, CancellationToken cancellationToken = default);
+
+ /// Validates, writes the revision and attestation signature, queues the submission when the profile allows, and emits the lifecycle events.
+ Task FinalizeAsync(int departmentId, string userId, string reportId, long expectedRowVersion, string attestationStatementVersion, string ipAddress, string reasonCode, string reasonText, CancellationToken cancellationToken = default);
+
+ /// After a rejection: a new revision (N+1 referencing N) and a new idempotency key, then back into the submission queue.
+ Task CorrectAndResubmitAsync(int departmentId, string userId, string reportId, long expectedRowVersion, string attestationStatementVersion, string ipAddress, string reasonCode, string reasonText, CancellationToken cancellationToken = default);
+
+ Task OpenAmendmentAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default);
+
+ Task AbandonAmendmentAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default);
+
+ /// Queues (or re-queues) the current revision for the destination; used when auto-submit is off or after a failed delivery.
+ Task QueueSubmissionAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default);
+
+ Task VoidAsync(int departmentId, string userId, string reportId, string reasonCode, string reasonText, CancellationToken cancellationToken = default);
+
+ Task CancelAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default);
+
+ Task> QueryAsync(int departmentId, RmsIncidentReportQuery query);
+
+ Task CountAsync(int departmentId, RmsIncidentReportQuery query);
+
+ Task> GetYearsAsync(int departmentId);
+
+ /// The snapshot the mapper consumes, from the draft rows or from a revision's copies.
+ Task BuildSnapshotAsync(int departmentId, string reportId, string revisionId = null);
+
+ Task RecordAccessAsync(int departmentId, string userId, string reportId, string revisionId, RmsAccessAuditAction action, string purpose = null, string ipAddress = null);
+ }
+
+ /// Result of one worker sweep over the submission queue.
+ public class RecordsSubmissionSweepResult
+ {
+ public int Claimed { get; set; }
+ public int Delivered { get; set; }
+ public int Accepted { get; set; }
+ public int Rejected { get; set; }
+ public int Deferred { get; set; }
+ public int Failed { get; set; }
+ public int Errors { get; set; }
+ public string Message { get; set; }
+ }
+
+ ///
+ /// Worker 41 (RmsSubmissionCommand): claims due submissions, talks to the destination outside any database
+ /// transaction, persists the immutable response artifact, moves the report through Submitted / Accepted /
+ /// Rejected, retries transient failures with backoff, and emits triggers 108–111 plus notification 33.
+ ///
+ public interface IRecordsSubmissionService
+ {
+ Task SweepAsync(CancellationToken cancellationToken = default);
+
+ /// Processes one claimed submission; public so a single delivery can be driven and tested directly.
+ Task ProcessAsync(RmsSubmission submission, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IRecordsCutoverService.cs b/Core/Resgrid.Model/Services/IRecordsCutoverService.cs
index e42c499b..2fad0846 100644
--- a/Core/Resgrid.Model/Services/IRecordsCutoverService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsCutoverService.cs
@@ -28,13 +28,13 @@ public interface IRecordsCutoverService
/// (registry section 4.6), ViewGroupRecords row when the administrator chose LockToGroup, history
/// event and access audit. Refuses when the flag is off, preflight blocks, or already activated.
///
- Task ActivateAsync(int departmentId, string userId, string reason, bool viewGroupRecordsLockToGroup, CancellationToken cancellationToken = default);
+ Task ActivateAsync(int departmentId, string userId, string reason, bool viewGroupRecordsLockToGroup, string ipAddress = null, CancellationToken cancellationToken = default);
/// Which rollback outcome the decision frame permits right now.
Task GetRollbackOutcomeAsync(int departmentId);
/// Clean revert only (zero Records rows since activation); anything else returns a failure naming the outcome.
- Task RevertAsync(int departmentId, string userId, string reason, CancellationToken cancellationToken = default);
+ Task RevertAsync(int departmentId, string userId, string reason, string ipAddress = null, CancellationToken cancellationToken = default);
Task InvalidateCacheAsync(int departmentId);
}
diff --git a/Core/Resgrid.Model/Services/IRecordsNotificationService.cs b/Core/Resgrid.Model/Services/IRecordsNotificationService.cs
index 2b235b10..4a775380 100644
--- a/Core/Resgrid.Model/Services/IRecordsNotificationService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsNotificationService.cs
@@ -13,5 +13,8 @@ public interface IRecordsNotificationService
{
/// Notifies the author that their record was returned; false when nothing was sent (record missing, not Returned, no author).
Task NotifyReturnedForCorrectionAsync(int departmentId, string recordId, CancellationToken cancellationToken = default);
+
+ /// EventTypes 33: the destination rejected the author's incident report; codes and field paths only, plus a link.
+ Task NotifySubmissionRejectedAsync(int departmentId, string reportId, CancellationToken cancellationToken = default);
}
}
diff --git a/Core/Resgrid.Model/Services/IRecordsService.cs b/Core/Resgrid.Model/Services/IRecordsService.cs
index de9704d2..0c290551 100644
--- a/Core/Resgrid.Model/Services/IRecordsService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsService.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -59,6 +60,9 @@ public interface IRecordsService
Task> GetYearsAsync(int departmentId);
+ /// Delta cursor for clients (plan section 5.3): projections modified after , oldest first, including tombstones (cancelled, voided, deleted).
+ Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take);
+
Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default);
/// Loads bytes; the caller authorizes per Record before serving.
diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
index 654c3614..2b9c507c 100644
--- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
+++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
@@ -141,6 +141,24 @@ private static List GetCommon() =>
new TemplateVariableDescriptor("record_change.reason_code", "Normalized reason code", "string", false),
};
+ // submission.* (RMS-2 triggers 108-111): the sanitized delivery outcome — state, external id/status, attempts,
+ // codes and field paths. Never the destination payload or response body (plan section 5.6).
+ private static readonly List SubmissionVariables = new List
+ {
+ new TemplateVariableDescriptor("submission.id", "Submission ID", "string", false),
+ new TemplateVariableDescriptor("submission.destination", "Reporting destination, e.g. NERIS", "string", false),
+ new TemplateVariableDescriptor("submission.destination_version", "Pinned destination contract version", "string", false),
+ new TemplateVariableDescriptor("submission.state", "Submission state (Queued, AwaitingDestination, Accepted, Rejected, Failed)", "string", false),
+ new TemplateVariableDescriptor("submission.external_id", "Destination incident ID once assigned", "string", false),
+ new TemplateVariableDescriptor("submission.external_status", "Destination status value", "string", false),
+ new TemplateVariableDescriptor("submission.attempts", "Delivery attempts so far", "int", false),
+ new TemplateVariableDescriptor("submission.max_attempts", "Attempts allowed before the submission fails", "int", false),
+ new TemplateVariableDescriptor("submission.error_summary", "Normalized rejection/failure summary (codes and field paths)", "string", false),
+ new TemplateVariableDescriptor("submission.queued_on", "When the revision was queued (UTC)", "datetime", false),
+ new TemplateVariableDescriptor("submission.sent_on", "When the last attempt was sent (UTC)", "datetime", false),
+ new TemplateVariableDescriptor("submission.completed_on", "When the submission reached a final state (UTC)", "datetime", false),
+ };
+
// review.* rides only on the two review-path triggers: review bookkeeping, never record content.
private static readonly List ReviewVariables = new List
{
@@ -638,6 +656,19 @@ public static IReadOnlyList GetVariableCatalog(Workf
if (eventType == WorkflowTriggerEventType.RecordSubmittedForReview || eventType == WorkflowTriggerEventType.RecordReturnedForCorrection)
list.AddRange(ReviewVariables);
break;
+
+ case WorkflowTriggerEventType.RecordSubmissionQueued:
+ case WorkflowTriggerEventType.RecordSubmissionAccepted:
+ case WorkflowTriggerEventType.RecordSubmissionRejected:
+ case WorkflowTriggerEventType.RecordSubmissionFailed:
+ list.AddRange(RecordEventVariables);
+ list.AddRange(RecordVariables);
+ list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational or IncidentReport)", "string", false));
+ list.Add(new TemplateVariableDescriptor("record.incident_number", "Department incident number sent to the destination", "string", false));
+ list.Add(new TemplateVariableDescriptor("record.neris_incident_id", "NERIS incident ID once assigned", "string", false));
+ list.AddRange(RecordChangeVariables);
+ list.AddRange(SubmissionVariables);
+ break;
}
return list.AsReadOnly();
diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
index 08537827..27eb4d66 100644
--- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs
+++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
@@ -84,7 +84,22 @@ public enum WorkflowTriggerEventType
RecordVoided = 106,
/// A non-finalized Record was abandoned; carries the reserved-number disposition.
- RecordCancelled = 107
+ RecordCancelled = 107,
+
+ // RMS-2 reporting-destination triggers (plan section 5.6). Emitted by the submission worker (41), never
+ // from the Records transaction; payloads carry sanitized submission.* data and never the destination payload.
+
+ /// A finalized revision was queued for NERIS or another configured reporting destination.
+ RecordSubmissionQueued = 108,
+
+ /// The destination accepted the immutable revision.
+ RecordSubmissionAccepted = 109,
+
+ /// The destination rejected it with normalized, non-sensitive error codes.
+ RecordSubmissionRejected = 110,
+
+ /// Delivery exhausted its retries or requires operator attention.
+ RecordSubmissionFailed = 111
}
public static class WorkflowTriggerEventTypes
diff --git a/Core/Resgrid.Search/LuceneRecordsIndexHost.cs b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs
index 4ac04a45..35c21951 100644
--- a/Core/Resgrid.Search/LuceneRecordsIndexHost.cs
+++ b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs
@@ -150,7 +150,7 @@ public void Dispose()
{
try { _directory.Dispose(); } catch (Exception ex) { Logging.LogException(ex); }
}
- Analyzer.Dispose();
+ try { Analyzer.Dispose(); } catch (Exception ex) { Logging.LogException(ex); }
}
}
}
diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs
new file mode 100644
index 00000000..47bc7cd7
--- /dev/null
+++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs
@@ -0,0 +1,1475 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services.Records
+{
+ ///
+ /// NERIS incident report lifecycle (RMS plan sections 4.2, 5.2.1, 5.3, 5.5; RMS-2). Mirrors RecordsService for
+ /// the second aggregate: the same state machine (RmsLifecycle), the same revision/audit/projection/group-scope
+ /// tables, the same outbox. What is specific here: source-aware prefill from the Call with an RmsSourceFact
+ /// per prefilled value, local validation against the pinned NERIS contract before finalize, an attestation
+ /// signature bound to the revision checksum, and a submission row per revision with its own idempotency key.
+ ///
+ public class IncidentReportsService : IIncidentReportsService
+ {
+ public const string AttestationStatementVersion = "1";
+ public const string IncidentAggregate = "RmsIncidentReport";
+ public const string DispatchCommentFactPrefix = "dispatch.comment.";
+ public const string NumberPrefix = "INC";
+
+ private readonly IRmsIncidentReportsRepository _reports;
+ private readonly IRmsSourceFactsRepository _facts;
+ private readonly IRmsUnitResponsesRepository _units;
+ private readonly IRmsIncidentTypesRepository _types;
+ private readonly IRmsActionTacticsRepository _tactics;
+ private readonly IRmsAidsRepository _aids;
+ private readonly IRmsLocationsRepository _locations;
+ private readonly IRmsNarrativesRepository _narratives;
+ private readonly IRmsValidationIssuesRepository _issues;
+ private readonly IRmsSubmissionsRepository _submissions;
+ private readonly IRmsSignaturesRepository _signatures;
+ private readonly IRmsRevisionsRepository _revisions;
+ private readonly IRmsAccessAuditsRepository _audits;
+ private readonly IRmsRecordGroupScopesRepository _scopes;
+ private readonly IRmsRecordSharesRepository _shares;
+ private readonly IRmsRecordSearchProjectionsRepository _projections;
+ private readonly IDomainEventOutboxService _outbox;
+ private readonly IDepartmentSettingsService _settings;
+ private readonly IDepartmentGroupsService _groups;
+ private readonly IUserProfileService _profiles;
+ private readonly IPersonnelRolesService _roles;
+ private readonly IUnitsService _unitsService;
+ private readonly ICallsService _calls;
+ private readonly IDepartmentDataProtectionService _dataProtection;
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly INerisProfileService _neris;
+ private readonly INerisMappingService _mapping;
+ private readonly INerisValidationService _validation;
+
+ public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceFactsRepository facts, IRmsUnitResponsesRepository units,
+ IRmsIncidentTypesRepository types, IRmsActionTacticsRepository tactics, IRmsAidsRepository aids, IRmsLocationsRepository locations,
+ IRmsNarrativesRepository narratives, IRmsValidationIssuesRepository issues, IRmsSubmissionsRepository submissions, IRmsSignaturesRepository signatures,
+ IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, IRmsRecordGroupScopesRepository scopes, IRmsRecordSharesRepository shares,
+ IRmsRecordSearchProjectionsRepository projections, IDomainEventOutboxService outbox, IDepartmentSettingsService settings,
+ IDepartmentGroupsService groups, IUserProfileService profiles, IPersonnelRolesService roles, IUnitsService unitsService, ICallsService calls,
+ IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, INerisProfileService neris, INerisMappingService mapping,
+ INerisValidationService validation)
+ {
+ _reports = reports;
+ _facts = facts;
+ _units = units;
+ _types = types;
+ _tactics = tactics;
+ _aids = aids;
+ _locations = locations;
+ _narratives = narratives;
+ _issues = issues;
+ _submissions = submissions;
+ _signatures = signatures;
+ _revisions = revisions;
+ _audits = audits;
+ _scopes = scopes;
+ _shares = shares;
+ _projections = projections;
+ _outbox = outbox;
+ _settings = settings;
+ _groups = groups;
+ _profiles = profiles;
+ _roles = roles;
+ _unitsService = unitsService;
+ _calls = calls;
+ _dataProtection = dataProtection;
+ _unitOfWork = unitOfWork;
+ _neris = neris;
+ _mapping = mapping;
+ _validation = validation;
+ }
+
+ #region Start / read
+
+ public async Task StartFromCallAsync(int departmentId, string userId, int callId, RmsOriginClient origin = RmsOriginClient.Web, CancellationToken cancellationToken = default)
+ {
+ if (callId <= 0) throw new ArgumentException("A call is required.", nameof(callId));
+ if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("A user is required.", nameof(userId));
+
+ var profile = await _neris.GetProfileAsync(departmentId);
+ var entity = ReportingEntityFor(departmentId, profile);
+
+ // SingleAuthoritative (plan 5.2.1): a second start returns the existing report, never a duplicate.
+ var existing = await _reports.GetByCallAsync(departmentId, callId, entity);
+ if (existing != null && !existing.DeletedOn.HasValue)
+ return await GetAsync(departmentId, existing.RmsIncidentReportId, false);
+
+ var call = await _calls.GetCallByIdAsync(callId);
+ if (call == null || call.DepartmentId != departmentId)
+ throw new ArgumentException($"Call {callId} does not belong to this department.");
+ call = await _calls.PopulateCallData(call, true, false, true, false, true, false, false, false, false) ?? call;
+
+ var now = DateTime.UtcNow;
+ var reportId = Guid.NewGuid().ToString();
+ var authorGroup = await _groups.GetGroupForUserAsync(userId, departmentId);
+ var facts = new List();
+
+ var report = new RmsIncidentReport
+ {
+ RmsIncidentReportId = reportId,
+ DepartmentId = departmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ CallId = callId,
+ ReportingEntityId = entity,
+ DefinitionKey = RmsDefinitionKeys.NerisIncidentReport,
+ DefinitionVersion = RmsDefinitionKeys.LockedDefinitionVersion,
+ ProfileVersion = _neris.ContractVersion,
+ LifecyclePreset = (int)RmsDefinitionKeys.LockedDefaultPreset,
+ State = (int)RmsRecordState.Draft,
+ DraftReference = NewDraftReference(),
+ IncidentNumber = string.IsNullOrWhiteSpace(call.Number) ? null : call.Number,
+ DispatchIncidentCode = string.IsNullOrWhiteSpace(call.Type) ? null : call.Type,
+ CallCreatedOn = call.LoggedOn,
+ CallAnsweredOn = call.LoggedOn,
+ IncidentClearedOn = call.ClosedOn,
+ StationGroupId = authorGroup?.DepartmentGroupId,
+ AuthorUserId = userId,
+ OwnerUserId = userId,
+ OriginClient = (int)origin,
+ CreatedOn = now,
+ CreatedByUserId = userId,
+ ModifiedOn = now,
+ ModifiedByUserId = userId,
+ RowVersion = 1
+ };
+ facts.Add(Fact(report, NerisFactKeys.IncidentNumber, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), call.Number, call.LoggedOn, now));
+ facts.Add(Fact(report, NerisFactKeys.IncidentCode, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), call.Type, call.LoggedOn, now));
+ facts.Add(Fact(report, NerisFactKeys.CallCreate, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), Iso(call.LoggedOn), call.LoggedOn, now));
+ // Resgrid holds no PSAP answered time; the create time stands in and is marked Derived so the author sees it.
+ facts.Add(Fact(report, NerisFactKeys.CallAnswered, RmsSourceKind.Derived, "Calls", "Call", callId.ToString(), Iso(call.LoggedOn), call.LoggedOn, now));
+ if (call.ClosedOn.HasValue)
+ facts.Add(Fact(report, NerisFactKeys.IncidentClear, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), Iso(call.ClosedOn), call.ClosedOn, now));
+
+ var location = BuildLocationFromCall(report, call, now);
+ if (location != null)
+ {
+ facts.Add(Fact(report, NerisFactKeys.Location, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), location.AddressText, call.LoggedOn, now));
+ if (location.Latitude.HasValue)
+ facts.Add(Fact(report, NerisFactKeys.Point, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), $"{location.Latitude},{location.Longitude}", call.LoggedOn, now));
+ }
+
+ var units = await BuildUnitsFromCallAsync(report, call, facts, now);
+ report.CallArrivalOn = units.Where(u => u.OnSceneOn.HasValue).Select(u => u.OnSceneOn).OrderBy(t => t).FirstOrDefault();
+ if (report.CallArrivalOn.HasValue)
+ facts.Add(Fact(report, NerisFactKeys.CallArrival, RmsSourceKind.App, "UnitStates", "Call", callId.ToString(), Iso(report.CallArrivalOn), report.CallArrivalOn, now));
+
+ var types = new List();
+ var mappedType = await _neris.ResolveCrosswalkAsync(departmentId, "incident_type", NerisCrosswalkSources.CallType, call.Type);
+ if (!string.IsNullOrWhiteSpace(mappedType))
+ {
+ types.Add(new RmsIncidentType
+ {
+ RmsIncidentTypeId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = reportId,
+ TypeCode = mappedType, IsPrimary = true, LocalCode = call.Type, ValueSetVersion = _neris.ContractVersion, Ordinal = 0, CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ });
+ facts.Add(Fact(report, NerisFactKeys.IncidentType, RmsSourceKind.Derived, "Crosswalk", "CallType", call.Type, mappedType, call.LoggedOn, now));
+ }
+
+ var ordinal = 0;
+ foreach (var note in (call.CallNotes ?? new List()).Where(n => !string.IsNullOrWhiteSpace(n.Note)).OrderBy(n => n.Timestamp))
+ facts.Add(Fact(report, DispatchCommentFactPrefix + ordinal++, RmsSourceKind.Dispatch, "Calls", "CallNote", note.CallNoteId.ToString(), note.Note, note.Timestamp, now));
+
+ var narrative = new RmsNarrative
+ {
+ RmsNarrativeId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = reportId,
+ CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ };
+
+ report.DisplaySummary = BuildSummary(report, types, call.Name);
+
+ var outboxIds = new List();
+ await InTransactionAsync(async () =>
+ {
+ await _reports.InsertAsync(report, cancellationToken, true);
+ if (location != null)
+ await _locations.InsertAsync(location, cancellationToken, true);
+ foreach (var unit in units)
+ await _units.InsertAsync(unit, cancellationToken, true);
+ foreach (var type in types)
+ await _types.InsertAsync(type, cancellationToken, true);
+ await _narratives.InsertAsync(narrative, cancellationToken, true);
+ foreach (var fact in facts)
+ await _facts.InsertAsync(fact, cancellationToken, true);
+
+ var aggregate = new IncidentReportAggregate { Report = report, Location = location, Units = units, Types = types, Narrative = narrative, Facts = facts };
+ await RecomputeGroupScopeAsync(aggregate, authorGroup?.DepartmentGroupId, cancellationToken);
+ await UpsertProjectionAsync(aggregate, cancellationToken);
+ outboxIds.Add((await EnqueueLifecycleEventAsync(report, null, WorkflowTriggerEventType.RecordCreated, RmsRecordState.Draft, RmsRecordState.Draft, null, null, cancellationToken)).DomainEventOutboxId);
+ await AuditAsync(departmentId, userId, reportId, null, RmsAccessAuditAction.Change, "Start incident report from call", origin, cancellationToken, new { callId, prefilledFacts = facts.Count });
+ });
+ await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken);
+
+ return await GetAsync(departmentId, reportId, false);
+ }
+
+ public async Task GetAsync(int departmentId, string reportId, bool includeHistory = false)
+ {
+ var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId);
+ if (report == null || report.DeletedOn.HasValue)
+ return null;
+
+ return await HydrateAsync(report, null, includeHistory);
+ }
+
+ public async Task GetForCallAsync(int departmentId, int callId)
+ {
+ var profile = await _neris.GetProfileAsync(departmentId);
+ var report = await _reports.GetByCallAsync(departmentId, callId, ReportingEntityFor(departmentId, profile))
+ ?? (await _reports.GetByCallAnyEntityAsync(departmentId, callId))?.FirstOrDefault(r => !r.DeletedOn.HasValue);
+ return report == null ? null : await HydrateAsync(report, null, false);
+ }
+
+ public async Task BuildSnapshotAsync(int departmentId, string reportId, string revisionId = null)
+ {
+ var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId);
+ if (report == null)
+ return null;
+
+ var aggregate = await HydrateAsync(report, revisionId, false);
+ return ToSnapshot(aggregate);
+ }
+
+ public static NerisIncidentSnapshot ToSnapshot(IncidentReportAggregate aggregate)
+ {
+ return new NerisIncidentSnapshot
+ {
+ Report = aggregate.Report,
+ Location = aggregate.Location,
+ Types = aggregate.Types,
+ Units = aggregate.Units,
+ Aids = aggregate.Aids,
+ Tactics = aggregate.Tactics,
+ Narrative = aggregate.Narrative,
+ Facts = aggregate.Facts,
+ DispatchComments = aggregate.Facts.Where(f => f.FactKey != null && f.FactKey.StartsWith(DispatchCommentFactPrefix, StringComparison.Ordinal))
+ .OrderBy(f => f.SourceTime).Select(f => new NerisDispatchComment { Timestamp = f.SourceTime, Comment = f.CurrentValue ?? f.SourceValue }).ToList(),
+ SpecialModifiers = SplitCsv(aggregate.Report.SpecialModifiersCsv)
+ };
+ }
+
+ #endregion
+
+ #region Draft / validate
+
+ public async Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, CancellationToken cancellationToken = default)
+ {
+ if (input == null) throw new ArgumentNullException(nameof(input));
+ var report = await LoadAsync(departmentId, reportId);
+ RequireEditable(report);
+
+ var now = DateTime.UtcNow;
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, expectedRowVersion, cancellationToken);
+ var facts = (await _facts.GetForRecordAsync(departmentId, reportId, null))?.ToList() ?? new List();
+
+ ApplyHeader(report, input, facts, userId, now);
+ var location = await ReplaceLocationAsync(report, input.Location, facts, userId, now, cancellationToken);
+ var types = await ReplaceTypesAsync(report, input.Types, now, cancellationToken);
+ var units = await ReplaceUnitsAsync(report, input.Units, facts, userId, now, cancellationToken);
+ var aids = await ReplaceAidsAsync(report, input.Aids, now, cancellationToken);
+ var tactics = await ReplaceTacticsAsync(report, input.Tactics, now, cancellationToken);
+ var narrative = await ReplaceNarrativeAsync(report, input, now, cancellationToken);
+ foreach (var fact in facts.Where(f => f.CorrectedOn == now))
+ await _facts.UpdateAsync(fact, cancellationToken, true);
+
+ report.DisplaySummary = BuildSummary(report, types, null);
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+
+ var aggregate = new IncidentReportAggregate { Report = report, Location = location, Types = types, Units = units, Aids = aids, Tactics = tactics, Narrative = narrative, Facts = facts };
+ await RecomputeGroupScopeAsync(aggregate, null, cancellationToken);
+ await UpsertProjectionAsync(aggregate, cancellationToken);
+ await AuditAsync(departmentId, userId, reportId, null, RmsAccessAuditAction.Change, "Save draft", input.OriginClient, cancellationToken);
+ });
+
+ return await GetAsync(departmentId, reportId, false);
+ }
+
+ public async Task> ValidateAsync(int departmentId, string reportId, bool includeDestination, CancellationToken cancellationToken = default)
+ {
+ var report = await LoadAsync(departmentId, reportId);
+ var aggregate = await HydrateAsync(report, null, false);
+ var profile = await _neris.GetProfileAsync(departmentId);
+ var snapshot = ToSnapshot(aggregate);
+
+ var local = _validation.ValidateLocal(snapshot, profile);
+ await _issues.ReplaceForRecordAsync(departmentId, reportId, RmsValidationSource.Local, local, cancellationToken);
+
+ if (includeDestination && await _neris.IsSubmissionEnabledAsync(departmentId) && !local.Any(i => i.Severity == (int)RmsValidationSeverity.Error))
+ {
+ var remote = await _validation.ValidateRemoteAsync(profile, _mapping.BuildIncidentPayloadJson(snapshot, profile), cancellationToken);
+ foreach (var issue in remote)
+ {
+ issue.DepartmentId = departmentId;
+ issue.RecordId = reportId;
+ }
+ await _issues.ReplaceForRecordAsync(departmentId, reportId, RmsValidationSource.Destination, remote, cancellationToken);
+ }
+ else
+ {
+ await _issues.ReplaceForRecordAsync(departmentId, reportId, RmsValidationSource.Destination, Enumerable.Empty(), cancellationToken);
+ }
+
+ await AuditAsync(departmentId, null, reportId, null, RmsAccessAuditAction.Change, includeDestination ? "Validate (local + destination)" : "Validate (local)", RmsOriginClient.Web, cancellationToken, new { errors = local.Count(i => i.Severity == (int)RmsValidationSeverity.Error) });
+ return (await _issues.GetForRecordAsync(departmentId, reportId))?.ToList() ?? new List();
+ }
+
+ #endregion
+
+ #region Review
+
+ public async Task SubmitForReviewAsync(int departmentId, string userId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default)
+ {
+ var report = await LoadAsync(departmentId, reportId);
+ var from = (RmsRecordState)report.State;
+ RequireTransition(report, from, RmsRecordState.ReadyForReview);
+
+ var now = DateTime.UtcNow;
+ var outboxIds = new List();
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, expectedRowVersion, cancellationToken);
+ report.State = (int)RmsRecordState.ReadyForReview;
+ report.SubmittedForReviewOn = now;
+ report.ReviewDueOn = now.AddHours(await _settings.GetRecordsReviewDueHoursAsync(departmentId));
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await RefreshProjectionAsync(report, cancellationToken);
+ outboxIds.Add((await EnqueueLifecycleEventAsync(report, null, WorkflowTriggerEventType.RecordSubmittedForReview, from, RmsRecordState.ReadyForReview, null, null, cancellationToken)).DomainEventOutboxId);
+ await AuditAsync(departmentId, userId, reportId, null, RmsAccessAuditAction.Change, "Submit for review", RmsOriginClient.Web, cancellationToken);
+ });
+ await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken);
+ return await GetAsync(departmentId, reportId, false);
+ }
+
+ public async Task ReturnForCorrectionAsync(int departmentId, string userId, string reportId, string reasonCode, string reasonText, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(reasonCode)) throw new ArgumentException("A reason code is required to return a report.", nameof(reasonCode));
+ var report = await LoadAsync(departmentId, reportId);
+ var from = (RmsRecordState)report.State;
+ RequireTransition(report, from, RmsRecordState.Returned);
+
+ var now = DateTime.UtcNow;
+ var outboxIds = new List();
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, report.RowVersion, cancellationToken);
+ report.State = (int)RmsRecordState.Returned;
+ report.ReturnedOn = now;
+ report.ReturnReasonCode = reasonCode;
+ report.ReturnReasonText = reasonText;
+ report.ReturnCount += 1;
+ report.ReviewerUserId = userId;
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await RefreshProjectionAsync(report, cancellationToken);
+ outboxIds.Add((await EnqueueLifecycleEventAsync(report, null, WorkflowTriggerEventType.RecordReturnedForCorrection, from, RmsRecordState.Returned, reasonCode, null, cancellationToken)).DomainEventOutboxId);
+ await AuditAsync(departmentId, userId, reportId, null, RmsAccessAuditAction.Change, "Return for correction", RmsOriginClient.Web, cancellationToken, new { reasonCode });
+ });
+ await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken);
+ return await GetAsync(departmentId, reportId, false);
+ }
+
+ #endregion
+
+ #region Finalize / correct / amend
+
+ public Task FinalizeAsync(int departmentId, string userId, string reportId, long expectedRowVersion, string attestationStatementVersion, string ipAddress, string reasonCode, string reasonText, CancellationToken cancellationToken = default)
+ {
+ return SignRevisionAsync(departmentId, userId, reportId, expectedRowVersion, attestationStatementVersion, ipAddress, reasonCode, reasonText, false, cancellationToken);
+ }
+
+ public Task CorrectAndResubmitAsync(int departmentId, string userId, string reportId, long expectedRowVersion, string attestationStatementVersion, string ipAddress, string reasonCode, string reasonText, CancellationToken cancellationToken = default)
+ {
+ return SignRevisionAsync(departmentId, userId, reportId, expectedRowVersion, attestationStatementVersion, ipAddress, reasonCode, reasonText, true, cancellationToken);
+ }
+
+ private async Task SignRevisionAsync(int departmentId, string userId, string reportId, long expectedRowVersion, string attestationStatementVersion, string ipAddress, string reasonCode, string reasonText, bool correction, CancellationToken cancellationToken)
+ {
+ var report = await LoadAsync(departmentId, reportId);
+ var from = (RmsRecordState)report.State;
+ var isAmendment = report.AmendsRevisionId != null;
+ RmsRecordState to;
+ if (correction)
+ {
+ if (from != RmsRecordState.Rejected)
+ throw new RecordTransitionException(reportId, from, RmsRecordState.Corrected, "only a rejected report can be corrected and resubmitted");
+ to = RmsRecordState.Corrected;
+ }
+ else
+ {
+ to = isAmendment ? RmsRecordState.Amended : RmsRecordState.Finalized;
+ RequireTransition(report, from, to);
+ }
+
+ var needsReason = isAmendment || correction;
+ if (needsReason && string.IsNullOrWhiteSpace(reasonCode))
+ throw new ArgumentException("A reason code is required to finalize an amendment or a correction.", nameof(reasonCode));
+
+ var now = DateTime.UtcNow;
+ var profile = await _neris.GetProfileAsync(departmentId);
+ var outboxIds = new List();
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, expectedRowVersion, cancellationToken);
+ var draft = await HydrateAsync(report, null, false);
+
+ // Validation blocks the signature (plan 4.2 "progressive validation"): the issues stay on the report for the author.
+ var local = _validation.ValidateLocal(ToSnapshot(draft), profile);
+ await _issues.ReplaceForRecordAsync(departmentId, reportId, RmsValidationSource.Local, local, cancellationToken);
+ var errors = local.Where(i => i.Severity == (int)RmsValidationSeverity.Error).ToList();
+ if (errors.Count > 0)
+ throw new IncidentReportValidationException(reportId, errors);
+
+ if (string.IsNullOrWhiteSpace(report.RecordNumber))
+ report.RecordNumber = await AllocateRecordNumberAsync(report, cancellationToken);
+
+ var transition = isAmendment || correction ? RmsRevisionTransition.Amended : RmsRevisionTransition.Finalized;
+ var revision = await WriteRevisionAsync(report, draft, transition, userId, reasonCode, reasonText, attestationStatementVersion ?? AttestationStatementVersion, now, cancellationToken);
+ await WriteSignatureAsync(report, revision, userId, attestationStatementVersion ?? AttestationStatementVersion, ipAddress, now, cancellationToken);
+
+ var priorState = from;
+ report.State = (int)to;
+ if (!isAmendment && !correction)
+ {
+ report.FinalizedOn = now;
+ report.FinalizedByUserId = userId;
+ }
+ report.CurrentRevisionId = revision.RmsRevisionId;
+ report.RevisionCount = revision.RevisionNumber;
+ report.AmendsRevisionId = null;
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+
+ draft.Report = report;
+ await RecomputeGroupScopeAsync(draft, null, cancellationToken);
+ await UpsertProjectionAsync(draft, cancellationToken);
+
+ var trigger = correction || isAmendment ? WorkflowTriggerEventType.RecordAmended : WorkflowTriggerEventType.RecordFinalized;
+ var lifecycle = await EnqueueLifecycleEventAsync(report, revision, trigger, priorState, to, reasonCode, null, cancellationToken);
+ outboxIds.Add(lifecycle.DomainEventOutboxId);
+ await AuditAsync(departmentId, userId, reportId, revision.RmsRevisionId, RmsAccessAuditAction.Sign, correction ? "Correct and resubmit" : isAmendment ? "Finalize amendment" : "Finalize", (RmsOriginClient)report.OriginClient, cancellationToken, new { revision.RevisionNumber, revision.Checksum, reasonCode }, ipAddress);
+
+ // Submission: every finalized/corrected revision gets its own idempotency key (plan 5.3). Queued here when the
+ // profile allows; otherwise the author queues it explicitly.
+ var autoSubmit = correction || (profile != null && profile.AutoSubmitOnFinalize);
+ if (autoSubmit && await _neris.IsSubmissionEnabledAsync(departmentId))
+ {
+ var queued = await QueueSubmissionCoreAsync(report, draft, revision, profile, userId, now, cancellationToken);
+ outboxIds.Add(queued.outboxId);
+ }
+ });
+ await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken);
+ return await GetAsync(departmentId, reportId, true);
+ }
+
+ public async Task QueueSubmissionAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default)
+ {
+ var report = await LoadAsync(departmentId, reportId);
+ var state = (RmsRecordState)report.State;
+ if (!RmsLifecycle.IsFinalizedFamily(state) || RmsLifecycle.IsTerminal(state) || string.IsNullOrWhiteSpace(report.CurrentRevisionId))
+ throw new RecordTransitionException(reportId, state, RmsRecordState.Submitted, "only a finalized revision can be submitted");
+ if (report.AmendsRevisionId != null)
+ throw new RecordTransitionException(reportId, state, RmsRecordState.Submitted, "close the open amendment before submitting");
+ if (!await _neris.IsSubmissionEnabledAsync(departmentId))
+ throw new InvalidOperationException("NERIS submission is not enabled for this department.");
+
+ var profile = await _neris.GetProfileAsync(departmentId);
+ var now = DateTime.UtcNow;
+ var outboxIds = new List();
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, report.RowVersion, cancellationToken);
+ var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, report.CurrentRevisionId);
+ var aggregate = await HydrateAsync(report, revision.RmsRevisionId, false);
+ var queued = await QueueSubmissionCoreAsync(report, aggregate, revision, profile, userId, now, cancellationToken);
+ outboxIds.Add(queued.outboxId);
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await RefreshProjectionAsync(report, cancellationToken);
+ });
+ await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken);
+ return await GetAsync(departmentId, reportId, true);
+ }
+
+ private async Task<(RmsSubmission submission, long outboxId)> QueueSubmissionCoreAsync(RmsIncidentReport report, IncidentReportAggregate aggregate, RmsRevision revision, RmsNerisProfile profile, string userId, DateTime now, CancellationToken cancellationToken)
+ {
+ var payload = _mapping.BuildIncidentPayloadJson(ToSnapshot(aggregate), profile);
+ var key = IdempotencyKey(report.DepartmentId, report.RmsIncidentReportId, revision.RmsRevisionId);
+
+ var submission = await _submissions.GetByIdempotencyKeyAsync(key);
+ if (submission != null && submission.State != (int)RmsSubmissionState.Failed && submission.State != (int)RmsSubmissionState.Superseded && submission.State != (int)RmsSubmissionState.Rejected)
+ throw new InvalidOperationException("This revision is already queued or delivered.");
+
+ if (submission == null)
+ {
+ submission = new RmsSubmission
+ {
+ RmsSubmissionId = Guid.NewGuid().ToString(),
+ DepartmentId = report.DepartmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ RecordId = report.RmsIncidentReportId,
+ RecordKind = (int)RmsRecordKind.IncidentReport,
+ RevisionId = revision.RmsRevisionId,
+ Destination = RmsSubmissionDestinations.Neris,
+ DestinationVersion = profile?.ContractVersion ?? _neris.ContractVersion,
+ IdempotencyKey = key,
+ MaxAttempts = Math.Max(1, Config.NerisConfig.MaxAttempts),
+ PayloadJson = payload,
+ PayloadChecksum = RecordSnapshotSerializer.Checksum(payload),
+ QueuedOn = now,
+ CreatedByUserId = userId,
+ CreatedOn = now,
+ ModifiedOn = now,
+ RowVersion = 1
+ };
+ await _submissions.SupersedeOpenForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, submission.RmsSubmissionId, now, cancellationToken);
+ await _submissions.InsertAsync(submission, cancellationToken, true);
+ }
+ else
+ {
+ // A failed or rejected delivery of the same revision is re-queued with the same key: retry, not a new payload.
+ submission.State = (int)RmsSubmissionState.Queued;
+ submission.Attempts = 0;
+ submission.NextAttemptOn = null;
+ submission.LeaseOwner = null;
+ submission.LeaseExpiresOn = null;
+ submission.ErrorSummary = null;
+ submission.QueuedOn = now;
+ submission.ModifiedOn = now;
+ submission.RowVersion += 1;
+ await _submissions.UpdateAsync(submission, cancellationToken, true);
+ }
+
+ var priorState = (RmsRecordState)report.State;
+ report.State = (int)RmsRecordState.Submitted;
+ report.LastSubmissionId = submission.RmsSubmissionId;
+ report.LastSubmissionState = submission.State;
+ report.LastSubmittedOn = now;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await RefreshProjectionAsync(report, cancellationToken);
+
+ var entry = await EnqueueLifecycleEventAsync(report, revision, WorkflowTriggerEventType.RecordSubmissionQueued, priorState, RmsRecordState.Submitted, null, SubmissionBlock(submission), cancellationToken);
+ await AuditAsync(report.DepartmentId, userId, report.RmsIncidentReportId, revision.RmsRevisionId, RmsAccessAuditAction.Submit, "Queue submission", RmsOriginClient.Web, cancellationToken, new { submission.RmsSubmissionId, submission.IdempotencyKey, submission.PayloadChecksum });
+ return (submission, entry.DomainEventOutboxId);
+ }
+
+ public async Task OpenAmendmentAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default)
+ {
+ var report = await LoadAsync(departmentId, reportId);
+ var state = (RmsRecordState)report.State;
+ if (!RmsLifecycle.CanTransition((RmsLifecyclePreset)report.LifecyclePreset, state, RmsRecordState.Amended))
+ throw new RecordTransitionException(reportId, state, RmsRecordState.Amended);
+ if (report.AmendsRevisionId != null)
+ throw new RecordTransitionException(reportId, state, RmsRecordState.Amended, "an amendment draft is already open");
+
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, report.RowVersion, cancellationToken);
+ report.AmendsRevisionId = report.CurrentRevisionId;
+ report.OwnerUserId = userId;
+ report.ModifiedOn = DateTime.UtcNow;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await AuditAsync(departmentId, userId, reportId, report.CurrentRevisionId, RmsAccessAuditAction.Change, "Open amendment", RmsOriginClient.Web, cancellationToken);
+ });
+ return await GetAsync(departmentId, reportId, true);
+ }
+
+ public async Task AbandonAmendmentAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default)
+ {
+ var report = await LoadAsync(departmentId, reportId);
+ if (report.AmendsRevisionId == null)
+ throw new RecordTransitionException(reportId, (RmsRecordState)report.State, (RmsRecordState)report.State, "no amendment draft is open");
+
+ var now = DateTime.UtcNow;
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, report.RowVersion, cancellationToken);
+ // The draft rows are rebuilt from the current revision's copies so the finalized content is what the author sees again.
+ var current = await HydrateAsync(report, report.CurrentRevisionId, false);
+ await ReplaceDraftRowsFromAsync(report, current, now, cancellationToken);
+ report.AmendsRevisionId = null;
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await AuditAsync(departmentId, userId, reportId, report.CurrentRevisionId, RmsAccessAuditAction.Change, "Abandon amendment", RmsOriginClient.Web, cancellationToken);
+ });
+ return await GetAsync(departmentId, reportId, true);
+ }
+
+ public async Task VoidAsync(int departmentId, string userId, string reportId, string reasonCode, string reasonText, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(reasonCode)) throw new ArgumentException("A reason code is required to void a report.", nameof(reasonCode));
+ var report = await LoadAsync(departmentId, reportId);
+ var from = (RmsRecordState)report.State;
+ RequireTransition(report, from, RmsRecordState.Voided);
+ if (report.AmendsRevisionId != null)
+ throw new RecordTransitionException(reportId, from, RmsRecordState.Voided, "abandon the open amendment first");
+
+ var now = DateTime.UtcNow;
+ var outboxIds = new List();
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, report.RowVersion, cancellationToken);
+ var current = await HydrateAsync(report, report.CurrentRevisionId, false);
+ var revision = await WriteRevisionAsync(report, current, RmsRevisionTransition.Voided, userId, reasonCode, reasonText, null, now, cancellationToken);
+ report.State = (int)RmsRecordState.Voided;
+ report.VoidedOn = now;
+ report.VoidedByUserId = userId;
+ report.VoidReasonCode = reasonCode;
+ report.VoidReasonText = reasonText;
+ report.CurrentRevisionId = revision.RmsRevisionId;
+ report.RevisionCount = revision.RevisionNumber;
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await _submissions.SupersedeOpenForRecordAsync(departmentId, reportId, null, now, cancellationToken);
+ await RefreshProjectionAsync(report, cancellationToken);
+ outboxIds.Add((await EnqueueLifecycleEventAsync(report, revision, WorkflowTriggerEventType.RecordVoided, from, RmsRecordState.Voided, reasonCode, null, cancellationToken)).DomainEventOutboxId);
+ await AuditAsync(departmentId, userId, reportId, revision.RmsRevisionId, RmsAccessAuditAction.Change, "Void", RmsOriginClient.Web, cancellationToken, new { reasonCode });
+ });
+ await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken);
+ return await GetAsync(departmentId, reportId, true);
+ }
+
+ public async Task CancelAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default)
+ {
+ var report = await LoadAsync(departmentId, reportId);
+ var from = (RmsRecordState)report.State;
+ RequireTransition(report, from, RmsRecordState.Cancelled);
+
+ var now = DateTime.UtcNow;
+ var outboxIds = new List();
+ await InTransactionAsync(async () =>
+ {
+ await GuardVersionAsync(report, report.RowVersion, cancellationToken);
+ report.State = (int)RmsRecordState.Cancelled;
+ report.CancelledOn = now;
+ report.CancelledByUserId = userId;
+ report.ModifiedOn = now;
+ report.ModifiedByUserId = userId;
+ await _reports.UpdateAsync(report, cancellationToken, true);
+ await RefreshProjectionAsync(report, cancellationToken);
+ outboxIds.Add((await EnqueueLifecycleEventAsync(report, null, WorkflowTriggerEventType.RecordCancelled, from, RmsRecordState.Cancelled, null, null, cancellationToken, new { number_disposition = string.IsNullOrWhiteSpace(report.RecordNumber) ? "none" : "voided" })).DomainEventOutboxId);
+ await AuditAsync(departmentId, userId, reportId, null, RmsAccessAuditAction.Change, "Cancel", RmsOriginClient.Web, cancellationToken);
+ });
+ await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken);
+ return await GetAsync(departmentId, reportId, false);
+ }
+
+ #endregion
+
+ #region Queries / audit
+
+ public async Task> QueryAsync(int departmentId, RmsIncidentReportQuery query)
+ {
+ return (await _reports.QueryAsync(departmentId, query ?? new RmsIncidentReportQuery()))?.ToList() ?? new List();
+ }
+
+ public Task CountAsync(int departmentId, RmsIncidentReportQuery query)
+ {
+ return _reports.CountAsync(departmentId, query ?? new RmsIncidentReportQuery());
+ }
+
+ public async Task> GetYearsAsync(int departmentId)
+ {
+ return (await _reports.GetYearsAsync(departmentId))?.ToList() ?? new List();
+ }
+
+ public Task RecordAccessAsync(int departmentId, string userId, string reportId, string revisionId, RmsAccessAuditAction action, string purpose = null, string ipAddress = null)
+ {
+ return AuditAsync(departmentId, userId, reportId, revisionId, action, purpose, RmsOriginClient.Web, CancellationToken.None, null, ipAddress);
+ }
+
+ #endregion
+
+ #region Prefill helpers
+
+ public static string ReportingEntityFor(int departmentId, RmsNerisProfile profile)
+ {
+ return string.IsNullOrWhiteSpace(profile?.NerisEntityId) ? $"department:{departmentId}" : profile.NerisEntityId;
+ }
+
+ private static RmsLocation BuildLocationFromCall(RmsIncidentReport report, Call call, DateTime now)
+ {
+ decimal? latitude = null, longitude = null;
+ if (!string.IsNullOrWhiteSpace(call.GeoLocationData) && call.GeoLocationData.Contains(","))
+ {
+ var parts = call.GeoLocationData.Split(',');
+ if (parts.Length == 2 && decimal.TryParse(parts[0].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) && decimal.TryParse(parts[1].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
+ {
+ latitude = lat;
+ longitude = lon;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(call.Address) && latitude == null)
+ return null;
+
+ return new RmsLocation
+ {
+ RmsLocationId = Guid.NewGuid().ToString(),
+ DepartmentId = report.DepartmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ RecordId = report.RmsIncidentReportId,
+ AddressText = string.IsNullOrWhiteSpace(call.Address) ? null : call.Address.Trim(),
+ Latitude = latitude,
+ Longitude = longitude,
+ SourceKind = (int)RmsSourceKind.Dispatch,
+ CreatedOn = now,
+ ModifiedOn = now,
+ RowVersion = 1
+ };
+ }
+
+ /// One unit response per dispatched unit; times come from the unit state log (App) and the dispatch row (Dispatch), each with a provenance fact.
+ private async Task> BuildUnitsFromCallAsync(RmsIncidentReport report, Call call, List facts, DateTime now)
+ {
+ var result = new List();
+ var dispatches = (call.UnitDispatches ?? new List()).GroupBy(d => d.UnitId).Select(g => g.OrderBy(d => d.DispatchedOn).First()).ToList();
+ if (dispatches.Count == 0)
+ return result;
+
+ var states = (await _unitsService.GetUnitStatesForCallAsync(report.DepartmentId, call.CallId) ?? new List()).OrderBy(s => s.Timestamp).ToList();
+ var ordinal = 0;
+ foreach (var dispatch in dispatches)
+ {
+ var unit = await _unitsService.GetUnitByIdAsync(dispatch.UnitId);
+ if (unit == null || unit.DepartmentId != report.DepartmentId)
+ 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();
+
+ var response = new RmsUnitResponse
+ {
+ RmsUnitResponseId = Guid.NewGuid().ToString(),
+ DepartmentId = report.DepartmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ RecordId = report.RmsIncidentReportId,
+ UnitId = unit.UnitId,
+ UnitNameSnapshot = unit.Name,
+ UnitTypeSnapshot = unit.Type,
+ StationGroupIdSnapshot = unit.StationGroupId,
+ DispatchedOn = dispatch.DispatchedOn,
+ EnrouteOn = At(UnitStateTypes.Responding, UnitStateTypes.Enroute),
+ OnSceneOn = onScene,
+ StagingOn = At(UnitStateTypes.Staging),
+ CanceledEnrouteOn = onScene.HasValue ? null : At(UnitStateTypes.Cancelled),
+ ClearedOn = cleared,
+ ResponseMode = "EMERGENT",
+ TimesSourceKind = (int)RmsSourceKind.App,
+ Ordinal = ordinal++,
+ CreatedOn = now,
+ ModifiedOn = now,
+ RowVersion = 1
+ };
+ 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) })
+ {
+ if (value.HasValue)
+ facts.Add(Fact(report, NerisFactKeys.UnitTime(unit.UnitId, field), RmsSourceKind.App, "UnitStates", "Unit", unit.UnitId.ToString(), Iso(value), value, now));
+ }
+ }
+
+ return result;
+ }
+
+ 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
+ {
+ RmsSourceFactId = Guid.NewGuid().ToString(),
+ DepartmentId = report.DepartmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ RecordId = report.RmsIncidentReportId,
+ FactKey = key,
+ SourceKind = (int)kind,
+ SourceSystem = system,
+ SourceEntityType = entityType,
+ SourceEntityId = entityId,
+ SourceValue = value,
+ CurrentValue = value,
+ SourceTime = sourceTime,
+ ImportedOn = now,
+ CreatedOn = now,
+ ModifiedOn = now,
+ RowVersion = 1
+ };
+ }
+
+ /// An edit to a prefilled value keeps the original and records the correction on its provenance row (plan 4.2).
+ private static void Correct(List facts, string key, string newValue, string userId, DateTime now)
+ {
+ var fact = facts.FirstOrDefault(f => string.Equals(f.FactKey, key, StringComparison.Ordinal));
+ if (fact == null || string.Equals(fact.CurrentValue, newValue, StringComparison.Ordinal))
+ return;
+
+ fact.CurrentValue = newValue;
+ fact.CorrectedOn = now;
+ fact.CorrectedByUserId = userId;
+ fact.ModifiedOn = now;
+ fact.RowVersion += 1;
+ }
+
+ public static string Iso(DateTime? value)
+ {
+ return value.HasValue ? value.Value.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture) : null;
+ }
+
+ #endregion
+
+ #region Draft replacement
+
+ private static void ApplyHeader(RmsIncidentReport report, IncidentReportDraftInput input, List facts, string userId, DateTime now)
+ {
+ Correct(facts, NerisFactKeys.IncidentNumber, input.IncidentNumber, userId, now);
+ Correct(facts, NerisFactKeys.IncidentCode, input.DispatchIncidentCode, userId, now);
+ Correct(facts, NerisFactKeys.CallCreate, Iso(input.CallCreatedOn), userId, now);
+ Correct(facts, NerisFactKeys.CallAnswered, Iso(input.CallAnsweredOn), userId, now);
+ Correct(facts, NerisFactKeys.CallArrival, Iso(input.CallArrivalOn), userId, now);
+ Correct(facts, NerisFactKeys.IncidentClear, Iso(input.IncidentClearedOn), userId, now);
+
+ report.IncidentNumber = Trim(input.IncidentNumber);
+ report.DispatchIncidentCode = Trim(input.DispatchIncidentCode);
+ report.CallCreatedOn = input.CallCreatedOn;
+ report.CallAnsweredOn = input.CallAnsweredOn;
+ report.CallArrivalOn = input.CallArrivalOn;
+ report.IncidentClearedOn = input.IncidentClearedOn;
+ report.DispatchCenterId = Trim(input.DispatchCenterId);
+ report.DeterminantCode = Trim(input.DeterminantCode);
+ report.Disposition = Trim(input.Disposition);
+ report.PeoplePresent = input.PeoplePresent;
+ report.DisplacementCount = input.DisplacementCount;
+ report.AnimalsRescued = input.AnimalsRescued;
+ report.SpecialModifiersCsv = input.SpecialModifiers == null || input.SpecialModifiers.Count == 0 ? null : string.Join(",", input.SpecialModifiers.Where(m => !string.IsNullOrWhiteSpace(m)).Select(m => m.Trim()).Distinct());
+ if (input.StationGroupId.HasValue)
+ report.StationGroupId = input.StationGroupId;
+ }
+
+ private async Task ReplaceLocationAsync(RmsIncidentReport report, IncidentLocationInput input, List facts, string userId, DateTime now, CancellationToken cancellationToken)
+ {
+ await _locations.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ if (input == null)
+ return null;
+
+ var location = new RmsLocation
+ {
+ RmsLocationId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId,
+ AddressText = Trim(input.AddressText), Number = Trim(input.Number), NumberPrefix = Trim(input.NumberPrefix), NumberSuffix = Trim(input.NumberSuffix), Street = Trim(input.Street),
+ UnitValue = Trim(input.UnitValue), Municipality = Trim(input.Municipality), County = Trim(input.County), State = Trim(input.State)?.ToUpperInvariant(), PostalCode = Trim(input.PostalCode),
+ Country = Trim(input.Country)?.ToUpperInvariant(), PlaceType = Trim(input.PlaceType), LocationUse = Trim(input.LocationUse), CrossStreet1 = Trim(input.CrossStreet1), CrossStreet2 = Trim(input.CrossStreet2),
+ Latitude = input.Latitude, Longitude = input.Longitude, Jurisdiction = Trim(input.Jurisdiction),
+ SourceKind = (int)RmsSourceKind.None, CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ };
+ Correct(facts, NerisFactKeys.Location, location.AddressText, userId, now);
+ Correct(facts, NerisFactKeys.Point, location.Latitude.HasValue ? $"{location.Latitude},{location.Longitude}" : null, userId, now);
+ await _locations.InsertAsync(location, cancellationToken, true);
+ return location;
+ }
+
+ private async Task> ReplaceTypesAsync(RmsIncidentReport report, List inputs, DateTime now, CancellationToken cancellationToken)
+ {
+ await _types.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ var result = new List();
+ var ordinal = 0;
+ foreach (var input in (inputs ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i.TypeCode)))
+ {
+ var row = new RmsIncidentType
+ {
+ RmsIncidentTypeId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId,
+ TypeCode = input.TypeCode.Trim(), IsPrimary = input.IsPrimary, LocalCode = ordinal == 0 ? report.DispatchIncidentCode : null, ValueSetVersion = _neris.ContractVersion,
+ Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ };
+ await _types.InsertAsync(row, cancellationToken, true);
+ result.Add(row);
+ }
+ if (result.Count > 0 && !result.Any(t => t.IsPrimary))
+ result[0].IsPrimary = true;
+ return result;
+ }
+
+ private async Task> ReplaceUnitsAsync(RmsIncidentReport report, List inputs, List facts, string userId, DateTime now, CancellationToken cancellationToken)
+ {
+ await _units.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ var result = new List();
+ var ordinal = 0;
+ foreach (var input in inputs ?? new List())
+ {
+ Unit unit = null;
+ if (input.UnitId.HasValue && input.UnitId > 0)
+ {
+ unit = await _unitsService.GetUnitByIdAsync(input.UnitId.Value);
+ if (unit == null || unit.DepartmentId != report.DepartmentId)
+ throw new ArgumentException($"Unit {input.UnitId} does not belong to this department.");
+ }
+ if (unit == null && string.IsNullOrWhiteSpace(input.ReportedUnitId) && string.IsNullOrWhiteSpace(input.UnitNerisId))
+ continue;
+
+ var row = new RmsUnitResponse
+ {
+ RmsUnitResponseId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId,
+ UnitId = unit?.UnitId, UnitNameSnapshot = unit?.Name ?? Trim(input.ReportedUnitId), UnitTypeSnapshot = unit?.Type, StationGroupIdSnapshot = unit?.StationGroupId,
+ UnitNerisId = Trim(input.UnitNerisId)?.ToUpperInvariant(), Staffing = input.Staffing, UnableToDispatch = input.UnableToDispatch,
+ DispatchedOn = input.DispatchedOn, EnrouteOn = input.EnrouteOn, OnSceneOn = input.OnSceneOn, CanceledEnrouteOn = input.CanceledEnrouteOn, StagingOn = input.StagingOn, ClearedOn = input.ClearedOn,
+ ResponseMode = Trim(input.ResponseMode)?.ToUpperInvariant(), TransportMode = Trim(input.TransportMode)?.ToUpperInvariant(),
+ TimesSourceKind = (int)RmsSourceKind.None, Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ };
+ if (unit != null)
+ {
+ Correct(facts, NerisFactKeys.UnitTime(unit.UnitId, "dispatch"), Iso(row.DispatchedOn), userId, now);
+ Correct(facts, NerisFactKeys.UnitTime(unit.UnitId, "enroute_to_scene"), Iso(row.EnrouteOn), userId, now);
+ Correct(facts, NerisFactKeys.UnitTime(unit.UnitId, "on_scene"), Iso(row.OnSceneOn), userId, now);
+ 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;
+ }
+ await _units.InsertAsync(row, cancellationToken, true);
+ result.Add(row);
+ }
+ return result;
+ }
+
+ private async Task> ReplaceAidsAsync(RmsIncidentReport report, List inputs, DateTime now, CancellationToken cancellationToken)
+ {
+ await _aids.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ var result = new List();
+ var ordinal = 0;
+ foreach (var input in inputs ?? new List())
+ {
+ if (!input.IsNonFireDepartment && string.IsNullOrWhiteSpace(input.AidType) && string.IsNullOrWhiteSpace(input.CounterpartNerisId))
+ continue;
+ if (input.IsNonFireDepartment && string.IsNullOrWhiteSpace(input.NonFdType))
+ continue;
+
+ var row = new RmsAid
+ {
+ RmsAidId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId,
+ Direction = Trim(input.Direction)?.ToUpperInvariant() ?? "RECEIVED", AidType = Trim(input.AidType)?.ToUpperInvariant() ?? string.Empty,
+ CounterpartNerisId = Trim(input.CounterpartNerisId)?.ToUpperInvariant(), CounterpartName = Trim(input.CounterpartName),
+ IsNonFireDepartment = input.IsNonFireDepartment, NonFdType = Trim(input.NonFdType)?.ToUpperInvariant(),
+ Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ };
+ await _aids.InsertAsync(row, cancellationToken, true);
+ result.Add(row);
+ }
+ return result;
+ }
+
+ private async Task> ReplaceTacticsAsync(RmsIncidentReport report, List inputs, DateTime now, CancellationToken cancellationToken)
+ {
+ await _tactics.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ var result = new List();
+ var ordinal = 0;
+ foreach (var input in (inputs ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i.TacticCode)))
+ {
+ var row = new RmsActionTactic
+ {
+ RmsActionTacticId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId,
+ TacticCode = input.TacticCode.Trim(), ActorUnitId = input.ActorUnitId, OccurredOn = input.OccurredOn, SourceKind = (int)RmsSourceKind.None,
+ Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ };
+ await _tactics.InsertAsync(row, cancellationToken, true);
+ result.Add(row);
+ }
+ return result;
+ }
+
+ private async Task ReplaceNarrativeAsync(RmsIncidentReport report, IncidentReportDraftInput input, DateTime now, CancellationToken cancellationToken)
+ {
+ await _narratives.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ var row = new RmsNarrative
+ {
+ RmsNarrativeId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId,
+ Narrative = input.Narrative, ImpedimentNarrative = input.ImpedimentNarrative, OutcomeNarrative = input.OutcomeNarrative, SupplementalJson = input.SupplementalJson,
+ CreatedOn = now, ModifiedOn = now, RowVersion = 1
+ };
+ await _narratives.InsertAsync(row, cancellationToken, true);
+ return row;
+ }
+
+ private async Task ReplaceDraftRowsFromAsync(RmsIncidentReport report, IncidentReportAggregate source, DateTime now, CancellationToken cancellationToken)
+ {
+ await _locations.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ await _types.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ await _units.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ await _aids.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ await _tactics.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ await _narratives.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+ await _facts.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken);
+
+ if (source.Location != null) await _locations.InsertAsync(Copy(source.Location, l => l.RmsLocationId = Guid.NewGuid().ToString(), null, now), cancellationToken, true);
+ foreach (var t in source.Types) await _types.InsertAsync(Copy(t, x => x.RmsIncidentTypeId = Guid.NewGuid().ToString(), null, now), cancellationToken, true);
+ foreach (var u in source.Units) await _units.InsertAsync(Copy(u, x => x.RmsUnitResponseId = Guid.NewGuid().ToString(), null, now), cancellationToken, true);
+ foreach (var a in source.Aids) await _aids.InsertAsync(Copy(a, x => x.RmsAidId = Guid.NewGuid().ToString(), null, now), cancellationToken, true);
+ foreach (var t in source.Tactics) await _tactics.InsertAsync(Copy(t, x => x.RmsActionTacticId = Guid.NewGuid().ToString(), null, now), cancellationToken, true);
+ if (source.Narrative != null) await _narratives.InsertAsync(Copy(source.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), null, now), cancellationToken, true);
+ foreach (var f in source.Facts) await _facts.InsertAsync(Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), null, now), cancellationToken, true);
+ }
+
+ #endregion
+
+ #region Revision / signature / number
+
+ private async Task WriteRevisionAsync(RmsIncidentReport report, IncidentReportAggregate draft, RmsRevisionTransition transition, string userId, string reasonCode, string reasonText, string attestationVersion, DateTime now, CancellationToken cancellationToken)
+ {
+ var json = SerializeSnapshot(draft);
+ var revision = new RmsRevision
+ {
+ RmsRevisionId = Guid.NewGuid().ToString(),
+ DepartmentId = report.DepartmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ RecordId = report.RmsIncidentReportId,
+ RecordKind = (int)RmsRecordKind.IncidentReport,
+ RevisionNumber = report.RevisionCount + 1,
+ Transition = (int)transition,
+ PriorRevisionId = report.CurrentRevisionId,
+ DefinitionKey = report.DefinitionKey,
+ DefinitionVersion = report.DefinitionVersion,
+ SnapshotJson = json,
+ Checksum = RecordSnapshotSerializer.Checksum(json),
+ ActorUserId = userId,
+ ReasonCode = reasonCode,
+ ReasonText = reasonText,
+ AttestationStatementVersion = transition == RmsRevisionTransition.Voided ? null : attestationVersion,
+ AttestedOn = transition == RmsRevisionTransition.Voided ? (DateTime?)null : now,
+ OriginClient = report.OriginClient,
+ CreatedOn = now
+ };
+ await _revisions.InsertAsync(revision, cancellationToken, true);
+
+ // Revision-bound copies keep finalized data queryable without touching the draft rows.
+ var id = revision.RmsRevisionId;
+ if (draft.Location != null) await _locations.InsertAsync(Copy(draft.Location, l => l.RmsLocationId = Guid.NewGuid().ToString(), id, now), cancellationToken, true);
+ foreach (var t in draft.Types) await _types.InsertAsync(Copy(t, x => x.RmsIncidentTypeId = Guid.NewGuid().ToString(), id, now), cancellationToken, true);
+ foreach (var u in draft.Units) await _units.InsertAsync(Copy(u, x => x.RmsUnitResponseId = Guid.NewGuid().ToString(), id, now), cancellationToken, true);
+ foreach (var a in draft.Aids) await _aids.InsertAsync(Copy(a, x => x.RmsAidId = Guid.NewGuid().ToString(), id, now), cancellationToken, true);
+ foreach (var t in draft.Tactics) await _tactics.InsertAsync(Copy(t, x => x.RmsActionTacticId = Guid.NewGuid().ToString(), id, now), cancellationToken, true);
+ if (draft.Narrative != null) await _narratives.InsertAsync(Copy(draft.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), id, now), cancellationToken, true);
+ foreach (var f in draft.Facts) await _facts.InsertAsync(Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), id, now), cancellationToken, true);
+
+ return revision;
+ }
+
+ private async Task WriteSignatureAsync(RmsIncidentReport report, RmsRevision revision, string userId, string statementVersion, string ipAddress, DateTime now, CancellationToken cancellationToken)
+ {
+ var profile = await _profiles.GetProfileByUserIdAsync(userId, false);
+ var roles = await _roles.GetRolesForUserAsync(userId, report.DepartmentId);
+ await _signatures.InsertAsync(new RmsSignature
+ {
+ RmsSignatureId = Guid.NewGuid().ToString(),
+ DepartmentId = report.DepartmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ RecordId = report.RmsIncidentReportId,
+ RecordKind = (int)RmsRecordKind.IncidentReport,
+ RevisionId = revision.RmsRevisionId,
+ SignerUserId = userId,
+ SignerNameSnapshot = profile == null ? null : $"{profile.FirstName} {profile.LastName}".Trim(),
+ SignerRoleSnapshot = roles == null || roles.Count == 0 ? null : string.Join(", ", roles.Select(r => r.Name).Where(n => !string.IsNullOrWhiteSpace(n))),
+ Intent = (int)RmsSignatureIntent.Attestation,
+ StatementVersion = statementVersion,
+ StatementText = AttestationStatement(statementVersion),
+ Method = (int)RmsSignatureMethod.WebAttestation,
+ SignedOn = now,
+ IpAddress = ipAddress,
+ ArtifactChecksum = revision.Checksum,
+ CreatedOn = now,
+ RowVersion = 1
+ }, cancellationToken, true);
+ }
+
+ public static string AttestationStatement(string version)
+ {
+ return "I attest that this incident report is accurate and complete to the best of my knowledge.";
+ }
+
+ private async Task AllocateRecordNumberAsync(RmsIncidentReport report, CancellationToken cancellationToken)
+ {
+ var config = await _settings.GetRecordsNumberingConfigAsync(report.DepartmentId);
+ var year = (report.CallCreatedOn ?? DateTime.UtcNow).Year;
+ var prefix = NumberPrefix + "-";
+ if (config.PerGroupSequence && report.StationGroupId.HasValue)
+ prefix += "G" + report.StationGroupId.Value + "-";
+ if (config.IncludeYear)
+ prefix += year + "-";
+ var width = Math.Max(3, Math.Min(8, config.SequenceWidth <= 0 ? 4 : config.SequenceWidth));
+ var sequence = await _reports.GetMaxRecordNumberSequenceAsync(report.DepartmentId, prefix) + 1;
+ return prefix + sequence.ToString("D" + width);
+ }
+
+ /// Scoped idempotency key (plan 5.3): stable for a revision, new for every new revision.
+ public static string IdempotencyKey(int departmentId, string reportId, string revisionId)
+ {
+ using var sha = SHA256.Create();
+ return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes($"neris:{departmentId}:{reportId}:{revisionId}"))).ToLowerInvariant();
+ }
+
+ #endregion
+
+ #region Hydration / projection / scope / events
+
+ private async Task HydrateAsync(RmsIncidentReport report, string revisionId, bool includeHistory)
+ {
+ var dept = report.DepartmentId;
+ var id = report.RmsIncidentReportId;
+ var aggregate = new IncidentReportAggregate
+ {
+ Report = report,
+ Location = (await _locations.GetForRecordAsync(dept, id, revisionId))?.FirstOrDefault(),
+ Types = (await _types.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(),
+ Units = (await _units.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(),
+ Aids = (await _aids.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(),
+ Tactics = (await _tactics.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(),
+ Narrative = (await _narratives.GetForRecordAsync(dept, id, revisionId))?.FirstOrDefault(),
+ Facts = (await _facts.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(),
+ Issues = (await _issues.GetForRecordAsync(dept, id))?.ToList() ?? new List(),
+ GroupScope = (await _scopes.GetForRecordAsync(dept, id))?.ToList() ?? new List()
+ };
+ if (includeHistory)
+ {
+ aggregate.Submissions = (await _submissions.GetForRecordAsync(dept, id))?.ToList() ?? new List();
+ aggregate.Signatures = (await _signatures.GetForRecordAsync(dept, id))?.ToList() ?? new List();
+ aggregate.Revisions = (await _revisions.GetForRecordAsync(dept, id))?.ToList() ?? new List();
+ }
+ return aggregate;
+ }
+
+ private async Task RefreshProjectionAsync(RmsIncidentReport report, CancellationToken cancellationToken)
+ {
+ await UpsertProjectionAsync(await HydrateAsync(report, null, false), cancellationToken);
+ }
+
+ private async Task UpsertProjectionAsync(IncidentReportAggregate aggregate, CancellationToken cancellationToken)
+ {
+ var report = aggregate.Report;
+ var existing = await _projections.GetByRecordIdAsync(report.DepartmentId, report.RmsIncidentReportId);
+ var projection = existing ?? new RmsRecordSearchProjection
+ {
+ RmsRecordSearchProjectionId = report.RmsIncidentReportId,
+ DepartmentId = report.DepartmentId,
+ ProtectionId = Guid.NewGuid().ToString(),
+ CreatedOn = DateTime.UtcNow,
+ RowVersion = 0
+ };
+
+ projection.SourceType = (int)RmsSearchSourceType.Record;
+ projection.SourceId = report.RmsIncidentReportId;
+ projection.RecordKind = (int)RmsRecordKind.IncidentReport;
+ projection.RecordNumber = report.RecordNumber;
+ projection.DraftReference = report.DraftReference;
+ projection.DefinitionKey = report.DefinitionKey;
+ projection.DefinitionVersion = report.DefinitionVersion;
+ projection.RecordType = null;
+ projection.State = report.State;
+ projection.OccurredOn = report.CallCreatedOn;
+ projection.RecordCreatedOn = report.CreatedOn;
+ projection.FinalizedOn = report.FinalizedOn;
+ projection.StationGroupId = report.StationGroupId;
+ projection.CallId = report.CallId;
+ projection.CallNumber = report.IncidentNumber;
+ projection.AuthorUserId = report.AuthorUserId;
+ projection.OwnerUserId = report.OwnerUserId;
+ projection.ReviewerUserId = report.ReviewerUserId;
+ projection.ParticipantUserIds = string.Empty;
+ projection.UnitIds = string.Join(",", aggregate.Units.Where(u => u.UnitId.HasValue).Select(u => u.UnitId.Value).Distinct());
+ projection.GroupScopeIds = string.Join(",", (aggregate.GroupScope ?? new List