diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs
index 535a0871e..eeeb27f94 100644
--- a/Core/Resgrid.Config/TtsConfig.cs
+++ b/Core/Resgrid.Config/TtsConfig.cs
@@ -36,6 +36,13 @@ public static class TtsConfig
public static int DefaultSpeed = 150;
public static int MaxConcurrentGenerations = 4;
public static int MaxTextLength = 1000;
+
+ ///
+ /// Upper bound, in seconds, on a single synthesis + storage run in the TTS service.
+ /// Generation doesn't observe the caller's cancellation token, so this bounds a
+ /// wedged Piper/ffmpeg process. Keep it well above a cold model load.
+ ///
+ public static int GenerationTimeoutSeconds = 300;
public static string PiperExecutable = "piper";
public static string PiperModelDirectory = "/usr/local/share/piper-voices";
public static string FfmpegExecutable = "ffmpeg";
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx
index 08c46955a..26ab9aba3 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx
@@ -738,4 +738,208 @@
يتطلب من كل عضو تأكيد الإجراءات الخطرة أو على مستوى القسم عبر المساعد/الرسائل النصية باستخدام رمز PIN الأمان الشخصي المكون من 4 أرقام. يحصل الأعضاء الذين ليس لديهم رمز PIN على رمز يتم إنشاؤه عشوائيًا (يمكن عرضه في صفحة ملفهم الشخصي).
+
+ بطاقات الإرسال والإرسال التلقائي
+
+
+ وضع الاختيار التلقائي للموارد
+
+
+ كيفية اختيار النظام للوحدات والأفراد لملء بطاقة الإرسال: حسب منطقة استجابة المحطة التي تحتوي على النداء (مع الانتقال إلى أقرب محطة عند النقص) أو حسب أقرب الموارد المتاحة.
+
+
+ إرسال الموارد الموصى بها تلقائيًا
+
+
+ عند التفعيل، ترسل بطاقات الإرسال المطابقة الموارد فور إنشاء النداء. عند الإيقاف، تُملأ التوصيات مسبقًا في صفحة النداء الجديد لمراجعة المرسل.
+
+
+ فترة الراحة (بالدقائق)
+
+
+ بعد إرسال وحدة أو شخص، يتم خفض أولويتهم لهذا العدد من الدقائق حتى لا تُرسل الموارد نفسها بشكل متتالٍ. 0 لتعطيل الخاصية.
+
+
+ الحد الأدنى لطاقم الوحدة للإرسال
+
+
+ الوحدات التي يقل طاقمها عن هذا المستوى لا يتم اختيارها حتى لو تطابقت حالتها. الوحدات بدون أدوار محددة تمر دائمًا.
+
+
+ تمكين توصيات إعادة التمركز / سد النقص
+
+
+ بعد الاختيار، يتحقق من الحد الأدنى لتغطية المحطات ويوصي بنقل الموارد إلى المحطات المستنزفة.
+
+
+ ضبط استجابة أقرب وحدة
+
+
+ أقصى عمر لموقع الوحدة (بالثواني)
+
+
+ أقصى عمر لموقع الأفراد (بالثواني)
+
+
+ أقصى نصف قطر للاستجابة (بالأمتار)
+
+
+ تضمين المواقع القديمة
+
+
+ الترتيب حسب وقت الوصول المقدر بالقيادة
+
+
+ يعيد ترتيب أقرب المرشحين باستخدام وقت القيادة بدلاً من المسافة المستقيمة. يستخدم مزود الخرائط وقد يضيف تكلفة وتأخيرًا.
+
+
+ حجم القائمة المختصرة لوقت الوصول
+
+
+ الحد الأدنى لتغطية المحطات
+
+
+ يحدد الحد الأدنى لعدد كل نوع وحدة أو دور أفراد يجب أن يظل متاحًا في المحطة. عندما يؤدي الإرسال إلى انخفاض التغطية دون الحد الأدنى، يوصي النظام بإعادة التمركز.
+
+
+ المحطة
+
+
+ نوع الوحدة / الدور
+
+
+ الحد الأدنى المتاح
+
+
+ نصف القطر (بالأمتار، وضع أقرب وحدة)
+
+
+ مُفعّل
+
+
+ بطاقات الإرسال
+
+
+ حزم استجابة مخطط لها مسبقًا تُطابق مع النداءات حسب الأولوية والنوع. تحدد كل بطاقة أنواع الوحدات وأدوار الأفراد لكل مستوى إنذار والحالات التي تعتبر متاحة.
+
+
+ بطاقة إرسال جديدة
+
+
+ تعديل
+
+
+ حذف هذه البطاقة؟ تحتفظ النداءات التي استخدمتها بالفعل بسجل الإرسال الخاص بها.
+
+
+ الاسم
+
+
+ الوصف
+
+
+ معطلة
+
+
+ عام
+
+
+ الافتراضي للإدارة
+
+
+ يدوي فقط (بدون اختيار تلقائي)
+
+
+ تعبئة مسبقة لمراجعة المرسل
+
+
+ إرسال تلقائي
+
+
+ بدون شرط طاقم
+
+
+ المحطة الأم (تُستخدم عندما لا يكون للنداء موقع)
+
+
+ لا شيء
+
+
+ المشغلات
+
+
+ تتطابق البطاقة مع النداء عندما يتطابق أي مشغل. يفوز التطابق الأكثر تحديدًا (الأولوية + النوع يتغلب على النوع، الذي يتغلب على الأولوية).
+
+
+ نوع المشغل
+
+
+ أولوية النداء
+
+
+ نوع النداء
+
+
+ الأولوية والنوع
+
+
+ إضافة مشغل
+
+
+ مستويات الإنذار
+
+
+ متطلبات كل مستوى إنذار تراكمية: التصعيد إلى المستوى 2 يرسل موارد المستوى 2 بالإضافة إلى ما تم تعيينه بالفعل.
+
+
+ إنذار
+
+
+ اسم اختياري، مثل حريق مؤكد
+
+
+ إضافة مستوى إنذار
+
+
+ متطلبات أنواع الوحدات
+
+
+ متطلبات أدوار الأفراد
+
+
+ إضافة نوع وحدة
+
+
+ إضافة دور
+
+
+ الحالات القابلة للإرسال
+
+
+ الحالات وأوضاع الجاهزية التي تعتبر متاحة لهذه البطاقة. اترك القسم فارغًا لاستخدام قواعد التوفر القياسية.
+
+
+ مستويات الجاهزية
+
+
+ اختبار / محاكاة
+
+
+ معاينة ما سترسله البطاقات الآن لأولوية ونوع وموقع — لا يتم إرسال أي شيء.
+
+
+ تشغيل الاختبار
+
+
+ تم حفظ البطاقة.
+
+
+ أولوية النداء
+
+
+ نوع النداء
+
+
+ بطاقات الإرسال
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx
index 4b997926c..09cedecf6 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx
@@ -689,4 +689,208 @@
Verlangt, dass jedes Mitglied gefährliche oder abteilungsweite Assistenten-/SMS-Aktionen mit seiner persönlichen 4-stelligen Sicherheits-PIN bestätigt. Mitglieder ohne PIN erhalten eine zufällig generierte (einsehbar auf ihrer Profilseite).
+
+ Alarm- und Ausrückeordnung & automatische Alarmierung
+
+
+ Modus der automatischen Ressourcenauswahl
+
+
+ Wie das System Einheiten und Personal zur Erfüllung einer Ausrückeordnung auswählt: nach dem Wachbereich, in dem der Einsatz liegt (bei Engpässen kaskadierend zur nächstgelegenen Wache), oder nach den nächstgelegenen verfügbaren Ressourcen.
+
+
+ Empfohlene Ressourcen automatisch alarmieren
+
+
+ Wenn aktiviert, alarmieren passende Ausrückeordnungen die Ressourcen sofort bei Einsatzerstellung. Wenn deaktiviert, werden Empfehlungen auf der Seite "Neuer Einsatz" zur Prüfung durch den Disponenten vorausgewählt.
+
+
+ Ruhezeit (Minuten)
+
+
+ Nach einer Alarmierung werden Einheit oder Person für diese Anzahl Minuten nachrangig behandelt, damit nicht immer dieselben Ressourcen hintereinander alarmiert werden. 0 deaktiviert.
+
+
+ Mindestbesetzung für Alarmierung
+
+
+ Einheiten unterhalb dieser Besetzung werden nicht ausgewählt, auch wenn ihr Status passt. Einheiten ohne definierte Rollen bestehen immer.
+
+
+ Nachrück-/Auffüllempfehlungen aktivieren
+
+
+ Nach der Auswahl werden die Mindestabdeckungen der Wachen geprüft und Verlegungen zu entblößten Wachen empfohlen.
+
+
+ Feineinstellung nächstgelegene Einheit
+
+
+ Maximales Alter der Einheitenposition (Sekunden)
+
+
+ Maximales Alter der Personalposition (Sekunden)
+
+
+ Maximaler Einsatzradius (Meter)
+
+
+ Veraltete Positionen einbeziehen
+
+
+ Nach Fahrzeit-ETA sortieren
+
+
+ Die nächstgelegenen Kandidaten werden anhand der Fahrzeit statt der Luftlinie neu sortiert. Nutzt den Kartenanbieter und kann Kosten und Latenz verursachen.
+
+
+ Größe der ETA-Auswahlliste
+
+
+ Mindestabdeckung der Wachen
+
+
+ Legt fest, wie viele Einheiten eines Typs oder Personen einer Rolle an einer Wache mindestens verfügbar bleiben sollen. Fällt die Abdeckung durch eine Alarmierung darunter, empfiehlt das System Nachrückungen.
+
+
+ Wache
+
+
+ Einheitentyp / Rolle
+
+
+ Mindestens verfügbar
+
+
+ Radius (Meter, Modus nächstgelegene Einheit)
+
+
+ Aktiviert
+
+
+ Ausrückeordnungen
+
+
+ Vorgeplante Einsatzpakete, die Einsätzen nach Priorität und Typ zugeordnet werden. Jede Ordnung definiert je Alarmstufe die zu alarmierenden Einheitentypen und Personalrollen sowie die als verfügbar geltenden Status.
+
+
+ Neue Ausrückeordnung
+
+
+ Bearbeiten
+
+
+ Diese Ausrückeordnung löschen? Bereits damit erstellte Einsätze behalten ihre Alarmierungshistorie.
+
+
+ Name
+
+
+ Beschreibung
+
+
+ Deaktiviert
+
+
+ Allgemein
+
+
+ Standard der Organisation
+
+
+ Nur manuell (keine automatische Auswahl)
+
+
+ Zur Prüfung durch Disponenten vorbelegen
+
+
+ Automatisch alarmieren
+
+
+ Keine Besetzungsprüfung
+
+
+ Heimatwache (wenn ein Einsatz keinen Standort hat)
+
+
+ Keine
+
+
+ Auslöser
+
+
+ Eine Ordnung passt zu einem Einsatz, wenn ein beliebiger Auslöser zutrifft. Der spezifischste Treffer gewinnt (Priorität + Typ vor Typ, vor Priorität).
+
+
+ Auslösertyp
+
+
+ Einsatzpriorität
+
+
+ Einsatztyp
+
+
+ Priorität und Typ
+
+
+ Auslöser hinzufügen
+
+
+ Alarmstufen
+
+
+ Die Anforderungen jeder Alarmstufe sind additiv: Eine Eskalation auf Stufe 2 alarmiert die Ressourcen der Stufe 2 zusätzlich zu den bereits zugewiesenen.
+
+
+ Alarm
+
+
+ Optionaler Name, z. B. Vollbrand
+
+
+ Alarmstufe hinzufügen
+
+
+ Anforderungen Einheitentypen
+
+
+ Anforderungen Personalrollen
+
+
+ Einheitentyp hinzufügen
+
+
+ Rolle hinzufügen
+
+
+ Alarmierbare Status
+
+
+ Welche Einheiten-, Personalstatus und Bereitschaftsstufen für diese Ordnung als verfügbar gelten. Ein leerer Abschnitt nutzt die Standard-Verfügbarkeitsregeln.
+
+
+ Bereitschaftsstufen
+
+
+ Testen / Simulieren
+
+
+ Vorschau, was die Ausrückeordnungen für Priorität, Typ und Standort jetzt alarmieren würden — es wird nichts alarmiert.
+
+
+ Test ausführen
+
+
+ Ausrückeordnung gespeichert.
+
+
+ Einsatzpriorität
+
+
+ Einsatztyp
+
+
+ Ausrückeordnungen
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
index c8f1002bb..e92b94e40 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
@@ -768,4 +768,208 @@
Require every member to confirm dangerous or department-wide assistant/text actions with their personal 4-digit security PIN. Members without a PIN get a randomly generated one (viewable on their profile page).
+
+ Run Cards & Automatic Dispatch
+
+
+ Automatic resource selection mode
+
+
+ How the system selects units and personnel to fill a run card: by the station response area containing the call (cascading to the next nearest station on shortfall) or by the closest available resources.
+
+
+ Automatically dispatch recommended resources
+
+
+ When on, matched run cards dispatch resources immediately on call creation. When off, recommendations pre-populate the New Call page for dispatcher review.
+
+
+ Rest period (minutes)
+
+
+ After a unit or person is dispatched, deprioritize them for this many minutes so the same resources are not dispatched back to back. 0 disables.
+
+
+ Minimum unit staffing to dispatch
+
+
+ Units staffed below this level are not selected even when their status matches. Units without defined roles always pass.
+
+
+ Enable move-up / backfill recommendations
+
+
+ After dispatch selection, check station coverage minimums and recommend relocating resources to depleted stations.
+
+
+ Closest Unit Response Tuning
+
+
+ Maximum unit location age (seconds)
+
+
+ Maximum personnel location age (seconds)
+
+
+ Maximum response radius (meters)
+
+
+ Include stale locations
+
+
+ Order by driving ETA
+
+
+ Re-rank the closest candidates using routed drive time instead of straight-line distance. Uses the mapping provider and may add cost and latency.
+
+
+ ETA shortlist size
+
+
+ Station Coverage Minimums
+
+
+ Define the minimum number of each unit type or personnel role that should remain available at a station. When a dispatch would drop coverage below a minimum, the system recommends move-ups.
+
+
+ Station
+
+
+ Unit Type / Role
+
+
+ Minimum Available
+
+
+ Radius (meters, closest-unit mode)
+
+
+ Enabled
+
+
+ Run Cards
+
+
+ Pre-planned response packages matched to calls by priority and type. Each card defines the unit types and personnel roles to dispatch per alarm level and which statuses count as available.
+
+
+ New Run Card
+
+
+ Edit
+
+
+ Delete this run card? Calls already using it keep their dispatch history.
+
+
+ Name
+
+
+ Description
+
+
+ Disabled
+
+
+ General
+
+
+ Department default
+
+
+ Manual only (no automatic selection)
+
+
+ Pre-populate for dispatcher review
+
+
+ Dispatch automatically
+
+
+ No staffing gate
+
+
+ Home station (used when a call has no location)
+
+
+ None
+
+
+ Triggers
+
+
+ A card matches a call when any trigger matches. The most specific match wins (priority + type beats type, which beats priority).
+
+
+ Trigger type
+
+
+ Call Priority
+
+
+ Call Type
+
+
+ Priority and Type
+
+
+ Add Trigger
+
+
+ Alarm Levels
+
+
+ Each alarm level's requirements are additive: escalating to level 2 dispatches level 2's resources on top of what is already assigned.
+
+
+ Alarm
+
+
+ Optional name, e.g. Working Fire
+
+
+ Add Alarm Level
+
+
+ Unit type requirements
+
+
+ Personnel role requirements
+
+
+ Add Unit Type
+
+
+ Add Role
+
+
+ Dispatchable Statuses
+
+
+ Which unit statuses, personnel statuses and staffing levels count as available for this card. Leave a section empty to use the standard availability rules.
+
+
+ Staffing levels
+
+
+ Test / Simulate
+
+
+ Preview what this department's run cards would dispatch for a priority, type and location right now — nothing is dispatched.
+
+
+ Run Test
+
+
+ Run card saved.
+
+
+ Call Priority
+
+
+ Call Type
+
+
+ Run Cards
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx
index cc23448bd..a457f975e 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx
@@ -624,4 +624,208 @@
Exige que cada miembro confirme las acciones peligrosas o de todo el departamento del asistente/mensajes de texto con su PIN de seguridad personal de 4 dígitos. Los miembros sin PIN reciben uno generado aleatoriamente (visible en su página de perfil).
+
+ Tarjetas de despacho y despacho automático
+
+
+ Modo de selección automática de recursos
+
+
+ Cómo el sistema selecciona unidades y personal para completar una tarjeta de despacho: por el área de respuesta de la estación que contiene la llamada (pasando a la estación más cercana si falta) o por los recursos disponibles más cercanos.
+
+
+ Despachar automáticamente los recursos recomendados
+
+
+ Si está activado, las tarjetas coincidentes despachan los recursos inmediatamente al crear la llamada. Si está desactivado, las recomendaciones se preseleccionan en la página de Nueva Llamada para revisión del despachador.
+
+
+ Período de descanso (minutos)
+
+
+ Después de un despacho, la unidad o persona se despriorizará durante estos minutos para no despachar los mismos recursos consecutivamente. 0 lo desactiva.
+
+
+ Dotación mínima de la unidad para despachar
+
+
+ Las unidades con dotación inferior a este nivel no se seleccionan aunque su estado coincida. Las unidades sin roles definidos siempre pasan.
+
+
+ Habilitar recomendaciones de cobertura / reubicación
+
+
+ Tras la selección, comprueba los mínimos de cobertura de las estaciones y recomienda reubicar recursos a las estaciones descubiertas.
+
+
+ Ajustes de respuesta de unidad más cercana
+
+
+ Antigüedad máxima de la ubicación de la unidad (segundos)
+
+
+ Antigüedad máxima de la ubicación del personal (segundos)
+
+
+ Radio máximo de respuesta (metros)
+
+
+ Incluir ubicaciones obsoletas
+
+
+ Ordenar por tiempo estimado de conducción
+
+
+ Reordena los candidatos más cercanos usando el tiempo de conducción en lugar de la distancia en línea recta. Usa el proveedor de mapas y puede añadir coste y latencia.
+
+
+ Tamaño de la lista corta para ETA
+
+
+ Mínimos de cobertura de estaciones
+
+
+ Define el número mínimo de cada tipo de unidad o rol de personal que debe permanecer disponible en una estación. Si un despacho reduce la cobertura por debajo del mínimo, el sistema recomienda reubicaciones.
+
+
+ Estación
+
+
+ Tipo de unidad / Rol
+
+
+ Mínimo disponible
+
+
+ Radio (metros, modo unidad más cercana)
+
+
+ Habilitado
+
+
+ Tarjetas de despacho
+
+
+ Paquetes de respuesta predefinidos que se asignan a llamadas por prioridad y tipo. Cada tarjeta define los tipos de unidad y roles de personal a despachar por nivel de alarma y qué estados cuentan como disponibles.
+
+
+ Nueva tarjeta de despacho
+
+
+ Editar
+
+
+ ¿Eliminar esta tarjeta? Las llamadas que ya la usaron conservan su historial de despacho.
+
+
+ Nombre
+
+
+ Descripción
+
+
+ Deshabilitada
+
+
+ General
+
+
+ Predeterminado del departamento
+
+
+ Solo manual (sin selección automática)
+
+
+ Preseleccionar para revisión del despachador
+
+
+ Despachar automáticamente
+
+
+ Sin control de dotación
+
+
+ Estación base (si la llamada no tiene ubicación)
+
+
+ Ninguna
+
+
+ Disparadores
+
+
+ Una tarjeta coincide cuando cualquiera de sus disparadores coincide. Gana la coincidencia más específica (prioridad + tipo supera a tipo, que supera a prioridad).
+
+
+ Tipo de disparador
+
+
+ Prioridad de llamada
+
+
+ Tipo de llamada
+
+
+ Prioridad y tipo
+
+
+ Añadir disparador
+
+
+ Niveles de alarma
+
+
+ Los requisitos de cada nivel son aditivos: escalar al nivel 2 despacha los recursos del nivel 2 además de los ya asignados.
+
+
+ Alarma
+
+
+ Nombre opcional, p. ej. Incendio declarado
+
+
+ Añadir nivel de alarma
+
+
+ Requisitos de tipos de unidad
+
+
+ Requisitos de roles de personal
+
+
+ Añadir tipo de unidad
+
+
+ Añadir rol
+
+
+ Estados despachables
+
+
+ Qué estados de unidad, estados de personal y niveles de dotación cuentan como disponibles para esta tarjeta. Deje una sección vacía para usar las reglas de disponibilidad estándar.
+
+
+ Niveles de dotación
+
+
+ Probar / Simular
+
+
+ Vista previa de lo que las tarjetas despacharían ahora para una prioridad, tipo y ubicación — no se despacha nada.
+
+
+ Ejecutar prueba
+
+
+ Tarjeta guardada.
+
+
+ Prioridad de llamada
+
+
+ Tipo de llamada
+
+
+ Tarjetas de despacho
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx
index bc5a4d947..66e8b517e 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx
@@ -689,4 +689,208 @@
Exige que chaque membre confirme les actions dangereuses ou à l'échelle du service de l'assistant/SMS avec son code PIN de sécurité personnel à 4 chiffres. Les membres sans PIN en reçoivent un généré aléatoirement (visible sur leur page de profil).
+
+ Plans de départ et répartition automatique
+
+
+ Mode de sélection automatique des ressources
+
+
+ Comment le système sélectionne les unités et le personnel pour remplir un plan de départ : par la zone de réponse de la caserne contenant l'appel (en cascadant vers la caserne la plus proche en cas de manque) ou par les ressources disponibles les plus proches.
+
+
+ Répartir automatiquement les ressources recommandées
+
+
+ Si activé, les plans de départ correspondants répartissent les ressources immédiatement à la création de l'appel. Sinon, les recommandations sont présélectionnées sur la page Nouvel Appel pour examen par l'opérateur.
+
+
+ Période de repos (minutes)
+
+
+ Après une répartition, l'unité ou la personne est dépriorisée pendant ce nombre de minutes afin de ne pas solliciter les mêmes ressources coup sur coup. 0 désactive.
+
+
+ Effectif minimal de l'unité pour la répartition
+
+
+ Les unités dont l'effectif est inférieur à ce niveau ne sont pas sélectionnées même si leur statut correspond. Les unités sans rôles définis passent toujours.
+
+
+ Activer les recommandations de couverture / redéploiement
+
+
+ Après la sélection, vérifie les minimums de couverture des casernes et recommande de déplacer des ressources vers les casernes dégarnies.
+
+
+ Réglages de la réponse de l'unité la plus proche
+
+
+ Âge maximal de la position de l'unité (secondes)
+
+
+ Âge maximal de la position du personnel (secondes)
+
+
+ Rayon de réponse maximal (mètres)
+
+
+ Inclure les positions obsolètes
+
+
+ Trier par temps de trajet estimé
+
+
+ Reclasse les candidats les plus proches selon le temps de trajet routier plutôt que la distance à vol d'oiseau. Utilise le fournisseur de cartographie et peut ajouter coût et latence.
+
+
+ Taille de la liste restreinte ETA
+
+
+ Minimums de couverture des casernes
+
+
+ Définit le nombre minimal de chaque type d'unité ou rôle de personnel devant rester disponible dans une caserne. Si une répartition fait passer la couverture sous le minimum, le système recommande des redéploiements.
+
+
+ Caserne
+
+
+ Type d'unité / Rôle
+
+
+ Minimum disponible
+
+
+ Rayon (mètres, mode unité la plus proche)
+
+
+ Activé
+
+
+ Plans de départ
+
+
+ Ensembles de réponse préétablis associés aux appels par priorité et type. Chaque plan définit les types d'unités et rôles à répartir par niveau d'alarme et les statuts considérés comme disponibles.
+
+
+ Nouveau plan de départ
+
+
+ Modifier
+
+
+ Supprimer ce plan de départ ? Les appels l'ayant déjà utilisé conservent leur historique.
+
+
+ Nom
+
+
+ Description
+
+
+ Désactivé
+
+
+ Général
+
+
+ Défaut du service
+
+
+ Manuel uniquement (pas de sélection automatique)
+
+
+ Présélectionner pour examen par l'opérateur
+
+
+ Répartir automatiquement
+
+
+ Pas de contrôle d'effectif
+
+
+ Caserne de rattachement (si l'appel n'a pas de position)
+
+
+ Aucune
+
+
+ Déclencheurs
+
+
+ Un plan correspond quand n'importe quel déclencheur correspond. La correspondance la plus spécifique l'emporte (priorité + type avant type, avant priorité).
+
+
+ Type de déclencheur
+
+
+ Priorité d'appel
+
+
+ Type d'appel
+
+
+ Priorité et type
+
+
+ Ajouter un déclencheur
+
+
+ Niveaux d'alarme
+
+
+ Les besoins de chaque niveau sont additifs : passer au niveau 2 répartit les ressources du niveau 2 en plus de celles déjà engagées.
+
+
+ Alarme
+
+
+ Nom facultatif, p. ex. Feu confirmé
+
+
+ Ajouter un niveau d'alarme
+
+
+ Besoins en types d'unités
+
+
+ Besoins en rôles de personnel
+
+
+ Ajouter un type d'unité
+
+
+ Ajouter un rôle
+
+
+ Statuts mobilisables
+
+
+ Quels statuts d'unités, statuts de personnel et niveaux de disponibilité comptent comme disponibles pour ce plan. Laissez une section vide pour utiliser les règles standard.
+
+
+ Niveaux de disponibilité
+
+
+ Tester / Simuler
+
+
+ Aperçu de ce que les plans répartiraient maintenant pour une priorité, un type et une position — rien n'est réparti.
+
+
+ Lancer le test
+
+
+ Plan de départ enregistré.
+
+
+ Priorité d'appel
+
+
+ Type d'appel
+
+
+ Plans de départ
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx
index 6067adac8..3bd850612 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx
@@ -689,4 +689,208 @@
Richiede che ogni membro confermi le azioni pericolose o a livello di dipartimento dell'assistente/SMS con il proprio PIN di sicurezza personale a 4 cifre. I membri senza PIN ne ricevono uno generato casualmente (visibile nella pagina del profilo).
+
+ Schede di intervento e invio automatico
+
+
+ Modalità di selezione automatica delle risorse
+
+
+ Come il sistema seleziona unità e personale per completare una scheda di intervento: in base all'area di risposta della stazione che contiene la chiamata (passando alla stazione più vicina in caso di carenza) o in base alle risorse disponibili più vicine.
+
+
+ Inviare automaticamente le risorse consigliate
+
+
+ Se attivo, le schede corrispondenti inviano le risorse immediatamente alla creazione della chiamata. Se disattivo, i suggerimenti vengono preselezionati nella pagina Nuova Chiamata per la revisione dell'operatore.
+
+
+ Periodo di riposo (minuti)
+
+
+ Dopo un invio, l'unità o la persona viene deprioritizzata per questi minuti per non inviare le stesse risorse consecutivamente. 0 disattiva.
+
+
+ Equipaggio minimo dell'unità per l'invio
+
+
+ Le unità con equipaggio inferiore a questo livello non vengono selezionate anche se lo stato corrisponde. Le unità senza ruoli definiti passano sempre.
+
+
+ Abilita raccomandazioni di copertura / riposizionamento
+
+
+ Dopo la selezione, verifica i minimi di copertura delle stazioni e raccomanda il riposizionamento verso le stazioni scoperte.
+
+
+ Regolazione risposta unità più vicina
+
+
+ Età massima della posizione dell'unità (secondi)
+
+
+ Età massima della posizione del personale (secondi)
+
+
+ Raggio massimo di risposta (metri)
+
+
+ Includi posizioni obsolete
+
+
+ Ordina per tempo di guida stimato
+
+
+ Riordina i candidati più vicini usando il tempo di guida invece della distanza in linea d'aria. Usa il provider di mappe e può aggiungere costi e latenza.
+
+
+ Dimensione della rosa per ETA
+
+
+ Minimi di copertura delle stazioni
+
+
+ Definisce il numero minimo di ciascun tipo di unità o ruolo del personale che deve restare disponibile in una stazione. Se un invio porta la copertura sotto il minimo, il sistema raccomanda riposizionamenti.
+
+
+ Stazione
+
+
+ Tipo di unità / Ruolo
+
+
+ Minimo disponibile
+
+
+ Raggio (metri, modalità unità più vicina)
+
+
+ Abilitato
+
+
+ Schede di intervento
+
+
+ Pacchetti di risposta predefiniti abbinati alle chiamate per priorità e tipo. Ogni scheda definisce i tipi di unità e i ruoli da inviare per livello di allarme e quali stati contano come disponibili.
+
+
+ Nuova scheda di intervento
+
+
+ Modifica
+
+
+ Eliminare questa scheda? Le chiamate che l'hanno già usata mantengono la cronologia di invio.
+
+
+ Nome
+
+
+ Descrizione
+
+
+ Disabilitata
+
+
+ Generale
+
+
+ Predefinito del dipartimento
+
+
+ Solo manuale (nessuna selezione automatica)
+
+
+ Preseleziona per la revisione dell'operatore
+
+
+ Invia automaticamente
+
+
+ Nessun controllo equipaggio
+
+
+ Stazione di appartenenza (se la chiamata non ha posizione)
+
+
+ Nessuna
+
+
+ Trigger
+
+
+ Una scheda corrisponde quando un qualsiasi trigger corrisponde. Vince la corrispondenza più specifica (priorità + tipo batte tipo, che batte priorità).
+
+
+ Tipo di trigger
+
+
+ Priorità chiamata
+
+
+ Tipo chiamata
+
+
+ Priorità e tipo
+
+
+ Aggiungi trigger
+
+
+ Livelli di allarme
+
+
+ I requisiti di ogni livello sono additivi: l'escalation al livello 2 invia le risorse del livello 2 in aggiunta a quelle già assegnate.
+
+
+ Allarme
+
+
+ Nome facoltativo, es. Incendio confermato
+
+
+ Aggiungi livello di allarme
+
+
+ Requisiti tipi di unità
+
+
+ Requisiti ruoli del personale
+
+
+ Aggiungi tipo di unità
+
+
+ Aggiungi ruolo
+
+
+ Stati inviabili
+
+
+ Quali stati delle unità, stati del personale e livelli di disponibilità contano come disponibili per questa scheda. Lasciare vuota una sezione per usare le regole standard.
+
+
+ Livelli di disponibilità
+
+
+ Prova / Simula
+
+
+ Anteprima di cosa verrebbe inviato ora per priorità, tipo e posizione — non viene inviato nulla.
+
+
+ Esegui prova
+
+
+ Scheda salvata.
+
+
+ Priorità chiamata
+
+
+ Tipo chiamata
+
+
+ Schede di intervento
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx
index 48e1fc071..de702032e 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx
@@ -689,4 +689,208 @@
Wymaga, aby każdy członek potwierdzał niebezpieczne lub obejmujące cały wydział akcje asystenta/SMS swoim osobistym 4-cyfrowym kodem PIN bezpieczeństwa. Członkowie bez kodu PIN otrzymują losowo wygenerowany (widoczny na stronie profilu).
+
+ Karty dysponowania i automatyczne dysponowanie
+
+
+ Tryb automatycznego doboru zasobów
+
+
+ Jak system dobiera jednostki i personel do karty dysponowania: według obszaru chronionego stacji obejmującego zdarzenie (z kaskadą do najbliższej stacji przy braku) lub według najbliższych dostępnych zasobów.
+
+
+ Automatycznie dysponuj rekomendowane zasoby
+
+
+ Gdy włączone, dopasowane karty dysponują zasoby natychmiast przy tworzeniu zgłoszenia. Gdy wyłączone, rekomendacje są wstępnie zaznaczane na stronie nowego zgłoszenia do weryfikacji dyspozytora.
+
+
+ Okres odpoczynku (minuty)
+
+
+ Po zadysponowaniu jednostka lub osoba ma obniżony priorytet przez podaną liczbę minut, aby te same zasoby nie były dysponowane raz za razem. 0 wyłącza.
+
+
+ Minimalna obsada jednostki do zadysponowania
+
+
+ Jednostki obsadzone poniżej tego poziomu nie są wybierane, nawet gdy ich status pasuje. Jednostki bez zdefiniowanych ról zawsze przechodzą.
+
+
+ Włącz rekomendacje przesunięć / uzupełnień
+
+
+ Po doborze sprawdza minimalne pokrycie stacji i rekomenduje przesunięcie zasobów do ogołoconych stacji.
+
+
+ Strojenie odpowiedzi najbliższej jednostki
+
+
+ Maksymalny wiek lokalizacji jednostki (sekundy)
+
+
+ Maksymalny wiek lokalizacji personelu (sekundy)
+
+
+ Maksymalny promień odpowiedzi (metry)
+
+
+ Uwzględniaj nieaktualne lokalizacje
+
+
+ Sortuj według szacowanego czasu dojazdu
+
+
+ Zmienia kolejność najbliższych kandydatów według czasu dojazdu zamiast odległości w linii prostej. Korzysta z dostawcy map i może zwiększyć koszty i opóźnienia.
+
+
+ Rozmiar krótkiej listy ETA
+
+
+ Minimalne pokrycie stacji
+
+
+ Określa minimalną liczbę każdego typu jednostki lub roli personelu, jaka powinna pozostać dostępna na stacji. Gdy dysponowanie obniży pokrycie poniżej minimum, system rekomenduje przesunięcia.
+
+
+ Stacja
+
+
+ Typ jednostki / Rola
+
+
+ Minimum dostępnych
+
+
+ Promień (metry, tryb najbliższej jednostki)
+
+
+ Włączone
+
+
+ Karty dysponowania
+
+
+ Wstępnie zaplanowane pakiety odpowiedzi dopasowywane do zgłoszeń według priorytetu i typu. Każda karta określa typy jednostek i role personelu do zadysponowania na każdy stopień alarmu oraz statusy uznawane za dostępne.
+
+
+ Nowa karta dysponowania
+
+
+ Edytuj
+
+
+ Usunąć tę kartę? Zgłoszenia, które już z niej korzystały, zachowują historię dysponowania.
+
+
+ Nazwa
+
+
+ Opis
+
+
+ Wyłączona
+
+
+ Ogólne
+
+
+ Domyślne ustawienie jednostki
+
+
+ Tylko ręcznie (bez automatycznego doboru)
+
+
+ Wstępnie zaznacz do weryfikacji dyspozytora
+
+
+ Dysponuj automatycznie
+
+
+ Bez kontroli obsady
+
+
+ Stacja macierzysta (gdy zgłoszenie nie ma lokalizacji)
+
+
+ Brak
+
+
+ Wyzwalacze
+
+
+ Karta pasuje do zgłoszenia, gdy pasuje dowolny wyzwalacz. Wygrywa najbardziej szczegółowe dopasowanie (priorytet + typ przed typem, przed priorytetem).
+
+
+ Typ wyzwalacza
+
+
+ Priorytet zgłoszenia
+
+
+ Typ zgłoszenia
+
+
+ Priorytet i typ
+
+
+ Dodaj wyzwalacz
+
+
+ Stopnie alarmowe
+
+
+ Wymagania każdego stopnia są addytywne: eskalacja do stopnia 2 dysponuje zasoby stopnia 2 oprócz już przydzielonych.
+
+
+ Alarm
+
+
+ Opcjonalna nazwa, np. Pożar rozwinięty
+
+
+ Dodaj stopień alarmowy
+
+
+ Wymagane typy jednostek
+
+
+ Wymagane role personelu
+
+
+ Dodaj typ jednostki
+
+
+ Dodaj rolę
+
+
+ Statusy do dysponowania
+
+
+ Które statusy jednostek, statusy personelu i poziomy gotowości liczą się jako dostępne dla tej karty. Pozostaw sekcję pustą, aby użyć standardowych reguł dostępności.
+
+
+ Poziomy gotowości
+
+
+ Test / Symulacja
+
+
+ Podgląd tego, co karty zadysponowałyby teraz dla priorytetu, typu i lokalizacji — nic nie jest dysponowane.
+
+
+ Uruchom test
+
+
+ Karta zapisana.
+
+
+ Priorytet zgłoszenia
+
+
+ Typ zgłoszenia
+
+
+ Karty dysponowania
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx
index 7c3a6de72..94fc699d2 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx
@@ -363,4 +363,208 @@
Require every member to confirm dangerous or department-wide assistant/text actions with their personal 4-digit security PIN. Members without a PIN get a randomly generated one (viewable on their profile page).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx
index e2b67889f..9c6e8aad3 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx
@@ -689,4 +689,208 @@
Kräver att varje medlem bekräftar farliga eller avdelningsövergripande assistent-/SMS-åtgärder med sin personliga 4-siffriga säkerhets-PIN. Medlemmar utan PIN får en slumpmässigt genererad (visas på deras profilsida).
+
+ Larmplaner och automatisk utlarmning
+
+
+ Läge för automatiskt resursval
+
+
+ Hur systemet väljer enheter och personal för att fylla en larmplan: efter det stationsområde som innehåller larmet (med kaskad till närmaste station vid brist) eller efter närmast tillgängliga resurser.
+
+
+ Larma rekommenderade resurser automatiskt
+
+
+ När aktiverat larmar matchande larmplaner resurser direkt när larmet skapas. När avaktiverat förifylls rekommendationerna på sidan Nytt larm för operatörens granskning.
+
+
+ Viloperiod (minuter)
+
+
+ Efter utlarmning nedprioriteras enheten eller personen under detta antal minuter så att samma resurser inte larmas gång på gång. 0 inaktiverar.
+
+
+ Lägsta bemanning för utlarmning
+
+
+ Enheter bemannade under denna nivå väljs inte även om deras status matchar. Enheter utan definierade roller passerar alltid.
+
+
+ Aktivera rekommendationer för omflyttning/täckning
+
+
+ Efter valet kontrolleras stationernas minimitäckning och omflyttning till tömda stationer rekommenderas.
+
+
+ Inställningar för närmaste enhet
+
+
+ Maximal ålder för enhetens position (sekunder)
+
+
+ Maximal ålder för personalens position (sekunder)
+
+
+ Maximal insatsradie (meter)
+
+
+ Inkludera inaktuella positioner
+
+
+ Sortera efter beräknad körtid
+
+
+ Rangordnar de närmaste kandidaterna efter körtid i stället för fågelvägen. Använder kartleverantören och kan medföra kostnad och fördröjning.
+
+
+ Storlek på ETA-urvalslista
+
+
+ Stationernas minimitäckning
+
+
+ Anger det minsta antal av varje enhetstyp eller personalroll som ska förbli tillgängligt på en station. Om en utlarmning sänker täckningen under miniminivån rekommenderar systemet omflyttningar.
+
+
+ Station
+
+
+ Enhetstyp / Roll
+
+
+ Minst tillgängliga
+
+
+ Radie (meter, läget närmaste enhet)
+
+
+ Aktiverad
+
+
+ Larmplaner
+
+
+ Förplanerade insatspaket som matchas mot larm efter prioritet och typ. Varje plan anger enhetstyper och personalroller per larmnivå och vilka statusar som räknas som tillgängliga.
+
+
+ Ny larmplan
+
+
+ Redigera
+
+
+ Ta bort denna larmplan? Larm som redan använt den behåller sin historik.
+
+
+ Namn
+
+
+ Beskrivning
+
+
+ Inaktiverad
+
+
+ Allmänt
+
+
+ Organisationens standard
+
+
+ Endast manuellt (inget automatiskt val)
+
+
+ Förifyll för operatörsgranskning
+
+
+ Larma automatiskt
+
+
+ Ingen bemanningskontroll
+
+
+ Hemstation (används när larmet saknar plats)
+
+
+ Ingen
+
+
+ Utlösare
+
+
+ En plan matchar ett larm när någon utlösare matchar. Den mest specifika matchningen vinner (prioritet + typ slår typ, som slår prioritet).
+
+
+ Utlösartyp
+
+
+ Larmprioritet
+
+
+ Larmtyp
+
+
+ Prioritet och typ
+
+
+ Lägg till utlösare
+
+
+ Larmnivåer
+
+
+ Varje larmnivås krav är additiva: eskalering till nivå 2 larmar nivå 2:s resurser utöver de redan tilldelade.
+
+
+ Larm
+
+
+ Valfritt namn, t.ex. Konstaterad brand
+
+
+ Lägg till larmnivå
+
+
+ Krav på enhetstyper
+
+
+ Krav på personalroller
+
+
+ Lägg till enhetstyp
+
+
+ Lägg till roll
+
+
+ Larmbara statusar
+
+
+ Vilka enhetsstatusar, personalstatusar och beredskapsnivåer som räknas som tillgängliga för denna plan. Lämna en sektion tom för standardregler.
+
+
+ Beredskapsnivåer
+
+
+ Testa / Simulera
+
+
+ Förhandsvisar vad larmplanerna skulle larma nu för en prioritet, typ och plats — inget larmas.
+
+
+ Kör test
+
+
+ Larmplan sparad.
+
+
+ Larmprioritet
+
+
+ Larmtyp
+
+
+ Larmplaner
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx
index ea0b5c104..8fb2361ef 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx
@@ -689,4 +689,208 @@
Вимагає, щоб кожен учасник підтверджував небезпечні або загальновідомчі дії асистента/SMS своїм особистим 4-значним PIN-кодом безпеки. Учасники без PIN-коду отримують випадково згенерований (доступний на сторінці профілю).
+
+ Картки виїзду та автоматична диспетчеризація
+
+
+ Режим автоматичного вибору ресурсів
+
+
+ Як система обирає підрозділи та персонал для картки виїзду: за зоною реагування станції, що містить виклик (з переходом до найближчої станції в разі нестачі), або за найближчими доступними ресурсами.
+
+
+ Автоматично відправляти рекомендовані ресурси
+
+
+ Якщо увімкнено, відповідні картки відправляють ресурси одразу під час створення виклику. Якщо вимкнено, рекомендації попередньо заповнюють сторінку нового виклику для перегляду диспетчером.
+
+
+ Період відпочинку (хвилини)
+
+
+ Після відправлення підрозділ або особа отримують нижчий пріоритет протягом цієї кількості хвилин, щоб ті самі ресурси не відправлялися поспіль. 0 вимикає.
+
+
+ Мінімальна укомплектованість підрозділу для відправлення
+
+
+ Підрозділи з укомплектованістю нижче цього рівня не обираються, навіть якщо їхній статус відповідає. Підрозділи без визначених ролей завжди проходять.
+
+
+ Увімкнути рекомендації щодо передислокації / заповнення
+
+
+ Після вибору перевіряє мінімальне покриття станцій і рекомендує передислокацію ресурсів до оголених станцій.
+
+
+ Налаштування реагування найближчого підрозділу
+
+
+ Максимальний вік місцезнаходження підрозділу (секунди)
+
+
+ Максимальний вік місцезнаходження персоналу (секунди)
+
+
+ Максимальний радіус реагування (метри)
+
+
+ Враховувати застарілі місцезнаходження
+
+
+ Сортувати за розрахунковим часом у дорозі
+
+
+ Пересортовує найближчих кандидатів за часом у дорозі замість відстані по прямій. Використовує картографічного провайдера і може додати вартість та затримку.
+
+
+ Розмір короткого списку для ETA
+
+
+ Мінімальне покриття станцій
+
+
+ Визначає мінімальну кількість кожного типу підрозділів або ролі персоналу, що має залишатися доступною на станції. Якщо відправлення знижує покриття нижче мінімуму, система рекомендує передислокацію.
+
+
+ Станція
+
+
+ Тип підрозділу / Роль
+
+
+ Мінімум доступних
+
+
+ Радіус (метри, режим найближчого підрозділу)
+
+
+ Увімкнено
+
+
+ Картки виїзду
+
+
+ Заздалегідь сплановані пакети реагування, що зіставляються з викликами за пріоритетом і типом. Кожна картка визначає типи підрозділів і ролі персоналу для кожного рівня тривоги та які статуси вважаються доступними.
+
+
+ Нова картка виїзду
+
+
+ Редагувати
+
+
+ Видалити цю картку? Виклики, що вже її використали, зберігають історію відправлень.
+
+
+ Назва
+
+
+ Опис
+
+
+ Вимкнено
+
+
+ Загальні
+
+
+ За замовчуванням відділу
+
+
+ Лише вручну (без автоматичного вибору)
+
+
+ Попередньо заповнити для перегляду диспетчером
+
+
+ Відправляти автоматично
+
+
+ Без перевірки укомплектованості
+
+
+ Домашня станція (якщо виклик без локації)
+
+
+ Немає
+
+
+ Тригери
+
+
+ Картка відповідає виклику, коли спрацьовує будь-який тригер. Перемагає найточніший збіг (пріоритет + тип перед типом, перед пріоритетом).
+
+
+ Тип тригера
+
+
+ Пріоритет виклику
+
+
+ Тип виклику
+
+
+ Пріоритет і тип
+
+
+ Додати тригер
+
+
+ Рівні тривоги
+
+
+ Вимоги кожного рівня додаються: ескалація до рівня 2 відправляє ресурси рівня 2 на додачу до вже призначених.
+
+
+ Тривога
+
+
+ Необов'язкова назва, напр. Робоча пожежа
+
+
+ Додати рівень тривоги
+
+
+ Вимоги до типів підрозділів
+
+
+ Вимоги до ролей персоналу
+
+
+ Додати тип підрозділу
+
+
+ Додати роль
+
+
+ Статуси для відправлення
+
+
+ Які статуси підрозділів, персоналу та рівні готовності вважаються доступними для цієї картки. Залиште розділ порожнім, щоб використовувати стандартні правила доступності.
+
+
+ Рівні готовності
+
+
+ Тест / Симуляція
+
+
+ Попередній перегляд того, що картки відправили б зараз для пріоритету, типу та локації — нічого не відправляється.
+
+
+ Запустити тест
+
+
+ Картку збережено.
+
+
+ Пріоритет виклику
+
+
+ Тип виклику
+
+
+ Картки виїзду
+
\ No newline at end of file
diff --git a/Core/Resgrid.Model/Call.cs b/Core/Resgrid.Model/Call.cs
index f099dcf36..b3a0b721b 100644
--- a/Core/Resgrid.Model/Call.cs
+++ b/Core/Resgrid.Model/Call.cs
@@ -185,6 +185,20 @@ public class Call : IEntity
public string IndoorMapFloorId { get; set; }
+ ///
+ /// Current alarm level (1-based). Only advanced by explicit escalation
+ /// ("Strike Next Alarm") when a run card with multiple alarm levels is active.
+ ///
+ [ProtoMember(35)]
+ public int AlarmLevel { get; set; } = 1;
+
+ ///
+ /// Run card that matched this call at creation/escalation time; drives
+ /// escalation lookups and the dispatch audit trail.
+ ///
+ [ProtoMember(36)]
+ public int? ActiveRunCardId { get; set; }
+
public bool CheckInTimersEnabled { get; set; }
[NotMapped]
@@ -215,6 +229,29 @@ public string GetIdentifier()
return Number;
}
+ ///
+ /// The human-readable label for this call: the call number prefixed onto the call name, i.e.
+ /// "26-45 Structure Fire". Either half can be missing on a partially-populated call, so this
+ /// degrades to whichever one is set rather than emitting a stray separator.
+ ///
+ public string GetDisplayName()
+ {
+ var number = Number?.Trim();
+ var name = Name?.Trim();
+
+ if (String.IsNullOrWhiteSpace(name))
+ return String.IsNullOrWhiteSpace(number) ? String.Empty : number;
+
+ if (String.IsNullOrWhiteSpace(number))
+ return name;
+
+ // Departments that already fold the number into the name shouldn't get it twice.
+ if (name.StartsWith(number, StringComparison.OrdinalIgnoreCase))
+ return name;
+
+ return $"{number} {name}";
+ }
+
public bool HasUserBeenDispatched(string userId)
{
if (Dispatches != null && Dispatches.Any())
@@ -332,7 +369,7 @@ public bool DidDispatchCountChange()
if (PreviousDispatchCount == 0)
return false;
- return PreviousDispatchCount == DispatchCount;
+ return PreviousDispatchCount != DispatchCount;
}
public bool HasValidGeolocationData()
diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs
index 8fdfe3522..2d32cdc48 100644
--- a/Core/Resgrid.Model/DepartmentSettingTypes.cs
+++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs
@@ -59,5 +59,8 @@ public enum DepartmentSettingTypes
HardwareTrackingStaleAfterSeconds = 55,
HardwareTrackingMobileFallbackEnabled = 56,
HardwareTrackingLocationRetentionDays = 57,
+ DispatchRecommendationMode = 58,
+ DispatchRecommendationAutoDispatch = 59,
+ DispatchRecommendationConfig = 60,
}
}
diff --git a/Core/Resgrid.Model/DispatchRecommendation.cs b/Core/Resgrid.Model/DispatchRecommendation.cs
new file mode 100644
index 000000000..2fb0a77e2
--- /dev/null
+++ b/Core/Resgrid.Model/DispatchRecommendation.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ /// Why a unit/person was picked by the recommendation engine.
+ public enum RecommendationSelectionReasons
+ {
+ Unknown = 0,
+
+ /// Resource belongs to the station whose geofence contains the call.
+ InGeofence = 1,
+
+ /// Resource pulled from a next-nearest station after the owning station fell short. CascadeDepth says how far out.
+ CascadeStation = 2,
+
+ /// Closest-unit mode pick by straight-line distance.
+ ClosestByDistance = 3,
+
+ /// Closest-unit mode pick re-ranked by routed ETA.
+ ClosestByEta = 4,
+
+ /// Resource was inside its rest period but nothing rested could fill the requirement.
+ RestPeriodOverridden = 5
+ }
+
+ /// Why a run card requirement could not be (fully) filled.
+ public enum RequirementShortfallReasons
+ {
+ Unknown = 0,
+ NoCandidatesAvailable = 1,
+ OutsideRadius = 2,
+ LocationsTooStale = 3,
+ NoLocationData = 4,
+ UnitsNotStaffed = 5,
+ AllInRestPeriod = 6,
+ StationsExhausted = 7
+ }
+
+ public class DispatchRecommendationRequest
+ {
+ public int DepartmentId { get; set; }
+
+ public int Priority { get; set; }
+
+ public string CallTypeName { get; set; }
+
+ public double? Latitude { get; set; }
+
+ public double? Longitude { get; set; }
+
+ /// Alarm level whose requirements should be filled (1-based). Levels below it are assumed already handled.
+ public int TargetAlarmLevel { get; set; } = 1;
+
+ /// Units already on the call — never recommended again (escalation additivity).
+ public List AlreadyDispatchedUnitIds { get; set; } = new List();
+
+ /// Users already on the call — never recommended again.
+ public List AlreadyDispatchedUserIds { get; set; } = new List();
+
+ /// Forces a mode instead of resolving department default + card override (preview tooling).
+ public DispatchRecommendationModes? ModeOverride { get; set; }
+ }
+
+ public class UnitRecommendation
+ {
+ public int UnitId { get; set; }
+
+ public string UnitName { get; set; }
+
+ public int UnitTypeId { get; set; }
+
+ public string UnitTypeName { get; set; }
+
+ public int? StationGroupId { get; set; }
+
+ public string StationGroupName { get; set; }
+
+ public RecommendationSelectionReasons SelectionReason { get; set; }
+
+ /// How many stations out the cascade went (0 = owning/containing station).
+ public int CascadeDepth { get; set; }
+
+ public double? DistanceMeters { get; set; }
+
+ public double? EtaSeconds { get; set; }
+
+ public DateTime? LocationTimestamp { get; set; }
+
+ public bool LocationIsStale { get; set; }
+
+ public string CurrentStatusText { get; set; }
+
+ /// UnitStaffingLevel at recommendation time (null when the staffing gate is off).
+ public int? StaffingLevel { get; set; }
+
+ public int SatisfiesRequirementId { get; set; }
+ }
+
+ public class PersonnelRecommendation
+ {
+ public string UserId { get; set; }
+
+ public string Name { get; set; }
+
+ public int RoleId { get; set; }
+
+ public string RoleName { get; set; }
+
+ public int? StationGroupId { get; set; }
+
+ public string StationGroupName { get; set; }
+
+ public RecommendationSelectionReasons SelectionReason { get; set; }
+
+ public int CascadeDepth { get; set; }
+
+ public double? DistanceMeters { get; set; }
+
+ public double? EtaSeconds { get; set; }
+
+ public DateTime? LocationTimestamp { get; set; }
+
+ public bool LocationIsStale { get; set; }
+
+ public string CurrentStatusText { get; set; }
+
+ public int SatisfiesRequirementId { get; set; }
+ }
+
+ public class RequirementShortfall
+ {
+ /// true = unit type requirement, false = personnel role requirement.
+ public bool IsUnitRequirement { get; set; }
+
+ public int RequirementId { get; set; }
+
+ public int TypeOrRoleId { get; set; }
+
+ public string TypeOrRoleName { get; set; }
+
+ public int AlarmLevel { get; set; }
+
+ public int RequiredCount { get; set; }
+
+ public int FilledCount { get; set; }
+
+ public RequirementShortfallReasons Reason { get; set; }
+ }
+
+ public class MoveUpRecommendation
+ {
+ public int StationGroupId { get; set; }
+
+ public string StationGroupName { get; set; }
+
+ public int? UnitTypeId { get; set; }
+
+ public string UnitTypeName { get; set; }
+
+ public int? PersonnelRoleId { get; set; }
+
+ public string PersonnelRoleName { get; set; }
+
+ public int MinimumRequired { get; set; }
+
+ public int AvailableAfterDispatch { get; set; }
+
+ /// Suggested unit to relocate (null for personnel coverage gaps).
+ public int? SuggestedUnitId { get; set; }
+
+ public string SuggestedUnitName { get; set; }
+
+ /// Suggested person to relocate (null for unit coverage gaps).
+ public string SuggestedUserId { get; set; }
+
+ public string SuggestedUserName { get; set; }
+
+ public int? FromStationGroupId { get; set; }
+
+ public string FromStationGroupName { get; set; }
+
+ public double? DistanceMeters { get; set; }
+ }
+
+ public class DispatchRecommendationResult
+ {
+ /// Null when no run card matched — callers treat the whole result as a no-op.
+ public int? MatchedRunCardId { get; set; }
+
+ public string MatchedRunCardName { get; set; }
+
+ public int AlarmLevel { get; set; }
+
+ public DispatchRecommendationModes ModeUsed { get; set; }
+
+ /// Resolved auto-dispatch decision (department default + card override).
+ public bool AutoDispatch { get; set; }
+
+ public List Units { get; set; } = new List();
+
+ public List Personnel { get; set; } = new List();
+
+ public List Shortfalls { get; set; } = new List();
+
+ public List MoveUps { get; set; } = new List();
+
+ /// Human-readable decision log for the audit/explainability panel.
+ public List Notes { get; set; } = new List();
+
+ public bool HasRecommendations => Units.Count > 0 || Personnel.Count > 0;
+
+ public bool HasShortfalls => Shortfalls.Count > 0;
+ }
+}
diff --git a/Core/Resgrid.Model/DispatchRecommendationConfig.cs b/Core/Resgrid.Model/DispatchRecommendationConfig.cs
new file mode 100644
index 000000000..4004bcff4
--- /dev/null
+++ b/Core/Resgrid.Model/DispatchRecommendationConfig.cs
@@ -0,0 +1,89 @@
+using ProtoBuf;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Department tuning for the run card dispatch recommendation engine, stored
+ /// serialized in DepartmentSettingTypes.DispatchRecommendationConfig. Covers
+ /// closest-unit location constraints, ETA re-ranking, rest-period rotation,
+ /// crew-staffing gating and move-up recommendations.
+ ///
+ [ProtoContract]
+ public class DispatchRecommendationConfig
+ {
+ public const int DefaultMaxLocationAgeSeconds = 1800;
+ public const int DefaultEtaShortlistSize = 5;
+
+ /// A fix older than a day cannot inform where a resource is now.
+ public const int MaximumLocationAgeSeconds = 86400;
+
+ /// Beyond this a radius stops narrowing anything; 0 already means "no cap".
+ public const int MaximumRadiusMeters = 500000;
+
+ ///
+ /// Each shortlisted candidate costs one routed-ETA call to the mapping provider,
+ /// issued while the caller waits to create the call, so this bound is what keeps a
+ /// mistyped setting from turning one dispatch into thousands of external requests.
+ ///
+ public const int MaximumEtaShortlistSize = 25;
+
+ /// A rest period beyond a day would hold every resource back indefinitely.
+ public const int MaximumRestPeriodMinutes = 1440;
+
+ public DispatchRecommendationConfig()
+ {
+ MaxLocationAgeSeconds = DefaultMaxLocationAgeSeconds;
+ MaxRadiusMeters = 0;
+ IncludeStaleLocations = false;
+ PersonnelMaxLocationAgeSeconds = DefaultMaxLocationAgeSeconds;
+ UseRoutedEta = false;
+ EtaShortlistSize = DefaultEtaShortlistSize;
+ RestPeriodMinutes = 0;
+ UnitMinimumStaffingLevel = 0;
+ MoveUpRecommendationsEnabled = false;
+ }
+
+ /// Closest-unit mode: unit location fixes older than this are excluded. 0 = no age limit.
+ [ProtoMember(1)]
+ public int MaxLocationAgeSeconds { get; set; }
+
+ /// Closest-unit mode: candidates farther than this from the call are excluded. 0 = no radius cap.
+ [ProtoMember(2)]
+ public int MaxRadiusMeters { get; set; }
+
+ /// Closest-unit mode: when true, fixes past the age limit still count (flagged stale) instead of being excluded.
+ [ProtoMember(3)]
+ public bool IncludeStaleLocations { get; set; }
+
+ /// Closest-unit mode: personnel location fixes older than this are excluded. 0 = no age limit.
+ [ProtoMember(4)]
+ public int PersonnelMaxLocationAgeSeconds { get; set; }
+
+ /// When true, the top-N straight-line candidates per requirement are re-ranked by routed ETA.
+ [ProtoMember(5)]
+ public bool UseRoutedEta { get; set; }
+
+ /// How many straight-line candidates per requirement get a routed ETA when UseRoutedEta is on.
+ [ProtoMember(6)]
+ public int EtaShortlistSize { get; set; }
+
+ ///
+ /// Minutes after a unit's/person's last dispatch during which they are
+ /// deprioritized (picked only when nothing rested can fill the requirement).
+ /// 0 = rotation off.
+ ///
+ [ProtoMember(7)]
+ public int RestPeriodMinutes { get; set; }
+
+ ///
+ /// Minimum UnitStaffingLevel a unit must hold to be dispatchable (units with no
+ /// defined seats always pass). 0 = staffing gate off. Overridable per run card.
+ ///
+ [ProtoMember(8)]
+ public int UnitMinimumStaffingLevel { get; set; }
+
+ /// When true, the engine runs the station coverage move-up pass after selection.
+ [ProtoMember(9)]
+ public bool MoveUpRecommendationsEnabled { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/DispatchRecommendationModes.cs b/Core/Resgrid.Model/DispatchRecommendationModes.cs
new file mode 100644
index 000000000..bdf19fbf5
--- /dev/null
+++ b/Core/Resgrid.Model/DispatchRecommendationModes.cs
@@ -0,0 +1,19 @@
+namespace Resgrid.Model
+{
+ ///
+ /// How the dispatch recommendation engine selects resources to fill a run card.
+ /// Stored as the department-wide default (DepartmentSettingTypes.DispatchRecommendationMode)
+ /// and optionally overridden per run card (RunCard.DispatchModeOverride).
+ ///
+ public enum DispatchRecommendationModes
+ {
+ /// No automatic selection; run cards only inform manual dispatch.
+ Off = 0,
+
+ /// Fill from the station group whose geofence contains the call, cascading to next-nearest stations on shortfall.
+ StationBased = 1,
+
+ /// Fill by proximity using the latest unit/personnel geolocation.
+ ClosestUnit = 2
+ }
+}
diff --git a/Core/Resgrid.Model/Events/RunCardEvents.cs b/Core/Resgrid.Model/Events/RunCardEvents.cs
new file mode 100644
index 000000000..434d440f2
--- /dev/null
+++ b/Core/Resgrid.Model/Events/RunCardEvents.cs
@@ -0,0 +1,50 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Events
+{
+ ///
+ /// A run card matched a call and its recommendations were applied (auto-dispatch)
+ /// or accepted from the pre-populated New Call page.
+ ///
+ public class RunCardActivatedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public int RunCardId { get; set; }
+ public string RunCardName { get; set; }
+ public int AlarmLevel { get; set; }
+ public int ModeUsed { get; set; }
+ public bool WasAutoDispatched { get; set; }
+ public List UnitIds { get; set; } = new List();
+ public List UserIds { get; set; } = new List();
+ }
+
+ /// A call was escalated to its next alarm level ("Strike Next Alarm").
+ public class CallAlarmEscalatedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public int PreviousAlarmLevel { get; set; }
+ public int NewAlarmLevel { get; set; }
+ public List AddedUnitIds { get; set; } = new List();
+ public List AddedUserIds { get; set; } = new List();
+ }
+
+ /// Auto-dispatch completed but one or more run card requirements could not be filled.
+ public class DispatchShortfallEvent
+ {
+ public int DepartmentId { get; set; }
+ public int CallId { get; set; }
+ public int RunCardId { get; set; }
+ public int AlarmLevel { get; set; }
+ public List Shortfalls { get; set; } = new List();
+ }
+
+ /// The move-up pass found a station below its minimum coverage.
+ public class StationCoverageGapEvent
+ {
+ public int DepartmentId { get; set; }
+ public int? CallId { get; set; }
+ public List MoveUps { get; set; } = new List();
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs
index 717b53e41..fea05bef5 100644
--- a/Core/Resgrid.Model/FeatureFlagKeys.cs
+++ b/Core/Resgrid.Model/FeatureFlagKeys.cs
@@ -18,5 +18,11 @@ public static class FeatureFlagKeys
/// API, web UI and mobile apps. Free for all plans; used for staged rollout only. Seeded by M0108.
///
public const string ChatSystem = "Chat.System";
+
+ ///
+ /// Gates the run card dispatch system (run cards, station-based dispatching, closest unit
+ /// response, move-up recommendations) across the web UI and API. Seeded by M0116.
+ ///
+ public const string DispatchRunCards = "Dispatch.RunCards";
}
}
diff --git a/Core/Resgrid.Model/GeoMath.cs b/Core/Resgrid.Model/GeoMath.cs
new file mode 100644
index 000000000..fe2efc82d
--- /dev/null
+++ b/Core/Resgrid.Model/GeoMath.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Newtonsoft.Json.Linq;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Pure geometry helpers for the dispatch system: station geofence parsing
+ /// (DepartmentGroup.Geofence polygon JSON), point-in-polygon containment,
+ /// centroid and haversine distance. No I/O, no department context.
+ ///
+ public static class GeoMath
+ {
+ public readonly struct GeoPoint
+ {
+ public GeoPoint(double latitude, double longitude)
+ {
+ Latitude = latitude;
+ Longitude = longitude;
+ }
+
+ public double Latitude { get; }
+
+ public double Longitude { get; }
+ }
+
+ ///
+ /// Parses a station geofence stored on DepartmentGroup.Geofence. The current
+ /// writer emits [{"lat":39.7,"lng":-104.9},...]; legacy rows from the old
+ /// Google Maps drawing tool used [{"k":39.7,"A":-104.9},...] (also seen with
+ /// lower-case "a"). Returns null when the JSON is unparseable or describes
+ /// fewer than 3 vertices — callers must treat that as "no geofence".
+ ///
+ public static List ParseGeofence(string geofenceJson)
+ {
+ if (string.IsNullOrWhiteSpace(geofenceJson))
+ return null;
+
+ JArray array;
+ try
+ {
+ array = JArray.Parse(geofenceJson);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ var points = new List();
+
+ foreach (var token in array)
+ {
+ if (token.Type != JTokenType.Object)
+ return null;
+
+ var obj = (JObject)token;
+
+ var lat = GetNumber(obj, "lat") ?? GetNumber(obj, "k");
+ var lon = GetNumber(obj, "lng") ?? GetNumber(obj, "A") ?? GetNumber(obj, "a") ?? GetNumber(obj, "lon");
+
+ if (!lat.HasValue || !lon.HasValue || !IsValidCoordinate(lat.Value, lon.Value))
+ return null;
+
+ points.Add(new GeoPoint(lat.Value, lon.Value));
+ }
+
+ if (points.Count < 3)
+ return null;
+
+ return points;
+ }
+
+ ///
+ /// Ray-casting containment test against a polygon's exterior ring. The ring
+ /// does not need to be explicitly closed. Points exactly on an edge may fall
+ /// on either side; station fences are hand-drawn so this is acceptable.
+ ///
+ public static bool IsPointInPolygon(double latitude, double longitude, IReadOnlyList polygon)
+ {
+ if (polygon == null || polygon.Count < 3)
+ return false;
+
+ bool inside = false;
+
+ for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
+ {
+ var pi = polygon[i];
+ var pj = polygon[j];
+
+ bool crossesLatitude = (pi.Latitude > latitude) != (pj.Latitude > latitude);
+
+ if (!crossesLatitude)
+ continue;
+
+ double intersectLongitude = (pj.Longitude - pi.Longitude) * (latitude - pi.Latitude) / (pj.Latitude - pi.Latitude) + pi.Longitude;
+
+ if (longitude < intersectLongitude)
+ inside = !inside;
+ }
+
+ return inside;
+ }
+
+ ///
+ /// Arithmetic-mean centroid of the polygon vertices. Adequate for the small,
+ /// roughly convex fences stations draw; not an area-weighted centroid.
+ ///
+ public static GeoPoint Centroid(IReadOnlyList polygon)
+ {
+ if (polygon == null || polygon.Count == 0)
+ return new GeoPoint(0, 0);
+
+ double latSum = 0, lonSum = 0;
+
+ foreach (var point in polygon)
+ {
+ latSum += point.Latitude;
+ lonSum += point.Longitude;
+ }
+
+ return new GeoPoint(latSum / polygon.Count, lonSum / polygon.Count);
+ }
+
+ ///
+ /// Great-circle distance in meters (haversine, R = 6,371,000 m).
+ ///
+ public static double HaversineMeters(double lat1, double lon1, double lat2, double lon2)
+ {
+ const double earthRadiusMeters = 6371000d;
+
+ double dLat = ToRadians(lat2 - lat1);
+ double dLon = ToRadians(lon2 - lon1);
+
+ double a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
+ Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) *
+ Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
+
+ double c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
+
+ return earthRadiusMeters * c;
+ }
+
+ ///
+ /// Invariant-culture "lat,long" parser (Call.GeoLocationData, ActionLog
+ /// GeoLocationData, DepartmentGroup Latitude/Longitude strings). Returns null
+ /// for missing/unparseable input or a 0,0 fix.
+ ///
+ public static GeoPoint? ParseCoordinatePair(string latitude, string longitude)
+ {
+ if (string.IsNullOrWhiteSpace(latitude) || string.IsNullOrWhiteSpace(longitude))
+ return null;
+
+ if (!double.TryParse(latitude.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var lat))
+ return null;
+
+ if (!double.TryParse(longitude.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
+ return null;
+
+ if (lat == 0 && lon == 0)
+ return null;
+
+ if (!IsValidCoordinate(lat, lon))
+ return null;
+
+ return new GeoPoint(lat, lon);
+ }
+
+ ///
+ /// A coordinate is only usable if it is finite and on the globe. Both parsers
+ /// admit non-finite values otherwise — double.TryParse accepts "NaN"/"Infinity"
+ /// and Json.NET accepts bare NaN/Infinity literals — and a NaN distance sorts
+ /// ahead of every real one, which would make a station with corrupt coordinates
+ /// look like the nearest one to every call.
+ ///
+ private static bool IsValidCoordinate(double latitude, double longitude)
+ {
+ if (double.IsNaN(latitude) || double.IsInfinity(latitude)
+ || double.IsNaN(longitude) || double.IsInfinity(longitude))
+ return false;
+
+ return latitude >= -90d && latitude <= 90d
+ && longitude >= -180d && longitude <= 180d;
+ }
+
+ ///
+ /// Splits a "lat,long" blob (Call.GeoLocationData convention) into a point.
+ ///
+ public static GeoPoint? ParseLatLonString(string geoLocationData)
+ {
+ if (string.IsNullOrWhiteSpace(geoLocationData))
+ return null;
+
+ var parts = geoLocationData.Split(',');
+
+ if (parts.Length != 2)
+ return null;
+
+ return ParseCoordinatePair(parts[0], parts[1]);
+ }
+
+ private static double? GetNumber(JObject obj, string propertyName)
+ {
+ if (!obj.TryGetValue(propertyName, StringComparison.Ordinal, out var token))
+ return null;
+
+ if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
+ return token.Value();
+
+ if (token.Type == JTokenType.String &&
+ double.TryParse(token.Value(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
+ return parsed;
+
+ return null;
+ }
+
+ private static double ToRadians(double degrees)
+ {
+ return degrees * Math.PI / 180d;
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRunCardActivationsRepository.cs b/Core/Resgrid.Model/Repositories/IRunCardActivationsRepository.cs
new file mode 100644
index 000000000..cd43801b5
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRunCardActivationsRepository.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRunCardActivationsRepository : IRepository
+ {
+ Task> GetActivationsByCallIdAsync(int callId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRunCardAlarmLevelsRepository.cs b/Core/Resgrid.Model/Repositories/IRunCardAlarmLevelsRepository.cs
new file mode 100644
index 000000000..5c8b46749
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRunCardAlarmLevelsRepository.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRunCardAlarmLevelsRepository : IRepository
+ {
+ Task> GetAlarmLevelsByRunCardIdAsync(int runCardId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRunCardAvailabilitySelectionsRepository.cs b/Core/Resgrid.Model/Repositories/IRunCardAvailabilitySelectionsRepository.cs
new file mode 100644
index 000000000..0b3138840
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRunCardAvailabilitySelectionsRepository.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRunCardAvailabilitySelectionsRepository : IRepository
+ {
+ Task> GetSelectionsByRunCardIdAsync(int runCardId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRunCardRoleRequirementsRepository.cs b/Core/Resgrid.Model/Repositories/IRunCardRoleRequirementsRepository.cs
new file mode 100644
index 000000000..4211ca25b
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRunCardRoleRequirementsRepository.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRunCardRoleRequirementsRepository : IRepository
+ {
+ Task> GetRoleRequirementsByRunCardIdAsync(int runCardId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRunCardTriggersRepository.cs b/Core/Resgrid.Model/Repositories/IRunCardTriggersRepository.cs
new file mode 100644
index 000000000..6945e8071
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRunCardTriggersRepository.cs
@@ -0,0 +1,12 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRunCardTriggersRepository : IRepository
+ {
+ Task> GetTriggersByRunCardIdAsync(int runCardId);
+
+ Task> GetTriggersByDepartmentIdAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRunCardUnitRequirementsRepository.cs b/Core/Resgrid.Model/Repositories/IRunCardUnitRequirementsRepository.cs
new file mode 100644
index 000000000..8329eca65
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRunCardUnitRequirementsRepository.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRunCardUnitRequirementsRepository : IRepository
+ {
+ Task> GetUnitRequirementsByRunCardIdAsync(int runCardId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IRunCardsRepository.cs b/Core/Resgrid.Model/Repositories/IRunCardsRepository.cs
new file mode 100644
index 000000000..4511f9286
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IRunCardsRepository.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IRunCardsRepository : IRepository
+ {
+ /// Most recent dispatch time per unit for the department (rest-period input).
+ Task> GetLastUnitDispatchTimesByDepartmentAsync(int departmentId);
+
+ /// Most recent dispatch time per user for the department (rest-period input).
+ Task> GetLastUserDispatchTimesByDepartmentAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IStationCoverageRequirementsRepository.cs b/Core/Resgrid.Model/Repositories/IStationCoverageRequirementsRepository.cs
new file mode 100644
index 000000000..403d0eec9
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IStationCoverageRequirementsRepository.cs
@@ -0,0 +1,6 @@
+namespace Resgrid.Model.Repositories
+{
+ public interface IStationCoverageRequirementsRepository : IRepository
+ {
+ }
+}
diff --git a/Core/Resgrid.Model/ResolvedPersonnelLocation.cs b/Core/Resgrid.Model/ResolvedPersonnelLocation.cs
new file mode 100644
index 000000000..7af41a1d5
--- /dev/null
+++ b/Core/Resgrid.Model/ResolvedPersonnelLocation.cs
@@ -0,0 +1,23 @@
+using System;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A person's freshest known position after arbitrating between the
+ /// PersonnelLocation document store and ActionLog coordinates. Personnel-side
+ /// counterpart of ResolvedUnitLocation.
+ ///
+ public sealed class ResolvedPersonnelLocation
+ {
+ public string UserId { get; set; }
+
+ public double Latitude { get; set; }
+
+ public double Longitude { get; set; }
+
+ public DateTime Timestamp { get; set; }
+
+ /// True when the fix is older than the caller's max-age constraint.
+ public bool IsStale { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/RunCard.cs b/Core/Resgrid.Model/RunCard.cs
new file mode 100644
index 000000000..4a076e885
--- /dev/null
+++ b/Core/Resgrid.Model/RunCard.cs
@@ -0,0 +1,97 @@
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A CAD-style run card: a pre-planned response package matched to calls by
+ /// priority/type triggers, defining required unit types and personnel roles per
+ /// alarm level, plus which statuses/staffing levels count as dispatchable.
+ ///
+ [Table("RunCards")]
+ public class RunCard : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int RunCardId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ public virtual Department Department { get; set; }
+
+ [Required]
+ [MaxLength(100)]
+ public string Name { get; set; }
+
+ [MaxLength(500)]
+ public string Description { get; set; }
+
+ public bool IsDisabled { get; set; }
+
+ ///
+ /// Per-card override of DepartmentSettingTypes.DispatchRecommendationMode.
+ /// Null = use department default; otherwise a DispatchRecommendationModes value
+ /// (Off here means "manual only for this card" even when the department automates).
+ ///
+ public int? DispatchModeOverride { get; set; }
+
+ ///
+ /// Per-card override of DepartmentSettingTypes.DispatchRecommendationAutoDispatch.
+ /// Null = department default, 0 = pre-populate only, 1 = auto-dispatch.
+ ///
+ public int? AutoDispatchOverride { get; set; }
+
+ ///
+ /// Per-card override of the department minimum UnitStaffingLevel gate.
+ /// Null = department default, 0 = no gate, otherwise minimum UnitStaffingLevel value.
+ ///
+ public int? MinimumStaffingLevelOverride { get; set; }
+
+ ///
+ /// Station group used to anchor the nearest-station cascade when the call has no
+ /// usable location. Null means the card cannot fill without a call location.
+ ///
+ public int? HomeStationGroupId { get; set; }
+
+ [Required]
+ public DateTime AddedOn { get; set; }
+
+ [Required]
+ public string AddedByUserId { get; set; }
+
+ public DateTime? UpdatedOn { get; set; }
+
+ public string UpdatedByUserId { get; set; }
+
+ public virtual ICollection Triggers { get; set; }
+
+ public virtual ICollection AlarmLevels { get; set; }
+
+ public virtual ICollection AvailabilitySelections { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return RunCardId; }
+ set { RunCardId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "RunCards";
+
+ [NotMapped]
+ public string IdName => "RunCardId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Department", "Triggers", "AlarmLevels", "AvailabilitySelections" };
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardActivation.cs b/Core/Resgrid.Model/RunCardActivation.cs
new file mode 100644
index 000000000..b59dc3377
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardActivation.cs
@@ -0,0 +1,69 @@
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Audit record of one run card activation against a call: which card, alarm level
+ /// and mode ran, whether it auto-dispatched, and the full serialized
+ /// DispatchRecommendationResult (picks, reasons, shortfalls, move-ups) that powers
+ /// the "why were these resources selected?" panel.
+ ///
+ [Table("RunCardActivations")]
+ public class RunCardActivation : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int RunCardActivationId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ [Required]
+ public int CallId { get; set; }
+
+ [Required]
+ public int RunCardId { get; set; }
+
+ [Required]
+ public int AlarmLevel { get; set; }
+
+ /// DispatchRecommendationModes value used for this activation.
+ public int ModeUsed { get; set; }
+
+ public bool WasAutoDispatched { get; set; }
+
+ /// JSON-serialized DispatchRecommendationResult.
+ public string ResultJson { get; set; }
+
+ [Required]
+ public DateTime CreatedOn { get; set; }
+
+ /// Null for automated sources (email import, scheduled dispatch, etc.).
+ public string CreatedByUserId { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return RunCardActivationId; }
+ set { RunCardActivationId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "RunCardActivations";
+
+ [NotMapped]
+ public string IdName => "RunCardActivationId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardAlarmLevel.cs b/Core/Resgrid.Model/RunCardAlarmLevel.cs
new file mode 100644
index 000000000..f35980248
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardAlarmLevel.cs
@@ -0,0 +1,59 @@
+using Newtonsoft.Json;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// One alarm level (1st alarm, 2nd alarm, ...) of a run card. Requirements attached
+ /// to a level are ADDITIVE on top of the levels below it; escalating a call to level
+ /// N dispatches only level N's requirements.
+ ///
+ [Table("RunCardAlarmLevels")]
+ public class RunCardAlarmLevel : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int RunCardAlarmLevelId { get; set; }
+
+ [Required]
+ [ForeignKey("RunCard"), DatabaseGenerated(DatabaseGeneratedOption.None)]
+ public int RunCardId { get; set; }
+
+ public virtual RunCard RunCard { get; set; }
+
+ /// 1-based alarm level number.
+ [Required]
+ public int AlarmLevel { get; set; }
+
+ /// Optional display name, e.g. "Working Fire".
+ [MaxLength(100)]
+ public string Name { get; set; }
+
+ public virtual ICollection UnitRequirements { get; set; }
+
+ public virtual ICollection RoleRequirements { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return RunCardAlarmLevelId; }
+ set { RunCardAlarmLevelId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "RunCardAlarmLevels";
+
+ [NotMapped]
+ public string IdName => "RunCardAlarmLevelId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "RunCard", "UnitRequirements", "RoleRequirements" };
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardAvailabilitySelection.cs b/Core/Resgrid.Model/RunCardAvailabilitySelection.cs
new file mode 100644
index 000000000..382bf945f
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardAvailabilitySelection.cs
@@ -0,0 +1,66 @@
+using Newtonsoft.Json;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Marks a unit status, personnel status, or staffing level as "dispatchable" for a
+ /// run card. Cards with no selections of a given kind fall back to the
+ /// AvailabilityMatrix Available classification for that kind.
+ ///
+ [Table("RunCardAvailabilitySelections")]
+ public class RunCardAvailabilitySelection : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int RunCardAvailabilitySelectionId { get; set; }
+
+ [Required]
+ [ForeignKey("RunCard"), DatabaseGenerated(DatabaseGeneratedOption.None)]
+ public int RunCardId { get; set; }
+
+ public virtual RunCard RunCard { get; set; }
+
+ /// RunCardSelectionTypes value.
+ [Required]
+ public int SelectionType { get; set; }
+
+ ///
+ /// Only for SelectionType = UnitStatus: scopes the selection to one unit type's
+ /// custom status set. Null = applies to all unit types without a scoped row.
+ ///
+ public int? UnitTypeId { get; set; }
+
+ ///
+ /// True when StateId is a CustomStateDetailId; false when it is a built-in
+ /// UnitStateTypes / ActionTypes / UserStateTypes value.
+ ///
+ public bool IsCustomState { get; set; }
+
+ [Required]
+ public int StateId { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return RunCardAvailabilitySelectionId; }
+ set { RunCardAvailabilitySelectionId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "RunCardAvailabilitySelections";
+
+ [NotMapped]
+ public string IdName => "RunCardAvailabilitySelectionId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "RunCard" };
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardRoleRequirement.cs b/Core/Resgrid.Model/RunCardRoleRequirement.cs
new file mode 100644
index 000000000..d943fd6ac
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardRoleRequirement.cs
@@ -0,0 +1,53 @@
+using Newtonsoft.Json;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// "This alarm level needs N people holding this personnel role" (e.g. 4x Firefighter).
+ ///
+ [Table("RunCardRoleRequirements")]
+ public class RunCardRoleRequirement : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int RunCardRoleRequirementId { get; set; }
+
+ [Required]
+ [ForeignKey("AlarmLevel"), DatabaseGenerated(DatabaseGeneratedOption.None)]
+ public int RunCardAlarmLevelId { get; set; }
+
+ public virtual RunCardAlarmLevel AlarmLevel { get; set; }
+
+ [Required]
+ public int PersonnelRoleId { get; set; }
+
+ [Required]
+ public int RequiredCount { get; set; }
+
+ public int SortOrder { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return RunCardRoleRequirementId; }
+ set { RunCardRoleRequirementId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "RunCardRoleRequirements";
+
+ [NotMapped]
+ public string IdName => "RunCardRoleRequirementId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "AlarmLevel" };
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardSelectionTypes.cs b/Core/Resgrid.Model/RunCardSelectionTypes.cs
new file mode 100644
index 000000000..21e14f6ca
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardSelectionTypes.cs
@@ -0,0 +1,13 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Discriminator for RunCardAvailabilitySelection rows. Mirrors CustomStateTypes
+ /// (Personnel=1, Unit=2, Staffing=3) but ordered by how the engine consumes them.
+ ///
+ public enum RunCardSelectionTypes
+ {
+ UnitStatus = 1,
+ PersonnelStatus = 2,
+ PersonnelStaffing = 3
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardTrigger.cs b/Core/Resgrid.Model/RunCardTrigger.cs
new file mode 100644
index 000000000..e0d72cc1b
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardTrigger.cs
@@ -0,0 +1,60 @@
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A single match condition for a run card. Multiple triggers on one card are OR'd
+ /// together. Priority follows the call priority convention (0-3 = system CallPriority,
+ /// above 3 = DepartmentCallPriorityId); CallTypeId is a real FK to CallTypes and is
+ /// resolved from Call.Type's name at match time.
+ ///
+ [Table("RunCardTriggers")]
+ public class RunCardTrigger : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int RunCardTriggerId { get; set; }
+
+ [Required]
+ [ForeignKey("RunCard"), DatabaseGenerated(DatabaseGeneratedOption.None)]
+ public int RunCardId { get; set; }
+
+ public virtual RunCard RunCard { get; set; }
+
+ /// RunCardTriggerTypes value.
+ public int TriggerType { get; set; }
+
+ public int? Priority { get; set; }
+
+ public int? CallTypeId { get; set; }
+
+ public DateTime? StartsOn { get; set; }
+
+ public DateTime? EndsOn { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return RunCardTriggerId; }
+ set { RunCardTriggerId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "RunCardTriggers";
+
+ [NotMapped]
+ public string IdName => "RunCardTriggerId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "RunCard" };
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardTriggerTypes.cs b/Core/Resgrid.Model/RunCardTriggerTypes.cs
new file mode 100644
index 000000000..0cfdb9746
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardTriggerTypes.cs
@@ -0,0 +1,13 @@
+namespace Resgrid.Model
+{
+ ///
+ /// What a run card trigger matches against. Mirrors ProtocolTriggerTypes semantics
+ /// but run card triggers reference CallTypeId (a real FK) instead of the type name.
+ ///
+ public enum RunCardTriggerTypes
+ {
+ CallPriority = 0,
+ CallType = 1,
+ CallPriorityAndType = 2
+ }
+}
diff --git a/Core/Resgrid.Model/RunCardUnitRequirement.cs b/Core/Resgrid.Model/RunCardUnitRequirement.cs
new file mode 100644
index 000000000..ec0db1d25
--- /dev/null
+++ b/Core/Resgrid.Model/RunCardUnitRequirement.cs
@@ -0,0 +1,53 @@
+using Newtonsoft.Json;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// "This alarm level needs N units of this type" (e.g. 2x Engine).
+ ///
+ [Table("RunCardUnitRequirements")]
+ public class RunCardUnitRequirement : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int RunCardUnitRequirementId { get; set; }
+
+ [Required]
+ [ForeignKey("AlarmLevel"), DatabaseGenerated(DatabaseGeneratedOption.None)]
+ public int RunCardAlarmLevelId { get; set; }
+
+ public virtual RunCardAlarmLevel AlarmLevel { get; set; }
+
+ [Required]
+ public int UnitTypeId { get; set; }
+
+ [Required]
+ public int RequiredCount { get; set; }
+
+ public int SortOrder { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return RunCardUnitRequirementId; }
+ set { RunCardUnitRequirementId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "RunCardUnitRequirements";
+
+ [NotMapped]
+ public string IdName => "RunCardUnitRequirementId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "AlarmLevel" };
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
index c216563f0..12feda8f3 100644
--- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
+++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
@@ -294,6 +294,21 @@ Task SetUnitCallStatusOverridesByUnitTypeAsync(int department
Task GetPersonnelOnUnitSetUnitStatusAsync(int departmentId, bool bypassCache = false);
+ /// Department-wide dispatch recommendation mode (Off / StationBased / ClosestUnit). Cached.
+ Task GetDispatchRecommendationModeAsync(int departmentId, bool bypassCache = false);
+
+ Task SetDispatchRecommendationModeAsync(int departmentId, DispatchRecommendationModes mode, CancellationToken cancellationToken = default(CancellationToken));
+
+ /// True = matched run cards auto-dispatch; false = recommendations pre-populate for dispatcher review. Cached.
+ Task GetDispatchRecommendationAutoDispatchAsync(int departmentId, bool bypassCache = false);
+
+ Task SetDispatchRecommendationAutoDispatchAsync(int departmentId, bool enabled, CancellationToken cancellationToken = default(CancellationToken));
+
+ /// Engine tuning (location age/radius, ETA re-rank, rest period, staffing gate, move-up). Never null. Cached.
+ Task GetDispatchRecommendationConfigAsync(int departmentId, bool bypassCache = false);
+
+ Task SetDispatchRecommendationConfigAsync(int departmentId, DispatchRecommendationConfig config, CancellationToken cancellationToken = default(CancellationToken));
+
Task SetDepartmentModuleSettingsAsync(int departmentId, DepartmentModuleSettings settings, CancellationToken cancellationToken = default(CancellationToken));
///
diff --git a/Core/Resgrid.Model/Services/IDispatchRecommendationService.cs b/Core/Resgrid.Model/Services/IDispatchRecommendationService.cs
new file mode 100644
index 000000000..fb55e3e40
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IDispatchRecommendationService.cs
@@ -0,0 +1,42 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// The run card recommendation engine: matches a run card to a call, builds the
+ /// dispatchable candidate pool (availability selections, staffing gate, rest
+ /// period) and fills the card's requirements via the department's dispatch mode
+ /// (station geofence cascade or closest unit). The single seam used by the web
+ /// controllers, the v4 API and every automated call source — dispatch selection
+ /// logic must not be duplicated outside it.
+ ///
+ public interface IDispatchRecommendationService
+ {
+ ///
+ /// Computes recommendations without touching a call. MatchedRunCardId == null
+ /// means no card applies and the caller should change nothing.
+ ///
+ Task GetRecommendationAsync(DispatchRecommendationRequest request, CancellationToken cancellationToken = default(CancellationToken));
+
+ ///
+ /// Computes recommendations for the call's priority/type/location and adds the
+ /// recommended units/personnel to the call's dispatch collections (additive —
+ /// existing dispatches are never removed or duplicated). Sets ActiveRunCardId
+ /// and advances AlarmLevel when a card matched. The caller is responsible for
+ /// saving the call and broadcasting. When
+ /// is true the dispatch collections are only mutated if the resolved
+ /// auto-dispatch decision (department default + card override) is on — use this
+ /// from call-creation sites; explicit escalation passes false to always apply.
+ ///
+ Task EnrichCallForDispatchAsync(Call call, int targetAlarmLevel, bool onlyWhenAutoDispatch = false, CancellationToken cancellationToken = default(CancellationToken));
+
+ ///
+ /// Persists the RunCardActivation audit row and raises the workflow events
+ /// (RunCardActivated, DispatchShortfallDetected, StationCoverageGapDetected)
+ /// for an applied recommendation. Call AFTER the call is saved so CallId is
+ /// assigned. No-op when the result matched no card.
+ ///
+ Task RecordActivationAsync(Call call, DispatchRecommendationResult result, string createdByUserId, CancellationToken cancellationToken = default(CancellationToken));
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IGeoService.cs b/Core/Resgrid.Model/Services/IGeoService.cs
index 063b9948f..ff40ab981 100644
--- a/Core/Resgrid.Model/Services/IGeoService.cs
+++ b/Core/Resgrid.Model/Services/IGeoService.cs
@@ -1,4 +1,5 @@
-using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Threading.Tasks;
namespace Resgrid.Model.Services
{
@@ -21,5 +22,25 @@ public interface IGeoService
/// The destination.
/// Task<System.Double>.
Task GetEtaInSecondsAsync(string start, string destination);
+
+ ///
+ /// Best-effort coordinates for a station group: its stored Latitude/Longitude
+ /// first, then the centroid of its geofence polygon, then the geocoded group
+ /// address (GetMapCenterCoordinatesForGroupAsync fallback chain). Null when
+ /// nothing usable exists.
+ ///
+ Task GetStationCoordinatesAsync(DepartmentGroup group);
+
+ ///
+ /// Station groups whose geofence polygon contains the point, nearest first.
+ /// Stations without a parseable geofence are skipped.
+ ///
+ Task> GetStationsContainingPointAsync(int departmentId, double latitude, double longitude);
+
+ ///
+ /// All station groups with resolvable coordinates ordered by straight-line
+ /// distance to the point (nearest first), with geofence containment flagged.
+ ///
+ Task> OrderStationsByDistanceAsync(int departmentId, double latitude, double longitude);
}
}
diff --git a/Core/Resgrid.Model/Services/IPersonnelLocationResolver.cs b/Core/Resgrid.Model/Services/IPersonnelLocationResolver.cs
new file mode 100644
index 000000000..c649e6efa
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IPersonnelLocationResolver.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Resolves the freshest usable location per person for a department, merging the
+ /// PersonnelLocation document store with ActionLog coordinate fallbacks. Fills the
+ /// personnel-side gap next to IUnitLocationSourceResolver.
+ ///
+ public interface IPersonnelLocationResolver
+ {
+ ///
+ /// Latest location per user. Fixes older than
+ /// are returned flagged stale (never silently dropped — the caller decides);
+ /// 0 = no age limit. Users with no usable fix are absent from the result.
+ ///
+ Task> GetLatestLocationsAsync(int departmentId, int maxAgeSeconds, DateTime? utcNow = null);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IRunCardsService.cs b/Core/Resgrid.Model/Services/IRunCardsService.cs
new file mode 100644
index 000000000..11ff34e7f
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IRunCardsService.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// CRUD and trigger matching for run cards (CAD-style response plans) and station
+ /// coverage requirements. Recommendation/selection logic lives in
+ /// IDispatchRecommendationService; this service owns persistence and "which card
+ /// applies to this call" resolution.
+ ///
+ public interface IRunCardsService
+ {
+ /// All run cards for the department, fully hydrated (triggers, alarm levels with requirements, selections). Cached.
+ Task> GetAllRunCardsForDepartmentAsync(int departmentId, bool bypassCache = false);
+
+ /// One run card, fully hydrated. Null when not found.
+ Task GetRunCardByIdAsync(int runCardId);
+
+ ///
+ /// Saves the card header and replaces its child rows (triggers, alarm levels,
+ /// requirements, selections) to match the supplied graph. Invalidates the cache.
+ ///
+ Task SaveRunCardAsync(RunCard runCard, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task DeleteRunCardAsync(int runCardId, CancellationToken cancellationToken = default(CancellationToken));
+
+ ///
+ /// Best-matching enabled run card for a call's priority/type, or null. Specificity
+ /// wins (PriorityAndType over Type over Priority); ties break to the newest card.
+ /// The call type is matched by name (trimmed, case-insensitive) per the Call.Type
+ /// convention.
+ ///
+ Task GetMatchingRunCardAsync(int departmentId, int priority, string callTypeName);
+
+ Task> GetStationCoverageRequirementsForDepartmentAsync(int departmentId);
+
+ Task SaveStationCoverageRequirementAsync(StationCoverageRequirement requirement, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task DeleteStationCoverageRequirementAsync(int stationCoverageRequirementId, int departmentId, CancellationToken cancellationToken = default(CancellationToken));
+
+ /// Most recent dispatch time per unit (rest-period input).
+ Task> GetLastUnitDispatchTimesAsync(int departmentId);
+
+ /// Most recent dispatch time per user (rest-period input).
+ Task> GetLastUserDispatchTimesAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/StationCoverageRequirement.cs b/Core/Resgrid.Model/StationCoverageRequirement.cs
new file mode 100644
index 000000000..4ebe1460b
--- /dev/null
+++ b/Core/Resgrid.Model/StationCoverageRequirement.cs
@@ -0,0 +1,65 @@
+using Newtonsoft.Json;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Minimum coverage a station should retain: at least MinimumAvailableCount of a
+ /// unit type OR a personnel role available at/near the station. Exactly one of
+ /// UnitTypeId / PersonnelRoleId is set. When the recommendation engine's move-up
+ /// pass finds coverage below the minimum it emits move-up/backfill recommendations.
+ ///
+ [Table("StationCoverageRequirements")]
+ public class StationCoverageRequirement : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int StationCoverageRequirementId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ [Required]
+ public int DepartmentGroupId { get; set; }
+
+ public virtual DepartmentGroup StationGroup { get; set; }
+
+ public int? UnitTypeId { get; set; }
+
+ public int? PersonnelRoleId { get; set; }
+
+ [Required]
+ public int MinimumAvailableCount { get; set; }
+
+ ///
+ /// ClosestUnit mode: availability is measured within this radius of the station's
+ /// coordinates. Null = measure by station assignment/geofence (StationBased semantics).
+ ///
+ public int? RadiusMeters { get; set; }
+
+ public bool IsEnabled { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return StationCoverageRequirementId; }
+ set { StationCoverageRequirementId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "StationCoverageRequirements";
+
+ [NotMapped]
+ public string IdName => "StationCoverageRequirementId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "StationGroup" };
+ }
+}
diff --git a/Core/Resgrid.Model/StationDistanceResult.cs b/Core/Resgrid.Model/StationDistanceResult.cs
new file mode 100644
index 000000000..e645eeb9a
--- /dev/null
+++ b/Core/Resgrid.Model/StationDistanceResult.cs
@@ -0,0 +1,26 @@
+namespace Resgrid.Model
+{
+ ///
+ /// A station group resolved against a reference point (usually a call location):
+ /// its usable coordinates, straight-line distance and whether its geofence
+ /// contains the point. Produced by IGeoService station helpers for the dispatch
+ /// recommendation engine.
+ ///
+ public class StationDistanceResult
+ {
+ public DepartmentGroup Station { get; set; }
+
+ public double Latitude { get; set; }
+
+ public double Longitude { get; set; }
+
+ /// Straight-line meters from the reference point to the station's coordinates.
+ public double DistanceMeters { get; set; }
+
+ /// True when the station's geofence polygon contains the reference point.
+ public bool ContainsPoint { get; set; }
+
+ /// True when the station has a parseable geofence polygon.
+ public bool HasGeofence { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/UnitLastDispatchTime.cs b/Core/Resgrid.Model/UnitLastDispatchTime.cs
new file mode 100644
index 000000000..995db39be
--- /dev/null
+++ b/Core/Resgrid.Model/UnitLastDispatchTime.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Projection row: the most recent DispatchedOn for a unit across all calls in a
+ /// department. Feeds the rest-period deprioritization in dispatch recommendations.
+ ///
+ public class UnitLastDispatchTime
+ {
+ public int UnitId { get; set; }
+
+ public DateTime LastDispatchedOn { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/UserLastDispatchTime.cs b/Core/Resgrid.Model/UserLastDispatchTime.cs
new file mode 100644
index 000000000..1b41384fe
--- /dev/null
+++ b/Core/Resgrid.Model/UserLastDispatchTime.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Projection row: the most recent DispatchedOn for a user across all calls in a
+ /// department. Feeds the rest-period deprioritization in dispatch recommendations.
+ ///
+ public class UserLastDispatchTime
+ {
+ public string UserId { get; set; }
+
+ public DateTime LastDispatchedOn { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
index 8a3c843c4..b0a76e58e 100644
--- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
+++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs
@@ -510,6 +510,51 @@ public static IReadOnlyList GetVariableCatalog(Workf
new TemplateVariableDescriptor("group.address.country", "Country", "string", false),
});
break;
+
+ case WorkflowTriggerEventType.RunCardActivated:
+ list.AddRange(new[]
+ {
+ new TemplateVariableDescriptor("run_card.call_id", "Call ID", "int", false),
+ new TemplateVariableDescriptor("run_card.run_card_id", "Run card ID", "int", false),
+ new TemplateVariableDescriptor("run_card.run_card_name", "Run card name", "string", false),
+ new TemplateVariableDescriptor("run_card.alarm_level", "Alarm level", "int", false),
+ new TemplateVariableDescriptor("run_card.mode", "Dispatch mode used (1 = station based, 2 = closest unit)", "int", false),
+ new TemplateVariableDescriptor("run_card.was_auto_dispatched", "True when resources were auto-dispatched", "bool", false),
+ new TemplateVariableDescriptor("run_card.unit_count", "Number of units recommended", "int", false),
+ new TemplateVariableDescriptor("run_card.personnel_count", "Number of personnel recommended", "int", false),
+ });
+ break;
+
+ case WorkflowTriggerEventType.CallAlarmEscalated:
+ list.AddRange(new[]
+ {
+ new TemplateVariableDescriptor("escalation.call_id", "Call ID", "int", false),
+ new TemplateVariableDescriptor("escalation.previous_alarm_level", "Alarm level before escalation", "int", false),
+ new TemplateVariableDescriptor("escalation.new_alarm_level", "Alarm level after escalation", "int", false),
+ new TemplateVariableDescriptor("escalation.added_unit_count", "Units added by the escalation", "int", false),
+ new TemplateVariableDescriptor("escalation.added_personnel_count", "Personnel added by the escalation", "int", false),
+ });
+ break;
+
+ case WorkflowTriggerEventType.DispatchShortfallDetected:
+ list.AddRange(new[]
+ {
+ new TemplateVariableDescriptor("shortfall.call_id", "Call ID", "int", false),
+ new TemplateVariableDescriptor("shortfall.run_card_id", "Run card ID", "int", false),
+ new TemplateVariableDescriptor("shortfall.alarm_level", "Alarm level", "int", false),
+ new TemplateVariableDescriptor("shortfall.shortfall_count", "Number of unfilled requirements", "int", false),
+ new TemplateVariableDescriptor("shortfall.summary", "Human-readable shortfall summary", "string", false),
+ });
+ break;
+
+ case WorkflowTriggerEventType.StationCoverageGapDetected:
+ list.AddRange(new[]
+ {
+ new TemplateVariableDescriptor("coverage_gap.call_id", "Call ID that triggered the gap (0 when none)", "int", false),
+ new TemplateVariableDescriptor("coverage_gap.gap_count", "Number of stations below minimum coverage", "int", false),
+ new TemplateVariableDescriptor("coverage_gap.summary", "Human-readable coverage gap summary", "string", false),
+ });
+ break;
}
return list.AsReadOnly();
diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
index 386adde49..25b338471 100644
--- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs
+++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs
@@ -51,7 +51,13 @@ public enum WorkflowTriggerEventType
IncidentActionPlanUpdated = 44,
IncidentCommandPostUpdated = 45,
IncidentPublicSharingEnabled = 46,
- IncidentPublicSharingDisabled = 47
+ IncidentPublicSharingDisabled = 47,
+
+ // Run card dispatch system
+ RunCardActivated = 48,
+ CallAlarmEscalated = 49,
+ DispatchShortfallDetected = 50,
+ StationCoverageGapDetected = 51
}
}
diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs
index 0b6a78503..3320bfb7f 100644
--- a/Core/Resgrid.Services/ChatChannelService.cs
+++ b/Core/Resgrid.Services/ChatChannelService.cs
@@ -237,6 +237,11 @@ async Task> getChannels()
if (string.IsNullOrWhiteSpace(targetUserId) && !targetUnitId.HasValue)
return null;
+ // A DM with yourself would put the same user in the member list twice and violate
+ // the unique (ChatChannelId, UserId) member index; the clients never offer it.
+ if (!targetUnitId.HasValue && string.Equals(creatorUserId, targetUserId, StringComparison.OrdinalIgnoreCase))
+ return null;
+
var dmKey = BuildDmKey(creatorUserId, targetUserId, targetUnitId);
var existing = await _chatChannelRepository.GetByDmKeyAsync(departmentId, dmKey);
@@ -681,14 +686,14 @@ public async Task GetUserMembershipAsync(string chatChannelId
public async Task EnsureIncidentChannelAsync(int departmentId, int callId, string callName, CancellationToken cancellationToken = default(CancellationToken))
{
- var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.Incident);
- if (existing != null)
- return existing;
-
// Callers without the call in hand (the backfill) pass no name; resolve it here so healed
// channels get the real call name instead of the "Call {id}" fallback.
var name = !string.IsNullOrWhiteSpace(callName) ? callName.Trim() : await ResolveIncidentPrefixAsync(callId, null);
+ var existing = await _chatChannelRepository.GetByCallIdAndTypeAsync(callId, (int)ChatChannelType.Incident);
+ if (existing != null)
+ return await ApplyProvisionedNameAsync(existing, name, cancellationToken);
+
return await InsertProvisionedChannelAsync(new ChatChannel
{
ChatChannelId = Guid.NewGuid().ToString(),
@@ -1218,7 +1223,8 @@ private static string BuildDmKey(string creatorUserId, string targetUserId, int?
///
/// The incident prefix every incident-scoped channel name starts with: the incident's own name when
- /// command gave it one, otherwise the call's name, otherwise the call id.
+ /// command gave it one, otherwise the call's number-prefixed name ("26-45 Structure Fire"), otherwise
+ /// the call id. The bare "Call {id}" form is a last resort — it names nothing a responder recognizes.
///
private async Task ResolveIncidentPrefixAsync(int callId, string incidentName)
{
@@ -1228,8 +1234,10 @@ private async Task ResolveIncidentPrefixAsync(int callId, string inciden
try
{
var call = await _callsService.GetCallByIdAsync(callId);
- if (!string.IsNullOrWhiteSpace(call?.Name))
- return call.Name.Trim();
+
+ var displayName = call?.GetDisplayName();
+ if (!string.IsNullOrWhiteSpace(displayName))
+ return displayName;
}
catch (Exception ex)
{
diff --git a/Core/Resgrid.Services/ChatProvisioningEventService.cs b/Core/Resgrid.Services/ChatProvisioningEventService.cs
index a6e05c197..bc916ab2e 100644
--- a/Core/Resgrid.Services/ChatProvisioningEventService.cs
+++ b/Core/Resgrid.Services/ChatProvisioningEventService.cs
@@ -32,6 +32,7 @@ public ChatProvisioningEventService(IEventAggregator eventAggregator, ILifetimeS
_lifetimeScope = lifetimeScope;
_eventAggregator.AddAsyncListener(OnCallAddedAsync);
+ _eventAggregator.AddAsyncListener(OnCallUpdatedAsync);
_eventAggregator.AddAsyncListener(OnCallClosedAsync);
_eventAggregator.AddAsyncListener(OnCommandEstablishedAsync);
_eventAggregator.AddAsyncListener(OnIncidentClosedAsync);
@@ -45,7 +46,26 @@ private Task OnCallAddedAsync(CallAddedEvent message)
return Task.CompletedTask;
return RunAsync(scope => scope.Resolve()
- .EnsureIncidentChannelAsync(message.Call.DepartmentId, message.Call.CallId, message.Call.Name));
+ .EnsureIncidentChannelAsync(message.Call.DepartmentId, message.Call.CallId, message.Call.GetDisplayName()));
+ }
+
+ ///
+ /// The incident channel's name IS the call's display name, and every other incident-scoped channel
+ /// takes its prefix from that channel — so an edited call name or number has to flow back through.
+ /// Ensure is idempotent and renames in place, so this is a no-op when nothing naming-related changed.
+ ///
+ private Task OnCallUpdatedAsync(CallUpdatedEvent message)
+ {
+ if (message?.Call == null || message.Call.CallId <= 0)
+ return Task.CompletedTask;
+
+ // Ensure creates when the channel is missing, so a closed or deleted call would gain a live
+ // channel nobody asked for. Only running calls get provisioned (or renamed) here.
+ if (message.Call.IsDeleted || message.Call.State != (int)CallStates.Active)
+ return Task.CompletedTask;
+
+ return RunAsync(scope => scope.Resolve()
+ .EnsureIncidentChannelAsync(message.Call.DepartmentId, message.Call.CallId, message.Call.GetDisplayName()));
}
private Task OnCallClosedAsync(CallClosedEvent message)
diff --git a/Core/Resgrid.Services/DepartmentGroupsService.cs b/Core/Resgrid.Services/DepartmentGroupsService.cs
index 19bde0f3c..7bc52a739 100644
--- a/Core/Resgrid.Services/DepartmentGroupsService.cs
+++ b/Core/Resgrid.Services/DepartmentGroupsService.cs
@@ -77,9 +77,12 @@ public async Task> GetAllAsync()
DepartmentGroup saved;
try
{
- saved = await _departmentGroupsRepository.SaveOrUpdateAsync(departmentGroup, cancellationToken);
+ // firstLevelOnly: the reflection cascade must not run for DepartmentGroup — its
+ // self-referencing Children collection makes SyncChildArrayUpdates emit
+ // DELETE FROM DepartmentGroups WHERE DepartmentGroupId = @id, deleting the group
+ // being saved. Members are saved explicitly below.
+ saved = await _departmentGroupsRepository.SaveOrUpdateAsync(departmentGroup, cancellationToken, true);
- // Members is in IgnoredProperties so the ORM cascade skips it — save each member explicitly.
if (departmentGroup.Members != null && departmentGroup.Members.Any())
{
foreach (var member in departmentGroup.Members)
diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs
index 83aa5601c..5d1fbcf88 100644
--- a/Core/Resgrid.Services/DepartmentSettingsService.cs
+++ b/Core/Resgrid.Services/DepartmentSettingsService.cs
@@ -27,6 +27,9 @@ public class DepartmentSettingsService : IDepartmentSettingsService
private static string HardwareTrackingStaleAfterSecondsCacheKey = "DSetHardwareTrackingStale_{0}";
private static string HardwareTrackingMobileFallbackCacheKey = "DSetHardwareTrackingFallback_{0}";
private static string HardwareTrackingRetentionDaysCacheKey = "DSetHardwareTrackingRetention_{0}";
+ private static string DispatchRecommendationModeCacheKey = "DSetDispatchRecMode_{0}";
+ private static string DispatchRecommendationAutoDispatchCacheKey = "DSetDispatchRecAuto_{0}";
+ private static string DispatchRecommendationConfigCacheKey = "DSetDispatchRecConfig_{0}";
private static TimeSpan LongCacheLength = TimeSpan.FromDays(14);
private static TimeSpan ThatsNotLongThisIsLongCacheLength = TimeSpan.FromDays(365);
private static TimeSpan TwoYearCacheLength = TimeSpan.FromDays(730);
@@ -48,7 +51,8 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting
public async Task SaveOrUpdateSettingAsync(int departmentId, string setting, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken))
{
var savedSetting = await GetSettingByDepartmentIdType(departmentId, type);
- await InvalidateSettingCacheAsync(departmentId, type);
+
+ DepartmentSetting result;
if (savedSetting == null)
{
@@ -57,15 +61,21 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting
newSetting.Setting = setting;
newSetting.SettingType = (int)type;
- return await _departmentSettingsRepository.SaveOrUpdateAsync(newSetting, cancellationToken);
+ result = await _departmentSettingsRepository.SaveOrUpdateAsync(newSetting, cancellationToken);
}
else
{
savedSetting.Setting = setting;
- return await _departmentSettingsRepository.SaveOrUpdateAsync(savedSetting, cancellationToken);
+ result = await _departmentSettingsRepository.SaveOrUpdateAsync(savedSetting, cancellationToken);
}
- return null;
+ // Invalidate after the write commits, never before: dropping the key first lets a
+ // concurrent reader miss, re-read the pre-write value from the database and store
+ // it again, where it then survives for the full cache TTL. A throwing write skips
+ // this and leaves the still-correct cached value in place. Mirrors DeleteSettingAsync.
+ await InvalidateSettingCacheAsync(departmentId, type);
+
+ return result;
}
public async Task DeleteSettingAsync(int departmentId, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken))
@@ -837,6 +847,125 @@ public async Task SetUnitCallStatusOverridesByUnitTypeAsync(i
DepartmentSettingTypes.UnitCallStatusOverridesByUnitType, cancellationToken);
}
+ public async Task GetDispatchRecommendationModeAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getSetting()
+ {
+ var s = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.DispatchRecommendationMode);
+ return s?.Setting ?? ((int)DispatchRecommendationModes.Off).ToString();
+ }
+
+ string value;
+ if (Config.SystemBehaviorConfig.CacheEnabled && !bypassCache)
+ value = await _cacheProvider.RetrieveAsync(string.Format(DispatchRecommendationModeCacheKey, departmentId), getSetting, LongCacheLength);
+ else
+ value = await getSetting();
+
+ if (int.TryParse(value, out var mode) && Enum.IsDefined(typeof(DispatchRecommendationModes), mode))
+ return (DispatchRecommendationModes)mode;
+
+ return DispatchRecommendationModes.Off;
+ }
+
+ public async Task SetDispatchRecommendationModeAsync(int departmentId, DispatchRecommendationModes mode, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ return await SaveOrUpdateSettingAsync(departmentId, ((int)mode).ToString(), DepartmentSettingTypes.DispatchRecommendationMode, cancellationToken);
+ }
+
+ public async Task GetDispatchRecommendationAutoDispatchAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getSetting()
+ {
+ var s = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.DispatchRecommendationAutoDispatch);
+ return s?.Setting ?? "false";
+ }
+
+ string value;
+ if (Config.SystemBehaviorConfig.CacheEnabled && !bypassCache)
+ value = await _cacheProvider.RetrieveAsync(string.Format(DispatchRecommendationAutoDispatchCacheKey, departmentId), getSetting, LongCacheLength);
+ else
+ value = await getSetting();
+
+ return bool.TryParse(value, out var enabled) && enabled;
+ }
+
+ public async Task SetDispatchRecommendationAutoDispatchAsync(int departmentId, bool enabled, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ return await SaveOrUpdateSettingAsync(departmentId, enabled.ToString(), DepartmentSettingTypes.DispatchRecommendationAutoDispatch, cancellationToken);
+ }
+
+ public async Task GetDispatchRecommendationConfigAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getSetting()
+ {
+ var s = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.DispatchRecommendationConfig);
+ return s?.Setting ?? string.Empty;
+ }
+
+ string value;
+ if (Config.SystemBehaviorConfig.CacheEnabled && !bypassCache)
+ value = await _cacheProvider.RetrieveAsync(string.Format(DispatchRecommendationConfigCacheKey, departmentId), getSetting, LongCacheLength);
+ else
+ value = await getSetting();
+
+ if (!String.IsNullOrWhiteSpace(value))
+ {
+ try
+ {
+ var config = ObjectSerialization.Deserialize(value);
+
+ if (config != null)
+ return ClampDispatchRecommendationConfig(config);
+ }
+ catch (Exception)
+ {
+ // A corrupt setting blob falls back to defaults rather than breaking dispatch.
+ }
+ }
+
+ return new DispatchRecommendationConfig();
+ }
+
+ ///
+ /// Bounds the tuning values on the way out, the way retention days are bounded in
+ /// GetHardwareTrackingLocationRetentionDaysAsync. Clamping on read rather than on
+ /// save also covers values already stored and any writer other than the settings
+ /// page. Zero keeps its "no limit" meaning for the age and radius knobs.
+ ///
+ private static DispatchRecommendationConfig ClampDispatchRecommendationConfig(DispatchRecommendationConfig config)
+ {
+ config.MaxLocationAgeSeconds = ClampToRange(config.MaxLocationAgeSeconds, DispatchRecommendationConfig.MaximumLocationAgeSeconds);
+ config.PersonnelMaxLocationAgeSeconds = ClampToRange(config.PersonnelMaxLocationAgeSeconds, DispatchRecommendationConfig.MaximumLocationAgeSeconds);
+ config.MaxRadiusMeters = ClampToRange(config.MaxRadiusMeters, DispatchRecommendationConfig.MaximumRadiusMeters);
+ config.RestPeriodMinutes = ClampToRange(config.RestPeriodMinutes, DispatchRecommendationConfig.MaximumRestPeriodMinutes);
+
+ config.EtaShortlistSize = config.EtaShortlistSize > 0
+ ? Math.Min(config.EtaShortlistSize, DispatchRecommendationConfig.MaximumEtaShortlistSize)
+ : DispatchRecommendationConfig.DefaultEtaShortlistSize;
+
+ if (config.UnitMinimumStaffingLevel < 0)
+ config.UnitMinimumStaffingLevel = 0;
+
+ return config;
+ }
+
+ private static int ClampToRange(int value, int maximum)
+ {
+ if (value <= 0)
+ return 0;
+
+ return Math.Min(value, maximum);
+ }
+
+ public async Task SetDispatchRecommendationConfigAsync(int departmentId, DispatchRecommendationConfig config, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (config == null)
+ config = new DispatchRecommendationConfig();
+
+ return await SaveOrUpdateSettingAsync(departmentId, ObjectSerialization.Serialize(config),
+ DepartmentSettingTypes.DispatchRecommendationConfig, cancellationToken);
+ }
+
public async Task GetPersonnelOnUnitSetUnitStatusAsync(int departmentId, bool bypassCache = false)
{
async Task getSetting()
@@ -1059,6 +1188,15 @@ private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSetti
case DepartmentSettingTypes.HardwareTrackingLocationRetentionDays:
cacheKey = string.Format(HardwareTrackingRetentionDaysCacheKey, departmentId);
break;
+ case DepartmentSettingTypes.DispatchRecommendationMode:
+ cacheKey = string.Format(DispatchRecommendationModeCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.DispatchRecommendationAutoDispatch:
+ cacheKey = string.Format(DispatchRecommendationAutoDispatchCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.DispatchRecommendationConfig:
+ cacheKey = string.Format(DispatchRecommendationConfigCacheKey, departmentId);
+ break;
}
if (!string.IsNullOrWhiteSpace(cacheKey))
diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs
index e9b97d678..e2fc7220d 100644
--- a/Core/Resgrid.Services/DepartmentsService.cs
+++ b/Core/Resgrid.Services/DepartmentsService.cs
@@ -102,7 +102,9 @@ async Task getDepartment()
if (department == null && departmentId > 0)
{
- Logging.LogError($"GetDepartmentById(): Did not pull department info back for id {departmentId}");
+ // Warning, not error: the id is caller-supplied (SCIM/API requests probe with
+ // arbitrary values), so a miss is a normal not-found, not a system fault.
+ Logging.LogWarning($"GetDepartmentById(): Did not pull department info back for id {departmentId}");
}
return department;
diff --git a/Core/Resgrid.Services/DispatchRecommendationService.cs b/Core/Resgrid.Services/DispatchRecommendationService.cs
new file mode 100644
index 000000000..79c89044e
--- /dev/null
+++ b/Core/Resgrid.Services/DispatchRecommendationService.cs
@@ -0,0 +1,1348 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Resgrid.Model;
+using Resgrid.Model.Events;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Reporting;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class DispatchRecommendationService : IDispatchRecommendationService
+ {
+ private readonly IRunCardsService _runCardsService;
+ private readonly IUnitsService _unitsService;
+ private readonly IActionLogsService _actionLogsService;
+ private readonly IUserStateService _userStateService;
+ private readonly IPersonnelRolesService _personnelRolesService;
+ private readonly ICustomStateService _customStateService;
+ private readonly IDepartmentGroupsService _departmentGroupsService;
+ private readonly IDepartmentSettingsService _departmentSettingsService;
+ private readonly IGeoService _geoService;
+ private readonly IPersonnelLocationResolver _personnelLocationResolver;
+ private readonly IShiftsService _shiftsService;
+ private readonly IRunCardActivationsRepository _runCardActivationsRepository;
+ private readonly IEventAggregator _eventAggregator;
+
+ public DispatchRecommendationService(IRunCardsService runCardsService, IUnitsService unitsService,
+ IActionLogsService actionLogsService, IUserStateService userStateService, IPersonnelRolesService personnelRolesService,
+ ICustomStateService customStateService, IDepartmentGroupsService departmentGroupsService,
+ IDepartmentSettingsService departmentSettingsService, IGeoService geoService,
+ IPersonnelLocationResolver personnelLocationResolver, IShiftsService shiftsService,
+ IRunCardActivationsRepository runCardActivationsRepository, IEventAggregator eventAggregator)
+ {
+ _runCardsService = runCardsService;
+ _unitsService = unitsService;
+ _actionLogsService = actionLogsService;
+ _userStateService = userStateService;
+ _personnelRolesService = personnelRolesService;
+ _customStateService = customStateService;
+ _departmentGroupsService = departmentGroupsService;
+ _departmentSettingsService = departmentSettingsService;
+ _geoService = geoService;
+ _personnelLocationResolver = personnelLocationResolver;
+ _shiftsService = shiftsService;
+ _runCardActivationsRepository = runCardActivationsRepository;
+ _eventAggregator = eventAggregator;
+ }
+
+ public async Task GetRecommendationAsync(DispatchRecommendationRequest request, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (request == null)
+ throw new ArgumentNullException(nameof(request));
+
+ var result = new DispatchRecommendationResult { AlarmLevel = request.TargetAlarmLevel };
+
+ var card = await _runCardsService.GetMatchingRunCardAsync(request.DepartmentId, request.Priority, request.CallTypeName);
+
+ if (card == null)
+ {
+ result.Notes.Add("No run card matches this call's priority and type; manual dispatch flow applies.");
+ return result;
+ }
+
+ result.MatchedRunCardId = card.RunCardId;
+ result.MatchedRunCardName = card.Name;
+
+ var departmentMode = await _departmentSettingsService.GetDispatchRecommendationModeAsync(request.DepartmentId);
+ var mode = request.ModeOverride
+ ?? (card.DispatchModeOverride.HasValue ? (DispatchRecommendationModes)card.DispatchModeOverride.Value : departmentMode);
+ result.ModeUsed = mode;
+
+ var departmentAuto = await _departmentSettingsService.GetDispatchRecommendationAutoDispatchAsync(request.DepartmentId);
+ result.AutoDispatch = card.AutoDispatchOverride.HasValue ? card.AutoDispatchOverride.Value == 1 : departmentAuto;
+
+ var level = card.AlarmLevels?.FirstOrDefault(l => l.AlarmLevel == request.TargetAlarmLevel);
+
+ if (level == null)
+ {
+ result.Notes.Add($"Run card '{card.Name}' has no alarm level {request.TargetAlarmLevel}; nothing to add.");
+ return result;
+ }
+
+ if (mode == DispatchRecommendationModes.Off)
+ {
+ result.Notes.Add($"Run card '{card.Name}' matched but automatic resource selection is off; use its requirements as a manual checklist.");
+ return result;
+ }
+
+ var config = await _departmentSettingsService.GetDispatchRecommendationConfigAsync(request.DepartmentId);
+ var staffingGate = card.MinimumStaffingLevelOverride ?? config.UnitMinimumStaffingLevel;
+ var now = DateTime.UtcNow;
+
+ var context = new RecommendationContext
+ {
+ Request = request,
+ Card = card,
+ Level = level,
+ Config = config,
+ StaffingGate = staffingGate,
+ Now = now,
+ Result = result,
+ CancellationToken = cancellationToken
+ };
+
+ await BuildUnitCandidatesAsync(context);
+ await BuildPersonnelCandidatesAsync(context);
+
+ if (config.RestPeriodMinutes > 0)
+ {
+ context.UnitLastDispatched = await _runCardsService.GetLastUnitDispatchTimesAsync(request.DepartmentId);
+ context.UserLastDispatched = await _runCardsService.GetLastUserDispatchTimesAsync(request.DepartmentId);
+ }
+
+ context.CallLocation = ResolveCallLocation(request);
+
+ if (mode == DispatchRecommendationModes.StationBased)
+ await FillStationBasedAsync(context);
+ else if (mode == DispatchRecommendationModes.ClosestUnit)
+ await FillClosestUnitAsync(context);
+
+ if (config.MoveUpRecommendationsEnabled)
+ await RunMoveUpPassAsync(context);
+
+ return result;
+ }
+
+ public async Task EnrichCallForDispatchAsync(Call call, int targetAlarmLevel, bool onlyWhenAutoDispatch = false, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (call == null)
+ throw new ArgumentNullException(nameof(call));
+
+ var location = GeoMath.ParseLatLonString(call.GeoLocationData);
+
+ var request = new DispatchRecommendationRequest
+ {
+ DepartmentId = call.DepartmentId,
+ Priority = call.Priority,
+ CallTypeName = call.Type,
+ Latitude = location?.Latitude,
+ Longitude = location?.Longitude,
+ TargetAlarmLevel = targetAlarmLevel,
+ AlreadyDispatchedUnitIds = call.UnitDispatches?.Select(d => d.UnitId).ToList() ?? new List(),
+ AlreadyDispatchedUserIds = call.Dispatches?.Select(d => d.UserId).Where(id => !string.IsNullOrWhiteSpace(id)).ToList() ?? new List()
+ };
+
+ var result = await GetRecommendationAsync(request, cancellationToken);
+
+ if (!result.MatchedRunCardId.HasValue)
+ return result;
+
+ call.ActiveRunCardId = result.MatchedRunCardId;
+
+ if (targetAlarmLevel > call.AlarmLevel || call.AlarmLevel <= 0)
+ call.AlarmLevel = Math.Max(1, targetAlarmLevel);
+
+ if (onlyWhenAutoDispatch && !result.AutoDispatch)
+ {
+ result.Notes.Add("Auto-dispatch is off; recommendations were computed but not applied to the call.");
+ return result;
+ }
+
+ if (result.Units.Any())
+ {
+ if (call.UnitDispatches == null)
+ call.UnitDispatches = new Collection();
+
+ foreach (var unit in result.Units)
+ {
+ if (call.UnitDispatches.Any(d => d.UnitId == unit.UnitId))
+ continue;
+
+ call.UnitDispatches.Add(new CallDispatchUnit
+ {
+ CallId = call.CallId,
+ UnitId = unit.UnitId
+ });
+ }
+ }
+
+ if (result.Personnel.Any())
+ {
+ if (call.Dispatches == null)
+ call.Dispatches = new Collection();
+
+ foreach (var person in result.Personnel)
+ {
+ if (call.Dispatches.Any(d => d.UserId == person.UserId))
+ continue;
+
+ call.Dispatches.Add(new CallDispatch
+ {
+ CallId = call.CallId,
+ UserId = person.UserId
+ });
+ }
+ }
+
+ return result;
+ }
+
+ public async Task RecordActivationAsync(Call call, DispatchRecommendationResult result, string createdByUserId, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (call == null || result == null || !result.MatchedRunCardId.HasValue)
+ return;
+
+ var activation = new RunCardActivation
+ {
+ DepartmentId = call.DepartmentId,
+ CallId = call.CallId,
+ RunCardId = result.MatchedRunCardId.Value,
+ AlarmLevel = result.AlarmLevel,
+ ModeUsed = (int)result.ModeUsed,
+ WasAutoDispatched = result.AutoDispatch,
+ ResultJson = JsonConvert.SerializeObject(result),
+ CreatedOn = DateTime.UtcNow,
+ CreatedByUserId = createdByUserId
+ };
+
+ await _runCardActivationsRepository.SaveOrUpdateAsync(activation, cancellationToken, true);
+
+ _eventAggregator.SendMessage(new RunCardActivatedEvent
+ {
+ DepartmentId = call.DepartmentId,
+ CallId = call.CallId,
+ RunCardId = result.MatchedRunCardId.Value,
+ RunCardName = result.MatchedRunCardName,
+ AlarmLevel = result.AlarmLevel,
+ ModeUsed = (int)result.ModeUsed,
+ WasAutoDispatched = result.AutoDispatch,
+ UnitIds = result.Units.Select(u => u.UnitId).ToList(),
+ UserIds = result.Personnel.Select(p => p.UserId).ToList()
+ });
+
+ if (result.HasShortfalls)
+ {
+ _eventAggregator.SendMessage(new DispatchShortfallEvent
+ {
+ DepartmentId = call.DepartmentId,
+ CallId = call.CallId,
+ RunCardId = result.MatchedRunCardId.Value,
+ AlarmLevel = result.AlarmLevel,
+ Shortfalls = result.Shortfalls
+ });
+ }
+
+ if (result.MoveUps.Any())
+ {
+ _eventAggregator.SendMessage(new StationCoverageGapEvent
+ {
+ DepartmentId = call.DepartmentId,
+ CallId = call.CallId,
+ MoveUps = result.MoveUps
+ });
+ }
+ }
+
+ #region Candidate pools
+
+ private sealed class UnitCandidate
+ {
+ public Unit Unit { get; set; }
+ public int UnitTypeId { get; set; }
+ public string UnitTypeName { get; set; }
+ public string StatusText { get; set; }
+ public int? StaffingLevel { get; set; }
+ public bool InRestPeriod { get; set; }
+ public double? Latitude { get; set; }
+ public double? Longitude { get; set; }
+ public DateTime? LocationTimestamp { get; set; }
+ public bool LocationIsStale { get; set; }
+ }
+
+ private sealed class PersonnelCandidate
+ {
+ public string UserId { get; set; }
+ public List RoleIds { get; set; } = new List();
+ public string StatusText { get; set; }
+ public int? StationGroupId { get; set; }
+ public string StationGroupName { get; set; }
+ public bool InRestPeriod { get; set; }
+ public double? Latitude { get; set; }
+ public double? Longitude { get; set; }
+ public DateTime? LocationTimestamp { get; set; }
+ public bool LocationIsStale { get; set; }
+ }
+
+ private sealed class RecommendationContext
+ {
+ public DispatchRecommendationRequest Request { get; set; }
+ public RunCard Card { get; set; }
+ public RunCardAlarmLevel Level { get; set; }
+ public DispatchRecommendationConfig Config { get; set; }
+ public int StaffingGate { get; set; }
+ public DateTime Now { get; set; }
+ public DispatchRecommendationResult Result { get; set; }
+ ///
+ /// Carried on the context rather than threaded through every private fill
+ /// method. Checked at the boundaries that repeat slow external I/O (routed
+ /// ETA lookups, per-station geocoding) so an abandoned request stops calling
+ /// the mapping provider.
+ ///
+ public CancellationToken CancellationToken { get; set; }
+ public List UnitCandidates { get; set; } = new List();
+ public List PersonnelCandidates { get; set; } = new List();
+ public Dictionary UnitLastDispatched { get; set; } = new Dictionary();
+ public Dictionary UserLastDispatched { get; set; } = new Dictionary();
+ public GeoMath.GeoPoint? CallLocation { get; set; }
+ public HashSet UnitTypesWithStaffingExclusions { get; set; } = new HashSet();
+ }
+
+ private async Task BuildUnitCandidatesAsync(RecommendationContext context)
+ {
+ var departmentId = context.Request.DepartmentId;
+
+ var units = await _unitsService.GetUnitsForDepartmentUnlimitedAsync(departmentId) ?? new List();
+ var states = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(departmentId) ?? new List();
+ var unitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(departmentId) ?? new List();
+ var customStates = await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(departmentId) ?? new List();
+
+ var stateByUnit = states.GroupBy(s => s.UnitId).ToDictionary(g => g.Key, g => g.OrderByDescending(s => s.Timestamp).First());
+ var typeByName = BuildUnitTypeLookup(unitTypes);
+ var customDetails = BuildCustomDetailMap(customStates);
+
+ var selections = (context.Card.AvailabilitySelections ?? new List())
+ .Where(s => s.SelectionType == (int)RunCardSelectionTypes.UnitStatus)
+ .ToList();
+
+ Dictionary staffing = null;
+ if (context.StaffingGate > 0)
+ staffing = await _unitsService.GetUnitStaffingForDepartmentAsync(departmentId) ?? new Dictionary();
+
+ var alreadyDispatched = new HashSet(context.Request.AlreadyDispatchedUnitIds ?? new List());
+
+ foreach (var unit in units)
+ {
+ if (alreadyDispatched.Contains(unit.UnitId))
+ continue;
+
+ if (string.IsNullOrWhiteSpace(unit.Type) || !typeByName.TryGetValue(unit.Type.Trim(), out var unitType))
+ continue;
+
+ var stateId = stateByUnit.TryGetValue(unit.UnitId, out var unitState) ? unitState.State : (int)UnitStateTypes.Available;
+ var isCustom = customDetails.ContainsKey(stateId);
+
+ if (!IsUnitStateDispatchable(stateId, isCustom, unitType.UnitTypeId, selections, customDetails))
+ continue;
+
+ int? staffingLevel = null;
+ if (staffing != null)
+ {
+ staffing.TryGetValue(unit.UnitId, out var staffingResult);
+ var level = staffingResult?.Level ?? UnitStaffingLevel.Unknown;
+ staffingLevel = (int)level;
+
+ // Units with no defined seats (Unknown) always pass — departments not
+ // using unit roles shouldn't be locked out by the staffing gate.
+ if (level != UnitStaffingLevel.Unknown && (int)level < context.StaffingGate)
+ {
+ context.UnitTypesWithStaffingExclusions.Add(unitType.UnitTypeId);
+ context.Result.Notes.Add($"Unit '{unit.Name}' excluded: staffing {level} is below the required minimum.");
+ continue;
+ }
+ }
+
+ context.UnitCandidates.Add(new UnitCandidate
+ {
+ Unit = unit,
+ UnitTypeId = unitType.UnitTypeId,
+ UnitTypeName = unitType.Type,
+ StatusText = GetUnitStatusText(stateId, isCustom, customDetails),
+ StaffingLevel = staffingLevel
+ });
+ }
+ }
+
+ private async Task BuildPersonnelCandidatesAsync(RecommendationContext context)
+ {
+ var departmentId = context.Request.DepartmentId;
+
+ var rolesByUser = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(departmentId) ?? new Dictionary>();
+ var actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(departmentId) ?? new List();
+ var userStates = await _userStateService.GetLatestStatesForDepartmentAsync(departmentId) ?? new List();
+ var groupByUser = await _departmentGroupsService.GetAllDepartmentGroupsForDepartmentAsync(departmentId) ?? new Dictionary();
+
+ var personnelCustomState = await _customStateService.GetActivePersonnelStateForDepartmentAsync(departmentId);
+ var staffingCustomState = await _customStateService.GetActiveStaffingLevelsForDepartmentAsync(departmentId);
+
+ var personnelDetails = BuildCustomDetailMap(personnelCustomState);
+ var staffingDetails = BuildCustomDetailMap(staffingCustomState);
+
+ var logByUser = actionLogs.Where(l => !string.IsNullOrWhiteSpace(l.UserId))
+ .GroupBy(l => l.UserId).ToDictionary(g => g.Key, g => g.OrderByDescending(l => l.Timestamp).First());
+ var stateByUser = userStates.Where(s => !string.IsNullOrWhiteSpace(s.UserId))
+ .GroupBy(s => s.UserId).ToDictionary(g => g.Key, g => g.OrderByDescending(s => s.Timestamp).First());
+
+ var allSelections = context.Card.AvailabilitySelections ?? new List();
+ var statusSelections = allSelections.Where(s => s.SelectionType == (int)RunCardSelectionTypes.PersonnelStatus).ToList();
+ var staffingSelections = allSelections.Where(s => s.SelectionType == (int)RunCardSelectionTypes.PersonnelStaffing).ToList();
+
+ var alreadyDispatched = new HashSet(context.Request.AlreadyDispatchedUserIds ?? new List(), StringComparer.OrdinalIgnoreCase);
+
+ foreach (var pair in rolesByUser)
+ {
+ var userId = pair.Key;
+
+ if (string.IsNullOrWhiteSpace(userId) || alreadyDispatched.Contains(userId))
+ continue;
+
+ logByUser.TryGetValue(userId, out var lastLog);
+ stateByUser.TryGetValue(userId, out var lastState);
+
+ if (!IsPersonnelStatusDispatchable(lastLog, statusSelections, personnelDetails))
+ continue;
+
+ if (!IsPersonnelStaffingDispatchable(lastState, staffingSelections, staffingDetails))
+ continue;
+
+ groupByUser.TryGetValue(userId, out var group);
+
+ var candidate = new PersonnelCandidate
+ {
+ UserId = userId,
+ RoleIds = pair.Value?.Select(r => r.PersonnelRoleId).ToList() ?? new List(),
+ StatusText = GetPersonnelStatusText(lastLog, personnelDetails),
+ StationGroupId = group?.DepartmentGroupId,
+ StationGroupName = group?.Name
+ };
+
+ if (lastLog != null)
+ {
+ var coordinates = lastLog.GetCoordinates();
+ if (coordinates != null && coordinates.Latitude.HasValue && coordinates.Longitude.HasValue)
+ {
+ candidate.Latitude = coordinates.Latitude;
+ candidate.Longitude = coordinates.Longitude;
+ candidate.LocationTimestamp = lastLog.Timestamp;
+ }
+ }
+
+ context.PersonnelCandidates.Add(candidate);
+ }
+ }
+
+ private static Dictionary BuildUnitTypeLookup(List unitTypes)
+ {
+ var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var unitType in unitTypes)
+ {
+ if (string.IsNullOrWhiteSpace(unitType?.Type))
+ continue;
+
+ lookup[unitType.Type.Trim()] = unitType;
+ }
+
+ return lookup;
+ }
+
+ private static Dictionary BuildCustomDetailMap(List states)
+ {
+ var map = new Dictionary();
+
+ foreach (var state in states ?? new List())
+ MergeCustomDetails(map, state);
+
+ return map;
+ }
+
+ private static Dictionary BuildCustomDetailMap(CustomState state)
+ {
+ var map = new Dictionary();
+ MergeCustomDetails(map, state);
+ return map;
+ }
+
+ private static void MergeCustomDetails(Dictionary map, CustomState state)
+ {
+ if (state == null)
+ return;
+
+ foreach (var detail in state.GetActiveDetails() ?? new List())
+ map[detail.CustomStateDetailId] = detail;
+ }
+
+ private static bool IsUnitStateDispatchable(int stateId, bool isCustom, int unitTypeId,
+ List selections, Dictionary customDetails)
+ {
+ // Typed selections beat generic (null unit type) selections; either set, when
+ // present, is authoritative for this unit type.
+ var applicable = selections.Where(s => s.UnitTypeId == unitTypeId).ToList();
+ if (!applicable.Any())
+ applicable = selections.Where(s => !s.UnitTypeId.HasValue).ToList();
+
+ if (applicable.Any())
+ return applicable.Any(s => s.StateId == stateId && s.IsCustomState == isCustom);
+
+ // No selections: fall back to the availability matrix; Delayed still counts as
+ // dispatchable (mirrors platform reporting's unit availability policy).
+ var availability = isCustom
+ ? AvailabilityMatrix.ForCustomBaseType((int)(customDetails[stateId].BaseType))
+ : AvailabilityMatrix.ForUnitStateType(stateId);
+
+ return availability == AvailabilityClass.Available || availability == AvailabilityClass.Delayed;
+ }
+
+ private static bool IsPersonnelStatusDispatchable(ActionLog lastLog,
+ List selections, Dictionary customDetails)
+ {
+ // No status on file means the member never set one — the manual dispatch grids
+ // show them, so the engine treats them as standing by.
+ if (lastLog == null)
+ return !selections.Any() || selections.Any(s => !s.IsCustomState && s.StateId == (int)ActionTypes.StandingBy);
+
+ var stateId = lastLog.ActionTypeId;
+ var isCustom = customDetails.ContainsKey(stateId);
+
+ if (selections.Any())
+ return selections.Any(s => s.StateId == stateId && s.IsCustomState == isCustom);
+
+ var availability = isCustom
+ ? AvailabilityMatrix.ForCustomBaseType((int)(customDetails[stateId].BaseType))
+ : AvailabilityMatrix.ForBuiltInPersonnelActionType(stateId);
+
+ return availability == AvailabilityClass.Available || availability == AvailabilityClass.Delayed;
+ }
+
+ private static bool IsPersonnelStaffingDispatchable(UserState lastState,
+ List selections, Dictionary customDetails)
+ {
+ // No staffing row defaults to Available (matches UserStateService semantics).
+ if (lastState == null)
+ return !selections.Any() || selections.Any(s => !s.IsCustomState && s.StateId == (int)UserStateTypes.Available);
+
+ var stateId = lastState.State;
+ var isCustom = customDetails.ContainsKey(stateId);
+
+ if (selections.Any())
+ return selections.Any(s => s.StateId == stateId && s.IsCustomState == isCustom);
+
+ if (isCustom)
+ {
+ var availability = AvailabilityMatrix.ForCustomBaseType((int)(customDetails[stateId].BaseType));
+ return availability == AvailabilityClass.Available || availability == AvailabilityClass.Delayed;
+ }
+
+ return stateId == (int)UserStateTypes.Available
+ || stateId == (int)UserStateTypes.OnShift
+ || stateId == (int)UserStateTypes.Delayed;
+ }
+
+ private static string GetUnitStatusText(int stateId, bool isCustom, Dictionary customDetails)
+ {
+ if (isCustom && customDetails.TryGetValue(stateId, out var detail))
+ return detail.ButtonText;
+
+ if (Enum.IsDefined(typeof(UnitStateTypes), stateId))
+ return ((UnitStateTypes)stateId).ToString();
+
+ return stateId.ToString();
+ }
+
+ private static string GetPersonnelStatusText(ActionLog lastLog, Dictionary customDetails)
+ {
+ if (lastLog == null)
+ return ActionTypes.StandingBy.ToString();
+
+ if (customDetails.TryGetValue(lastLog.ActionTypeId, out var detail))
+ return detail.ButtonText;
+
+ if (Enum.IsDefined(typeof(ActionTypes), lastLog.ActionTypeId))
+ return ((ActionTypes)lastLog.ActionTypeId).ToString();
+
+ return lastLog.ActionTypeId.ToString();
+ }
+
+ private static GeoMath.GeoPoint? ResolveCallLocation(DispatchRecommendationRequest request)
+ {
+ if (request.Latitude.HasValue && request.Longitude.HasValue
+ && !(request.Latitude.Value == 0 && request.Longitude.Value == 0))
+ return new GeoMath.GeoPoint(request.Latitude.Value, request.Longitude.Value);
+
+ return null;
+ }
+
+ private bool IsUnitInRestPeriod(RecommendationContext context, int unitId)
+ {
+ if (context.Config.RestPeriodMinutes <= 0)
+ return false;
+
+ return context.UnitLastDispatched.TryGetValue(unitId, out var last)
+ && (context.Now - last).TotalMinutes < context.Config.RestPeriodMinutes;
+ }
+
+ private bool IsUserInRestPeriod(RecommendationContext context, string userId)
+ {
+ if (context.Config.RestPeriodMinutes <= 0)
+ return false;
+
+ return context.UserLastDispatched.TryGetValue(userId, out var last)
+ && (context.Now - last).TotalMinutes < context.Config.RestPeriodMinutes;
+ }
+
+ #endregion
+
+ #region Station-based selection
+
+ private async Task FillStationBasedAsync(RecommendationContext context)
+ {
+ var request = context.Request;
+ var anchor = context.CallLocation;
+
+ if (anchor == null && context.Card.HomeStationGroupId.HasValue)
+ {
+ var homeStation = await _departmentGroupsService.GetGroupByIdAsync(context.Card.HomeStationGroupId.Value, false);
+ var homePoint = await _geoService.GetStationCoordinatesAsync(homeStation);
+
+ if (homePoint != null)
+ {
+ anchor = homePoint;
+ context.Result.Notes.Add($"Call has no location; cascading from the run card's home station '{homeStation?.Name}'.");
+ }
+ }
+
+ if (anchor == null)
+ {
+ AddAllRequirementShortfalls(context, RequirementShortfallReasons.NoLocationData);
+ context.Result.Notes.Add("Call has no usable location and the run card has no home station; station-based selection cannot run.");
+ return;
+ }
+
+ var stations = await _geoService.OrderStationsByDistanceAsync(request.DepartmentId, anchor.Value.Latitude, anchor.Value.Longitude);
+
+ if (stations == null || !stations.Any())
+ {
+ AddAllRequirementShortfalls(context, RequirementShortfallReasons.StationsExhausted);
+ context.Result.Notes.Add("No station groups with usable coordinates or geofences exist; station-based selection cannot run.");
+ return;
+ }
+
+ // Containing stations first (nearest containing wins), then everything else by distance.
+ var ordered = stations.Where(s => s.ContainsPoint).Concat(stations.Where(s => !s.ContainsPoint)).ToList();
+
+ if (context.CallLocation != null && !stations.Any(s => s.ContainsPoint))
+ context.Result.Notes.Add("Call location is not inside any station's response area; filling from the nearest station outward.");
+
+ var dispatchShift = await _departmentSettingsService.GetDispatchShiftInsteadOfGroupAsync(request.DepartmentId);
+ var stationRosters = await BuildStationRostersAsync(context, ordered, dispatchShift);
+
+ foreach (var requirement in context.Level.UnitRequirements ?? new List())
+ FillUnitRequirementFromStations(context, requirement, ordered);
+
+ foreach (var requirement in context.Level.RoleRequirements ?? new List())
+ FillRoleRequirementFromStations(context, requirement, ordered, stationRosters);
+ }
+
+ private async Task>> BuildStationRostersAsync(RecommendationContext context,
+ List stations, bool dispatchShiftInsteadOfGroup)
+ {
+ var rosters = new Dictionary>();
+
+ if (!dispatchShiftInsteadOfGroup)
+ {
+ foreach (var candidate in context.PersonnelCandidates)
+ {
+ if (!candidate.StationGroupId.HasValue)
+ continue;
+
+ if (!rosters.TryGetValue(candidate.StationGroupId.Value, out var roster))
+ rosters[candidate.StationGroupId.Value] = roster = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ roster.Add(candidate.UserId);
+ }
+
+ return rosters;
+ }
+
+ // Shift-based departments dispatch today's shift roster instead of the whole
+ // group (same policy CallDispatchStatusService applies to group dispatches).
+ foreach (var station in stations)
+ {
+ var signups = await _shiftsService.GetShiftSignupsByDepartmentGroupIdAndDayAsync(station.Station.DepartmentGroupId, context.Now.Date);
+ var roster = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var signup in signups ?? new List())
+ {
+ if (!string.IsNullOrWhiteSpace(signup.UserId))
+ roster.Add(signup.UserId);
+ }
+
+ rosters[station.Station.DepartmentGroupId] = roster;
+ }
+
+ return rosters;
+ }
+
+ private void FillUnitRequirementFromStations(RecommendationContext context, RunCardUnitRequirement requirement, List stations)
+ {
+ var picked = new List();
+ var pool = context.UnitCandidates
+ .Where(c => c.UnitTypeId == requirement.UnitTypeId)
+ .Where(c => context.Result.Units.All(u => u.UnitId != c.Unit.UnitId))
+ .ToList();
+
+ if (!pool.Any())
+ {
+ AddUnitShortfall(context, requirement, 0, RequirementShortfallReasons.NoCandidatesAvailable);
+ return;
+ }
+
+ // Two passes: rested resources anywhere in the cascade beat in-rest resources
+ // at nearer stations — that's the whole point of the rest period.
+ foreach (var allowRestPeriod in new[] { false, true })
+ {
+ for (int depth = 0; depth < stations.Count && picked.Count < requirement.RequiredCount; depth++)
+ {
+ var station = stations[depth];
+ var stationUnits = pool
+ .Where(c => c.Unit.StationGroupId == station.Station.DepartmentGroupId)
+ .Where(c => picked.All(p => p.Unit.UnitId != c.Unit.UnitId))
+ .Where(c => IsUnitInRestPeriod(context, c.Unit.UnitId) == allowRestPeriod)
+ .OrderBy(c => c.Unit.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var candidate in stationUnits)
+ {
+ if (picked.Count >= requirement.RequiredCount)
+ break;
+
+ picked.Add(candidate);
+
+ context.Result.Units.Add(new UnitRecommendation
+ {
+ UnitId = candidate.Unit.UnitId,
+ UnitName = candidate.Unit.Name,
+ UnitTypeId = candidate.UnitTypeId,
+ UnitTypeName = candidate.UnitTypeName,
+ StationGroupId = station.Station.DepartmentGroupId,
+ StationGroupName = station.Station.Name,
+ SelectionReason = allowRestPeriod
+ ? RecommendationSelectionReasons.RestPeriodOverridden
+ : (station.ContainsPoint && depth == 0 ? RecommendationSelectionReasons.InGeofence : RecommendationSelectionReasons.CascadeStation),
+ CascadeDepth = depth,
+ DistanceMeters = station.DistanceMeters,
+ CurrentStatusText = candidate.StatusText,
+ StaffingLevel = candidate.StaffingLevel,
+ SatisfiesRequirementId = requirement.RunCardUnitRequirementId
+ });
+
+ if (allowRestPeriod)
+ context.Result.Notes.Add($"Unit '{candidate.Unit.Name}' is inside its rest period but was needed to fill {candidate.UnitTypeName}.");
+ }
+ }
+
+ if (picked.Count >= requirement.RequiredCount)
+ break;
+ }
+
+ if (picked.Count < requirement.RequiredCount)
+ AddUnitShortfall(context, requirement, picked.Count, RequirementShortfallReasons.StationsExhausted);
+ }
+
+ private void FillRoleRequirementFromStations(RecommendationContext context, RunCardRoleRequirement requirement,
+ List stations, Dictionary> stationRosters)
+ {
+ var pickedUsers = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var pool = context.PersonnelCandidates
+ .Where(c => c.RoleIds.Contains(requirement.PersonnelRoleId))
+ .Where(c => context.Result.Personnel.All(p => !string.Equals(p.UserId, c.UserId, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ if (!pool.Any())
+ {
+ AddRoleShortfall(context, requirement, 0, RequirementShortfallReasons.NoCandidatesAvailable);
+ return;
+ }
+
+ foreach (var allowRestPeriod in new[] { false, true })
+ {
+ for (int depth = 0; depth < stations.Count && pickedUsers.Count < requirement.RequiredCount; depth++)
+ {
+ var station = stations[depth];
+
+ if (!stationRosters.TryGetValue(station.Station.DepartmentGroupId, out var roster))
+ continue;
+
+ var stationPeople = pool
+ .Where(c => roster.Contains(c.UserId) && !pickedUsers.Contains(c.UserId))
+ .Where(c => IsUserInRestPeriod(context, c.UserId) == allowRestPeriod)
+ .OrderBy(c => c.UserId, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var candidate in stationPeople)
+ {
+ if (pickedUsers.Count >= requirement.RequiredCount)
+ break;
+
+ pickedUsers.Add(candidate.UserId);
+
+ context.Result.Personnel.Add(new PersonnelRecommendation
+ {
+ UserId = candidate.UserId,
+ RoleId = requirement.PersonnelRoleId,
+ StationGroupId = station.Station.DepartmentGroupId,
+ StationGroupName = station.Station.Name,
+ SelectionReason = allowRestPeriod
+ ? RecommendationSelectionReasons.RestPeriodOverridden
+ : (station.ContainsPoint && depth == 0 ? RecommendationSelectionReasons.InGeofence : RecommendationSelectionReasons.CascadeStation),
+ CascadeDepth = depth,
+ DistanceMeters = station.DistanceMeters,
+ CurrentStatusText = candidate.StatusText,
+ SatisfiesRequirementId = requirement.RunCardRoleRequirementId
+ });
+ }
+ }
+
+ if (pickedUsers.Count >= requirement.RequiredCount)
+ break;
+ }
+
+ if (pickedUsers.Count < requirement.RequiredCount)
+ AddRoleShortfall(context, requirement, pickedUsers.Count, RequirementShortfallReasons.StationsExhausted);
+ }
+
+ #endregion
+
+ #region Closest-unit selection
+
+ private async Task FillClosestUnitAsync(RecommendationContext context)
+ {
+ var request = context.Request;
+ var anchor = context.CallLocation;
+
+ if (anchor == null && context.Card.HomeStationGroupId.HasValue)
+ {
+ var homeStation = await _departmentGroupsService.GetGroupByIdAsync(context.Card.HomeStationGroupId.Value, false);
+ anchor = await _geoService.GetStationCoordinatesAsync(homeStation);
+ }
+
+ if (anchor == null)
+ {
+ AddAllRequirementShortfalls(context, RequirementShortfallReasons.NoLocationData);
+ context.Result.Notes.Add("Call has no usable location; closest-unit selection cannot run.");
+ return;
+ }
+
+ await AttachUnitLocationsAsync(context);
+ AttachPersonnelLocations(context, await _personnelLocationResolver.GetLatestLocationsAsync(
+ request.DepartmentId, context.Config.PersonnelMaxLocationAgeSeconds, context.Now));
+
+ foreach (var requirement in context.Level.UnitRequirements ?? new List())
+ await FillUnitRequirementByProximityAsync(context, requirement, anchor.Value);
+
+ foreach (var requirement in context.Level.RoleRequirements ?? new List())
+ await FillRoleRequirementByProximityAsync(context, requirement, anchor.Value);
+ }
+
+ private async Task AttachUnitLocationsAsync(RecommendationContext context)
+ {
+ var locations = await _unitsService.GetLatestUnitLocationsAsync(context.Request.DepartmentId) ?? new List();
+
+ var latestByUnit = locations
+ .Where(l => l != null && l.IsValidFix != false && !(l.Latitude == 0 && l.Longitude == 0))
+ .GroupBy(l => l.UnitId)
+ .ToDictionary(g => g.Key, g => g.OrderByDescending(l => l.Timestamp).First());
+
+ foreach (var candidate in context.UnitCandidates)
+ {
+ if (!latestByUnit.TryGetValue(candidate.Unit.UnitId, out var location))
+ continue;
+
+ candidate.Latitude = (double)location.Latitude;
+ candidate.Longitude = (double)location.Longitude;
+ candidate.LocationTimestamp = location.Timestamp;
+
+ if (context.Config.MaxLocationAgeSeconds > 0)
+ candidate.LocationIsStale = (context.Now - location.Timestamp).TotalSeconds > context.Config.MaxLocationAgeSeconds;
+ }
+ }
+
+ private static void AttachPersonnelLocations(RecommendationContext context, Dictionary locations)
+ {
+ foreach (var candidate in context.PersonnelCandidates)
+ {
+ if (locations == null || !locations.TryGetValue(candidate.UserId, out var location))
+ continue;
+
+ // The resolver may be fresher than the candidate's ActionLog fix.
+ if (!candidate.LocationTimestamp.HasValue || location.Timestamp > candidate.LocationTimestamp.Value)
+ {
+ candidate.Latitude = location.Latitude;
+ candidate.Longitude = location.Longitude;
+ candidate.LocationTimestamp = location.Timestamp;
+ candidate.LocationIsStale = location.IsStale;
+ }
+ }
+ }
+
+ private async Task FillUnitRequirementByProximityAsync(RecommendationContext context, RunCardUnitRequirement requirement, GeoMath.GeoPoint anchor)
+ {
+ var pool = context.UnitCandidates
+ .Where(c => c.UnitTypeId == requirement.UnitTypeId)
+ .Where(c => context.Result.Units.All(u => u.UnitId != c.Unit.UnitId))
+ .ToList();
+
+ if (!pool.Any())
+ {
+ AddUnitShortfall(context, requirement, 0, RequirementShortfallReasons.NoCandidatesAvailable);
+ return;
+ }
+
+ var located = pool.Where(c => c.Latitude.HasValue && c.Longitude.HasValue).ToList();
+ var unlocated = pool.Count - located.Count;
+
+ if (!context.Config.IncludeStaleLocations)
+ {
+ var fresh = located.Where(c => !c.LocationIsStale).ToList();
+
+ if (fresh.Count < located.Count)
+ context.Result.Notes.Add($"{located.Count - fresh.Count} '{pool.First().UnitTypeName}' candidate location fix(es) were too old and excluded.");
+
+ located = fresh;
+ }
+
+ var ranked = located
+ .Select(c => new
+ {
+ Candidate = c,
+ Distance = GeoMath.HaversineMeters(anchor.Latitude, anchor.Longitude, c.Latitude.Value, c.Longitude.Value)
+ })
+ .Where(x => context.Config.MaxRadiusMeters <= 0 || x.Distance <= context.Config.MaxRadiusMeters)
+ .OrderBy(x => IsUnitInRestPeriod(context, x.Candidate.Unit.UnitId) ? 1 : 0)
+ .ThenBy(x => x.Distance)
+ .ToList();
+
+ var outsideRadius = located.Count - ranked.Count;
+
+ var etaByUnitId = new Dictionary();
+ if (context.Config.UseRoutedEta && ranked.Any())
+ {
+ var shortlistSize = Math.Max(1, context.Config.EtaShortlistSize);
+ var shortlist = ranked.Take(Math.Max(shortlistSize, requirement.RequiredCount)).ToList();
+
+ foreach (var entry in shortlist)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ var eta = await _geoService.GetEtaInSecondsAsync(
+ FormatPoint(entry.Candidate.Latitude.Value, entry.Candidate.Longitude.Value),
+ FormatPoint(anchor.Latitude, anchor.Longitude));
+
+ if (eta >= 0)
+ etaByUnitId[entry.Candidate.Unit.UnitId] = eta;
+ }
+
+ if (etaByUnitId.Any())
+ {
+ var reranked = shortlist
+ .OrderBy(x => IsUnitInRestPeriod(context, x.Candidate.Unit.UnitId) ? 1 : 0)
+ .ThenBy(x => etaByUnitId.TryGetValue(x.Candidate.Unit.UnitId, out var eta) ? eta : double.MaxValue)
+ .ThenBy(x => x.Distance)
+ .ToList();
+
+ ranked = reranked.Concat(ranked.Skip(shortlist.Count)).ToList();
+ }
+ }
+
+ var picked = 0;
+ foreach (var entry in ranked)
+ {
+ if (picked >= requirement.RequiredCount)
+ break;
+
+ var inRest = IsUnitInRestPeriod(context, entry.Candidate.Unit.UnitId);
+ var hasEta = etaByUnitId.TryGetValue(entry.Candidate.Unit.UnitId, out var etaSeconds);
+ picked++;
+
+ context.Result.Units.Add(new UnitRecommendation
+ {
+ UnitId = entry.Candidate.Unit.UnitId,
+ UnitName = entry.Candidate.Unit.Name,
+ UnitTypeId = entry.Candidate.UnitTypeId,
+ UnitTypeName = entry.Candidate.UnitTypeName,
+ StationGroupId = entry.Candidate.Unit.StationGroupId,
+ StationGroupName = entry.Candidate.Unit.StationGroup?.Name,
+ SelectionReason = inRest
+ ? RecommendationSelectionReasons.RestPeriodOverridden
+ : (hasEta ? RecommendationSelectionReasons.ClosestByEta : RecommendationSelectionReasons.ClosestByDistance),
+ DistanceMeters = entry.Distance,
+ EtaSeconds = hasEta ? etaSeconds : (double?)null,
+ LocationTimestamp = entry.Candidate.LocationTimestamp,
+ LocationIsStale = entry.Candidate.LocationIsStale,
+ CurrentStatusText = entry.Candidate.StatusText,
+ StaffingLevel = entry.Candidate.StaffingLevel,
+ SatisfiesRequirementId = requirement.RunCardUnitRequirementId
+ });
+
+ if (inRest)
+ context.Result.Notes.Add($"Unit '{entry.Candidate.Unit.Name}' is inside its rest period but was needed to fill {entry.Candidate.UnitTypeName}.");
+ }
+
+ if (picked < requirement.RequiredCount)
+ {
+ var reason = RequirementShortfallReasons.NoCandidatesAvailable;
+
+ if (outsideRadius > 0)
+ reason = RequirementShortfallReasons.OutsideRadius;
+ else if (unlocated > 0 || pool.Count > located.Count)
+ reason = RequirementShortfallReasons.LocationsTooStale;
+
+ AddUnitShortfall(context, requirement, picked, reason);
+ }
+ }
+
+ private async Task FillRoleRequirementByProximityAsync(RecommendationContext context, RunCardRoleRequirement requirement, GeoMath.GeoPoint anchor)
+ {
+ var pool = context.PersonnelCandidates
+ .Where(c => c.RoleIds.Contains(requirement.PersonnelRoleId))
+ .Where(c => context.Result.Personnel.All(p => !string.Equals(p.UserId, c.UserId, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ if (!pool.Any())
+ {
+ AddRoleShortfall(context, requirement, 0, RequirementShortfallReasons.NoCandidatesAvailable);
+ return;
+ }
+
+ var located = pool.Where(c => c.Latitude.HasValue && c.Longitude.HasValue).ToList();
+ var unlocated = pool.Count - located.Count;
+
+ if (!context.Config.IncludeStaleLocations)
+ located = located.Where(c => !c.LocationIsStale).ToList();
+
+ var ranked = located
+ .Select(c => new
+ {
+ Candidate = c,
+ Distance = GeoMath.HaversineMeters(anchor.Latitude, anchor.Longitude, c.Latitude.Value, c.Longitude.Value)
+ })
+ .Where(x => context.Config.MaxRadiusMeters <= 0 || x.Distance <= context.Config.MaxRadiusMeters)
+ .OrderBy(x => IsUserInRestPeriod(context, x.Candidate.UserId) ? 1 : 0)
+ .ThenBy(x => x.Distance)
+ .ToList();
+
+ var outsideRadius = located.Count - ranked.Count;
+
+ var etaByUserId = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (context.Config.UseRoutedEta && ranked.Any())
+ {
+ var shortlistSize = Math.Max(1, context.Config.EtaShortlistSize);
+ var shortlist = ranked.Take(Math.Max(shortlistSize, requirement.RequiredCount)).ToList();
+
+ foreach (var entry in shortlist)
+ {
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ var eta = await _geoService.GetEtaInSecondsAsync(
+ FormatPoint(entry.Candidate.Latitude.Value, entry.Candidate.Longitude.Value),
+ FormatPoint(anchor.Latitude, anchor.Longitude));
+
+ if (eta >= 0)
+ etaByUserId[entry.Candidate.UserId] = eta;
+ }
+
+ if (etaByUserId.Any())
+ {
+ var reranked = shortlist
+ .OrderBy(x => IsUserInRestPeriod(context, x.Candidate.UserId) ? 1 : 0)
+ .ThenBy(x => etaByUserId.TryGetValue(x.Candidate.UserId, out var eta) ? eta : double.MaxValue)
+ .ThenBy(x => x.Distance)
+ .ToList();
+
+ ranked = reranked.Concat(ranked.Skip(shortlist.Count)).ToList();
+ }
+ }
+
+ var picked = 0;
+ foreach (var entry in ranked)
+ {
+ if (picked >= requirement.RequiredCount)
+ break;
+
+ var inRest = IsUserInRestPeriod(context, entry.Candidate.UserId);
+ var hasEta = etaByUserId.TryGetValue(entry.Candidate.UserId, out var etaSeconds);
+ picked++;
+
+ context.Result.Personnel.Add(new PersonnelRecommendation
+ {
+ UserId = entry.Candidate.UserId,
+ RoleId = requirement.PersonnelRoleId,
+ StationGroupId = entry.Candidate.StationGroupId,
+ StationGroupName = entry.Candidate.StationGroupName,
+ SelectionReason = inRest
+ ? RecommendationSelectionReasons.RestPeriodOverridden
+ : (hasEta ? RecommendationSelectionReasons.ClosestByEta : RecommendationSelectionReasons.ClosestByDistance),
+ DistanceMeters = entry.Distance,
+ EtaSeconds = hasEta ? etaSeconds : (double?)null,
+ LocationTimestamp = entry.Candidate.LocationTimestamp,
+ LocationIsStale = entry.Candidate.LocationIsStale,
+ CurrentStatusText = entry.Candidate.StatusText,
+ SatisfiesRequirementId = requirement.RunCardRoleRequirementId
+ });
+ }
+
+ if (picked < requirement.RequiredCount)
+ {
+ var reason = RequirementShortfallReasons.NoCandidatesAvailable;
+
+ if (outsideRadius > 0)
+ reason = RequirementShortfallReasons.OutsideRadius;
+ else if (unlocated > 0 || pool.Count > located.Count)
+ reason = RequirementShortfallReasons.LocationsTooStale;
+
+ AddRoleShortfall(context, requirement, picked, reason);
+ }
+ }
+
+ private static string FormatPoint(double latitude, double longitude)
+ {
+ return string.Format(CultureInfo.InvariantCulture, "{0},{1}", latitude, longitude);
+ }
+
+ #endregion
+
+ #region Move-up / backfill
+
+ private async Task RunMoveUpPassAsync(RecommendationContext context)
+ {
+ var requirements = await _runCardsService.GetStationCoverageRequirementsForDepartmentAsync(context.Request.DepartmentId);
+ var enabled = requirements?.Where(r => r.IsEnabled).ToList();
+
+ if (enabled == null || !enabled.Any())
+ return;
+
+ var committedUnitIds = new HashSet(context.Result.Units.Select(u => u.UnitId)
+ .Concat(context.Request.AlreadyDispatchedUnitIds ?? new List()));
+ var committedUserIds = new HashSet(context.Result.Personnel.Select(p => p.UserId)
+ .Concat(context.Request.AlreadyDispatchedUserIds ?? new List()), StringComparer.OrdinalIgnoreCase);
+
+ foreach (var requirement in enabled)
+ {
+ // Resolving a station's coordinates can fall through to address geocoding,
+ // so this loop is external I/O per requirement.
+ context.CancellationToken.ThrowIfCancellationRequested();
+
+ var station = await _departmentGroupsService.GetGroupByIdAsync(requirement.DepartmentGroupId, false);
+
+ if (station == null)
+ continue;
+
+ var stationPoint = await _geoService.GetStationCoordinatesAsync(station);
+
+ if (requirement.UnitTypeId.HasValue)
+ EvaluateUnitCoverage(context, requirement, station, stationPoint, committedUnitIds);
+ else if (requirement.PersonnelRoleId.HasValue)
+ EvaluateRoleCoverage(context, requirement, station, stationPoint, committedUserIds);
+ }
+ }
+
+ private void EvaluateUnitCoverage(RecommendationContext context, StationCoverageRequirement requirement,
+ DepartmentGroup station, GeoMath.GeoPoint? stationPoint, HashSet committedUnitIds)
+ {
+ var typeUnits = context.UnitCandidates.Where(c => c.UnitTypeId == requirement.UnitTypeId.Value).ToList();
+
+ List remaining;
+ if (UseRadiusCoverage(context, requirement, stationPoint, typeUnits.Any(c => c.Latitude.HasValue)))
+ {
+ remaining = typeUnits
+ .Where(c => !committedUnitIds.Contains(c.Unit.UnitId))
+ .Where(c => c.Latitude.HasValue && c.Longitude.HasValue
+ && GeoMath.HaversineMeters(stationPoint.Value.Latitude, stationPoint.Value.Longitude, c.Latitude.Value, c.Longitude.Value) <= requirement.RadiusMeters.Value)
+ .ToList();
+ }
+ else
+ {
+ remaining = typeUnits
+ .Where(c => !committedUnitIds.Contains(c.Unit.UnitId))
+ .Where(c => c.Unit.StationGroupId == requirement.DepartmentGroupId)
+ .ToList();
+ }
+
+ if (remaining.Count >= requirement.MinimumAvailableCount)
+ return;
+
+ var donor = context.UnitCandidates
+ .Where(c => c.UnitTypeId == requirement.UnitTypeId.Value)
+ .Where(c => !committedUnitIds.Contains(c.Unit.UnitId))
+ .Where(c => c.Unit.StationGroupId != requirement.DepartmentGroupId)
+ .OrderBy(c => DistanceToStation(c, stationPoint))
+ .FirstOrDefault();
+
+ var typeName = context.UnitCandidates.FirstOrDefault(c => c.UnitTypeId == requirement.UnitTypeId.Value)?.UnitTypeName;
+
+ context.Result.MoveUps.Add(new MoveUpRecommendation
+ {
+ StationGroupId = requirement.DepartmentGroupId,
+ StationGroupName = station.Name,
+ UnitTypeId = requirement.UnitTypeId,
+ UnitTypeName = typeName,
+ MinimumRequired = requirement.MinimumAvailableCount,
+ AvailableAfterDispatch = remaining.Count,
+ SuggestedUnitId = donor?.Unit.UnitId,
+ SuggestedUnitName = donor?.Unit.Name,
+ FromStationGroupId = donor?.Unit.StationGroupId,
+ FromStationGroupName = donor?.Unit.StationGroup?.Name,
+ DistanceMeters = donor != null ? DistanceToStationOrNull(donor, stationPoint) : null
+ });
+
+ context.Result.Notes.Add($"Station '{station.Name}' drops below minimum coverage ({remaining.Count}/{requirement.MinimumAvailableCount} {typeName}); move-up recommended.");
+ }
+
+ private void EvaluateRoleCoverage(RecommendationContext context, StationCoverageRequirement requirement,
+ DepartmentGroup station, GeoMath.GeoPoint? stationPoint, HashSet committedUserIds)
+ {
+ var roleHolders = context.PersonnelCandidates
+ .Where(c => c.RoleIds.Contains(requirement.PersonnelRoleId.Value))
+ .ToList();
+
+ List remaining;
+ if (UseRadiusCoverage(context, requirement, stationPoint, roleHolders.Any(c => c.Latitude.HasValue)))
+ {
+ remaining = roleHolders
+ .Where(c => !committedUserIds.Contains(c.UserId))
+ .Where(c => c.Latitude.HasValue && c.Longitude.HasValue
+ && GeoMath.HaversineMeters(stationPoint.Value.Latitude, stationPoint.Value.Longitude, c.Latitude.Value, c.Longitude.Value) <= requirement.RadiusMeters.Value)
+ .ToList();
+ }
+ else
+ {
+ remaining = roleHolders
+ .Where(c => !committedUserIds.Contains(c.UserId))
+ .Where(c => c.StationGroupId == requirement.DepartmentGroupId)
+ .ToList();
+ }
+
+ if (remaining.Count >= requirement.MinimumAvailableCount)
+ return;
+
+ var donor = roleHolders
+ .Where(c => !committedUserIds.Contains(c.UserId))
+ .Where(c => c.StationGroupId != requirement.DepartmentGroupId)
+ .FirstOrDefault();
+
+ context.Result.MoveUps.Add(new MoveUpRecommendation
+ {
+ StationGroupId = requirement.DepartmentGroupId,
+ StationGroupName = station.Name,
+ PersonnelRoleId = requirement.PersonnelRoleId,
+ MinimumRequired = requirement.MinimumAvailableCount,
+ AvailableAfterDispatch = remaining.Count,
+ SuggestedUserId = donor?.UserId,
+ FromStationGroupId = donor?.StationGroupId,
+ FromStationGroupName = donor?.StationGroupName
+ });
+
+ context.Result.Notes.Add($"Station '{station.Name}' drops below minimum role coverage ({remaining.Count}/{requirement.MinimumAvailableCount}); move-up recommended.");
+ }
+
+ ///
+ /// Whether a coverage requirement should be measured by distance from the station
+ /// rather than by station assignment. RadiusMeters is a closest-unit concept —
+ /// station-based departments define "at this station" by assignment/geofence — so
+ /// the mode is checked explicitly instead of inferring it from whether candidates
+ /// happen to carry a fix (units are only located in closest-unit mode, personnel
+ /// carry ActionLog coordinates in both, which would otherwise split the behaviour).
+ /// Falls back to assignment when no candidate has a location, so a department with
+ /// no position data reports real coverage instead of a phantom gap.
+ ///
+ private static bool UseRadiusCoverage(RecommendationContext context, StationCoverageRequirement requirement,
+ GeoMath.GeoPoint? stationPoint, bool anyCandidateLocated)
+ {
+ return context.Result.ModeUsed == DispatchRecommendationModes.ClosestUnit
+ && requirement.RadiusMeters.HasValue
+ && requirement.RadiusMeters.Value > 0
+ && stationPoint.HasValue
+ && anyCandidateLocated;
+ }
+
+ private static double DistanceToStation(UnitCandidate candidate, GeoMath.GeoPoint? stationPoint)
+ {
+ return DistanceToStationOrNull(candidate, stationPoint) ?? double.MaxValue;
+ }
+
+ private static double? DistanceToStationOrNull(UnitCandidate candidate, GeoMath.GeoPoint? stationPoint)
+ {
+ if (!stationPoint.HasValue || !candidate.Latitude.HasValue || !candidate.Longitude.HasValue)
+ return null;
+
+ return GeoMath.HaversineMeters(stationPoint.Value.Latitude, stationPoint.Value.Longitude, candidate.Latitude.Value, candidate.Longitude.Value);
+ }
+
+ #endregion
+
+ #region Shortfalls
+
+ private void AddAllRequirementShortfalls(RecommendationContext context, RequirementShortfallReasons reason)
+ {
+ foreach (var requirement in context.Level.UnitRequirements ?? new List())
+ AddUnitShortfall(context, requirement, 0, reason);
+
+ foreach (var requirement in context.Level.RoleRequirements ?? new List())
+ AddRoleShortfall(context, requirement, 0, reason);
+ }
+
+ private void AddUnitShortfall(RecommendationContext context, RunCardUnitRequirement requirement, int filled, RequirementShortfallReasons reason)
+ {
+ // When staffing-gate exclusions thinned this unit type's pool, that is the
+ // actionable cause to surface, not the generic exhaustion reason.
+ if ((reason == RequirementShortfallReasons.NoCandidatesAvailable || reason == RequirementShortfallReasons.StationsExhausted)
+ && context.UnitTypesWithStaffingExclusions.Contains(requirement.UnitTypeId))
+ reason = RequirementShortfallReasons.UnitsNotStaffed;
+
+ context.Result.Shortfalls.Add(new RequirementShortfall
+ {
+ IsUnitRequirement = true,
+ RequirementId = requirement.RunCardUnitRequirementId,
+ TypeOrRoleId = requirement.UnitTypeId,
+ TypeOrRoleName = context.UnitCandidates.FirstOrDefault(c => c.UnitTypeId == requirement.UnitTypeId)?.UnitTypeName,
+ AlarmLevel = context.Level.AlarmLevel,
+ RequiredCount = requirement.RequiredCount,
+ FilledCount = filled,
+ Reason = reason
+ });
+ }
+
+ private void AddRoleShortfall(RecommendationContext context, RunCardRoleRequirement requirement, int filled, RequirementShortfallReasons reason)
+ {
+ context.Result.Shortfalls.Add(new RequirementShortfall
+ {
+ IsUnitRequirement = false,
+ RequirementId = requirement.RunCardRoleRequirementId,
+ TypeOrRoleId = requirement.PersonnelRoleId,
+ AlarmLevel = context.Level.AlarmLevel,
+ RequiredCount = requirement.RequiredCount,
+ FilledCount = filled,
+ Reason = reason
+ });
+ }
+
+ #endregion
+ }
+}
diff --git a/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs b/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs
new file mode 100644
index 000000000..7faec5c0e
--- /dev/null
+++ b/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Builds the spoken dispatch prompt for outbound voice calls. Shared between the
+ /// Twilio voice webhook (which plays the prompt) and the call broadcast worker
+ /// (which pre-warms the TTS audio while recipients' phones are still ringing).
+ /// The TTS cache key is a hash of the exact chunk text, so both sides must produce
+ /// byte-identical text and identical chunk boundaries for the pre-warm to count.
+ ///
+ public static class DispatchVoicePromptBuilder
+ {
+ public static string BuildDispatchPrompt(Call call, string address)
+ {
+ // Periods between the segments give the TTS engine sentence boundaries
+ // (Piper inserts 0.35s of silence per sentence), which keeps the priority,
+ // address and nature audibly separated instead of running together.
+ var nature = StringHelpers.StripHtmlTagsCharArray(call.NatureOfCall);
+ var prompt = !String.IsNullOrWhiteSpace(address)
+ ? string.Format("{0}, Priority {1}. Address {2}. Nature {3}", call.Name, call.GetPriorityText(), address, nature)
+ : string.Format("{0}, Priority {1}. Nature {2}", call.Name, call.GetPriorityText(), nature);
+
+ return prompt.EndsWith(".", StringComparison.Ordinal) || prompt.EndsWith("!", StringComparison.Ordinal) || prompt.EndsWith("?", StringComparison.Ordinal)
+ ? prompt
+ : $"{prompt}.";
+ }
+
+ public static async Task ResolveDispatchAddressAsync(Call call, IGeoLocationProvider geoLocationProvider, CancellationToken cancellationToken = default)
+ {
+ var address = call.Address;
+
+ if (String.IsNullOrWhiteSpace(address) && !string.IsNullOrWhiteSpace(call.GeoLocationData) && call.GeoLocationData.Length > 1)
+ {
+ try
+ {
+ string[] points = call.GeoLocationData.Split(char.Parse(","));
+
+ // Bound the reverse-geocode: it's an external HTTP call with no timeout of
+ // its own, and the webhook caller runs inside a Twilio request whose total
+ // budget is 15s. On timeout the catch swallows and the dispatch is spoken
+ // without an address. TryParse with InvariantCulture: malformed coordinates
+ // skip the lookup instead of throwing, and a comma-decimal server culture
+ // can't silently misread "47.606" as 47606.
+ if (points != null && points.Length == 2
+ && double.TryParse(points[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude)
+ && double.TryParse(points[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
+ address = await geoLocationProvider.GetAproxAddressFromLatLong(latitude, longitude)
+ .WaitAsync(TimeSpan.FromSeconds(2), cancellationToken);
+ }
+ catch
+ {
+ }
+ }
+
+ return String.IsNullOrWhiteSpace(address) ? call.Address : address;
+ }
+
+ public static IEnumerable ChunkText(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ yield break;
+
+ var normalized = Regex.Replace(text, @"\s+", " ").Trim();
+ var maxLength = TtsConfig.MaxTextLength > 0 ? TtsConfig.MaxTextLength : 1000;
+
+ if (normalized.Length <= maxLength)
+ {
+ yield return normalized;
+ yield break;
+ }
+
+ var sentences = Regex.Split(normalized, @"(?<=[\.\!\?])\s+")
+ .Where(sentence => !string.IsNullOrWhiteSpace(sentence));
+ var builder = new StringBuilder();
+
+ foreach (var sentence in sentences)
+ {
+ var trimmed = sentence.Trim();
+
+ if (trimmed.Length > maxLength)
+ {
+ foreach (var fragment in ChunkLongSentence(trimmed, maxLength))
+ {
+ if (builder.Length > 0)
+ {
+ yield return builder.ToString();
+ builder.Clear();
+ }
+
+ yield return fragment;
+ }
+
+ continue;
+ }
+
+ if (builder.Length == 0)
+ {
+ builder.Append(trimmed);
+ continue;
+ }
+
+ if (builder.Length + 1 + trimmed.Length <= maxLength)
+ {
+ builder.Append(' ').Append(trimmed);
+ continue;
+ }
+
+ yield return builder.ToString();
+ builder.Clear();
+ builder.Append(trimmed);
+ }
+
+ if (builder.Length > 0)
+ {
+ yield return builder.ToString();
+ }
+ }
+
+ private static IEnumerable ChunkLongSentence(string sentence, int maxLength)
+ {
+ var words = sentence.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var builder = new StringBuilder();
+
+ foreach (var word in words)
+ {
+ if (word.Length > maxLength)
+ {
+ if (builder.Length > 0)
+ {
+ yield return builder.ToString();
+ builder.Clear();
+ }
+
+ for (var index = 0; index < word.Length; index += maxLength)
+ {
+ yield return word.Substring(index, Math.Min(maxLength, word.Length - index));
+ }
+
+ continue;
+ }
+
+ if (builder.Length == 0)
+ {
+ builder.Append(word);
+ continue;
+ }
+
+ if (builder.Length + 1 + word.Length <= maxLength)
+ {
+ builder.Append(' ').Append(word);
+ continue;
+ }
+
+ yield return builder.ToString();
+ builder.Clear();
+ builder.Append(word);
+ }
+
+ if (builder.Length > 0)
+ {
+ yield return builder.ToString();
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/GeoService.cs b/Core/Resgrid.Services/GeoService.cs
index 64f749f8d..9655ac9c6 100644
--- a/Core/Resgrid.Services/GeoService.cs
+++ b/Core/Resgrid.Services/GeoService.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
+using System.Linq;
using System.Threading.Tasks;
using Resgrid.Model;
using Resgrid.Model.Providers;
@@ -98,8 +100,70 @@ public async Task GetEtaInSecondsAsync(string start, string destination)
{
return route.Seconds;
}
-
+
return -1;
}
+
+ public async Task GetStationCoordinatesAsync(DepartmentGroup group)
+ {
+ if (group == null)
+ return null;
+
+ var stored = GeoMath.ParseCoordinatePair(group.Latitude, group.Longitude);
+ if (stored.HasValue)
+ return stored;
+
+ var polygon = GeoMath.ParseGeofence(group.Geofence);
+ if (polygon != null)
+ return GeoMath.Centroid(polygon);
+
+ var geocoded = await _departmentGroupsService.GetMapCenterCoordinatesForGroupAsync(group.DepartmentGroupId);
+ if (geocoded != null && geocoded.Latitude.HasValue && geocoded.Longitude.HasValue
+ && !(geocoded.Latitude.Value == 0 && geocoded.Longitude.Value == 0))
+ return new GeoMath.GeoPoint(geocoded.Latitude.Value, geocoded.Longitude.Value);
+
+ return null;
+ }
+
+ public async Task> GetStationsContainingPointAsync(int departmentId, double latitude, double longitude)
+ {
+ var stations = await OrderStationsByDistanceAsync(departmentId, latitude, longitude);
+
+ return stations.Where(s => s.ContainsPoint).ToList();
+ }
+
+ public async Task> OrderStationsByDistanceAsync(int departmentId, double latitude, double longitude)
+ {
+ var results = new List();
+ var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(departmentId);
+
+ if (stations == null)
+ return results;
+
+ foreach (var station in stations)
+ {
+ var polygon = GeoMath.ParseGeofence(station.Geofence);
+ var coordinates = await GetStationCoordinatesAsync(station);
+
+ // A station with neither coordinates nor a fence can't participate in
+ // distance ordering or containment; skip it rather than guessing.
+ if (coordinates == null && polygon == null)
+ continue;
+
+ var stationPoint = coordinates ?? GeoMath.Centroid(polygon);
+
+ results.Add(new StationDistanceResult
+ {
+ Station = station,
+ Latitude = stationPoint.Latitude,
+ Longitude = stationPoint.Longitude,
+ DistanceMeters = GeoMath.HaversineMeters(latitude, longitude, stationPoint.Latitude, stationPoint.Longitude),
+ HasGeofence = polygon != null,
+ ContainsPoint = polygon != null && GeoMath.IsPointInPolygon(latitude, longitude, polygon)
+ });
+ }
+
+ return results.OrderBy(r => r.DistanceMeters).ToList();
+ }
}
}
diff --git a/Core/Resgrid.Services/PersonnelLocationResolver.cs b/Core/Resgrid.Services/PersonnelLocationResolver.cs
new file mode 100644
index 000000000..7d0e990cf
--- /dev/null
+++ b/Core/Resgrid.Services/PersonnelLocationResolver.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class PersonnelLocationResolver : IPersonnelLocationResolver
+ {
+ private readonly IUsersService _usersService;
+ private readonly IActionLogsService _actionLogsService;
+
+ public PersonnelLocationResolver(IUsersService usersService, IActionLogsService actionLogsService)
+ {
+ _usersService = usersService;
+ _actionLogsService = actionLogsService;
+ }
+
+ public async Task> GetLatestLocationsAsync(int departmentId, int maxAgeSeconds, DateTime? utcNow = null)
+ {
+ var now = utcNow ?? DateTime.UtcNow;
+ var results = new Dictionary();
+
+ var documentLocations = await _usersService.GetLatestLocationsForDepartmentPersonnelAsync(departmentId);
+
+ if (documentLocations != null)
+ {
+ foreach (var location in documentLocations)
+ {
+ if (location == null || string.IsNullOrWhiteSpace(location.UserId))
+ continue;
+
+ if (location.Latitude == 0 && location.Longitude == 0)
+ continue;
+
+ AddIfFresher(results, location.UserId, (double)location.Latitude, (double)location.Longitude, location.Timestamp);
+ }
+ }
+
+ // ActionLog coordinates (status reports with a fix) as a fallback source —
+ // they may be fresher than the doc store for members without the app's
+ // background tracking enabled.
+ var actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(departmentId);
+
+ if (actionLogs != null)
+ {
+ foreach (var log in actionLogs)
+ {
+ if (log == null || string.IsNullOrWhiteSpace(log.UserId))
+ continue;
+
+ var coordinates = log.GetCoordinates();
+
+ if (coordinates == null || !coordinates.Latitude.HasValue || !coordinates.Longitude.HasValue)
+ continue;
+
+ AddIfFresher(results, log.UserId, coordinates.Latitude.Value, coordinates.Longitude.Value, log.Timestamp);
+ }
+ }
+
+ if (maxAgeSeconds > 0)
+ {
+ foreach (var resolved in results.Values)
+ resolved.IsStale = (now - resolved.Timestamp).TotalSeconds > maxAgeSeconds;
+ }
+
+ return results;
+ }
+
+ private static void AddIfFresher(Dictionary results, string userId, double latitude, double longitude, DateTime timestamp)
+ {
+ if (results.TryGetValue(userId, out var existing) && existing.Timestamp >= timestamp)
+ return;
+
+ results[userId] = new ResolvedPersonnelLocation
+ {
+ UserId = userId,
+ Latitude = latitude,
+ Longitude = longitude,
+ Timestamp = timestamp
+ };
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/RunCardsService.cs b/Core/Resgrid.Services/RunCardsService.cs
new file mode 100644
index 000000000..ceaf9efc5
--- /dev/null
+++ b/Core/Resgrid.Services/RunCardsService.cs
@@ -0,0 +1,628 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class RunCardsService : IRunCardsService
+ {
+ private const string RunCardsCacheKey = "RunCardsForDep_{0}";
+ private static readonly TimeSpan CacheLength = TimeSpan.FromDays(7);
+
+ private readonly IRunCardsRepository _runCardsRepository;
+ private readonly IRunCardTriggersRepository _runCardTriggersRepository;
+ private readonly IRunCardAlarmLevelsRepository _runCardAlarmLevelsRepository;
+ private readonly IRunCardUnitRequirementsRepository _runCardUnitRequirementsRepository;
+ private readonly IRunCardRoleRequirementsRepository _runCardRoleRequirementsRepository;
+ private readonly IRunCardAvailabilitySelectionsRepository _runCardAvailabilitySelectionsRepository;
+ private readonly IStationCoverageRequirementsRepository _stationCoverageRequirementsRepository;
+ private readonly ICallTypesRepository _callTypesRepository;
+ private readonly ICacheProvider _cacheProvider;
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly IUnitsService _unitsService;
+ private readonly IPersonnelRolesService _personnelRolesService;
+ private readonly IDepartmentGroupsService _departmentGroupsService;
+ private readonly ICustomStateService _customStateService;
+
+ public RunCardsService(IRunCardsRepository runCardsRepository, IRunCardTriggersRepository runCardTriggersRepository,
+ IRunCardAlarmLevelsRepository runCardAlarmLevelsRepository, IRunCardUnitRequirementsRepository runCardUnitRequirementsRepository,
+ IRunCardRoleRequirementsRepository runCardRoleRequirementsRepository, IRunCardAvailabilitySelectionsRepository runCardAvailabilitySelectionsRepository,
+ IStationCoverageRequirementsRepository stationCoverageRequirementsRepository, ICallTypesRepository callTypesRepository,
+ ICacheProvider cacheProvider, IUnitOfWork unitOfWork, IUnitsService unitsService,
+ IPersonnelRolesService personnelRolesService, IDepartmentGroupsService departmentGroupsService,
+ ICustomStateService customStateService)
+ {
+ _runCardsRepository = runCardsRepository;
+ _runCardTriggersRepository = runCardTriggersRepository;
+ _runCardAlarmLevelsRepository = runCardAlarmLevelsRepository;
+ _runCardUnitRequirementsRepository = runCardUnitRequirementsRepository;
+ _runCardRoleRequirementsRepository = runCardRoleRequirementsRepository;
+ _runCardAvailabilitySelectionsRepository = runCardAvailabilitySelectionsRepository;
+ _stationCoverageRequirementsRepository = stationCoverageRequirementsRepository;
+ _callTypesRepository = callTypesRepository;
+ _cacheProvider = cacheProvider;
+ _unitOfWork = unitOfWork;
+ _unitsService = unitsService;
+ _personnelRolesService = personnelRolesService;
+ _departmentGroupsService = departmentGroupsService;
+ _customStateService = customStateService;
+ }
+
+ public async Task> GetAllRunCardsForDepartmentAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task> getRunCards()
+ {
+ var cards = await _runCardsRepository.GetAllByDepartmentIdAsync(departmentId);
+
+ if (cards == null)
+ return new List();
+
+ var list = cards.ToList();
+ foreach (var card in list)
+ await HydrateRunCardAsync(card);
+
+ return list;
+ }
+
+ if (!bypassCache && Config.SystemBehaviorConfig.CacheEnabled)
+ return await _cacheProvider.RetrieveAsync(string.Format(RunCardsCacheKey, departmentId), getRunCards, CacheLength);
+
+ return await getRunCards();
+ }
+
+ public async Task GetRunCardByIdAsync(int runCardId)
+ {
+ var card = await _runCardsRepository.GetByIdAsync(runCardId);
+
+ if (card == null)
+ return null;
+
+ await HydrateRunCardAsync(card);
+
+ return card;
+ }
+
+ public async Task SaveRunCardAsync(RunCard runCard, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (runCard == null)
+ throw new ArgumentNullException(nameof(runCard));
+
+ // Alarm levels are 1-based and one row per level per card (enforced by
+ // UX_RunCardAlarmLevels_Card_Level). Catch both here so an API client gets a
+ // stated reason instead of a unique-index violation, and so a level below 1 —
+ // which the engine can never match, since escalation starts at 1 — cannot be
+ // stored as silently dead configuration.
+ if (runCard.AlarmLevels != null)
+ {
+ if (runCard.AlarmLevels.Any(l => l.AlarmLevel < 1))
+ throw new ArgumentException("Run card alarm levels start at 1.", nameof(runCard));
+
+ if (runCard.AlarmLevels.GroupBy(l => l.AlarmLevel).Any(g => g.Count() > 1))
+ throw new ArgumentException("A run card cannot define the same alarm level twice.", nameof(runCard));
+ }
+
+ await ValidateRunCardReferencesAsync(runCard);
+ await ValidateRunCardChildOwnershipAsync(runCard);
+
+ var isNew = runCard.RunCardId == 0;
+
+ // Snapshot the incoming graph, then persist the header without letting the
+ // repository cascade (child sync is managed explicitly below so nested
+ // requirement rows and level renumbering behave deterministically).
+ var triggers = runCard.Triggers?.ToList() ?? new List();
+ var alarmLevels = runCard.AlarmLevels?.ToList() ?? new List();
+ var selections = runCard.AvailabilitySelections?.ToList() ?? new List();
+
+ // The header and five child tables are rewritten as one graph; a failure part way
+ // through would otherwise leave, say, the old triggers deleted and the new ones
+ // unwritten — a card that matches nothing — or alarm levels gone with their
+ // requirements orphaned.
+ _unitOfWork.CreateOrGetConnection();
+ try
+ {
+ await _runCardsRepository.SaveOrUpdateAsync(runCard, cancellationToken, true);
+
+ // Triggers
+ var existingTriggers = isNew
+ ? new List()
+ : (await _runCardTriggersRepository.GetTriggersByRunCardIdAsync(runCard.RunCardId)).ToList();
+
+ foreach (var removed in existingTriggers.Where(e => triggers.All(t => t.RunCardTriggerId != e.RunCardTriggerId)))
+ await _runCardTriggersRepository.DeleteAsync(removed, cancellationToken);
+
+ foreach (var trigger in triggers)
+ {
+ trigger.RunCardId = runCard.RunCardId;
+ await _runCardTriggersRepository.SaveOrUpdateAsync(trigger, cancellationToken, true);
+ }
+
+ // Alarm levels + their requirements
+ var existingLevels = isNew
+ ? new List()
+ : (await _runCardAlarmLevelsRepository.GetAlarmLevelsByRunCardIdAsync(runCard.RunCardId)).ToList();
+ var existingUnitReqs = isNew
+ ? new List()
+ : (await _runCardUnitRequirementsRepository.GetUnitRequirementsByRunCardIdAsync(runCard.RunCardId)).ToList();
+ var existingRoleReqs = isNew
+ ? new List()
+ : (await _runCardRoleRequirementsRepository.GetRoleRequirementsByRunCardIdAsync(runCard.RunCardId)).ToList();
+
+ foreach (var removedLevel in existingLevels.Where(e => alarmLevels.All(l => l.RunCardAlarmLevelId != e.RunCardAlarmLevelId)))
+ {
+ foreach (var req in existingUnitReqs.Where(r => r.RunCardAlarmLevelId == removedLevel.RunCardAlarmLevelId))
+ await _runCardUnitRequirementsRepository.DeleteAsync(req, cancellationToken);
+
+ foreach (var req in existingRoleReqs.Where(r => r.RunCardAlarmLevelId == removedLevel.RunCardAlarmLevelId))
+ await _runCardRoleRequirementsRepository.DeleteAsync(req, cancellationToken);
+
+ await _runCardAlarmLevelsRepository.DeleteAsync(removedLevel, cancellationToken);
+ }
+
+ foreach (var level in alarmLevels)
+ {
+ var unitReqs = level.UnitRequirements?.ToList() ?? new List();
+ var roleReqs = level.RoleRequirements?.ToList() ?? new List();
+
+ level.RunCardId = runCard.RunCardId;
+ await _runCardAlarmLevelsRepository.SaveOrUpdateAsync(level, cancellationToken, true);
+
+ foreach (var removed in existingUnitReqs.Where(e => e.RunCardAlarmLevelId == level.RunCardAlarmLevelId
+ && unitReqs.All(r => r.RunCardUnitRequirementId != e.RunCardUnitRequirementId)))
+ await _runCardUnitRequirementsRepository.DeleteAsync(removed, cancellationToken);
+
+ foreach (var req in unitReqs)
+ {
+ req.RunCardAlarmLevelId = level.RunCardAlarmLevelId;
+ await _runCardUnitRequirementsRepository.SaveOrUpdateAsync(req, cancellationToken, true);
+ }
+
+ foreach (var removed in existingRoleReqs.Where(e => e.RunCardAlarmLevelId == level.RunCardAlarmLevelId
+ && roleReqs.All(r => r.RunCardRoleRequirementId != e.RunCardRoleRequirementId)))
+ await _runCardRoleRequirementsRepository.DeleteAsync(removed, cancellationToken);
+
+ foreach (var req in roleReqs)
+ {
+ req.RunCardAlarmLevelId = level.RunCardAlarmLevelId;
+ await _runCardRoleRequirementsRepository.SaveOrUpdateAsync(req, cancellationToken, true);
+ }
+ }
+
+ // Availability selections
+ var existingSelections = isNew
+ ? new List()
+ : (await _runCardAvailabilitySelectionsRepository.GetSelectionsByRunCardIdAsync(runCard.RunCardId)).ToList();
+
+ foreach (var removed in existingSelections.Where(e => selections.All(s => s.RunCardAvailabilitySelectionId != e.RunCardAvailabilitySelectionId)))
+ await _runCardAvailabilitySelectionsRepository.DeleteAsync(removed, cancellationToken);
+
+ foreach (var selection in selections)
+ {
+ selection.RunCardId = runCard.RunCardId;
+ await _runCardAvailabilitySelectionsRepository.SaveOrUpdateAsync(selection, cancellationToken, true);
+ }
+
+ _unitOfWork.CommitChanges();
+ }
+ catch
+ {
+ _unitOfWork.DiscardChanges();
+ throw;
+ }
+
+ // Invalidate only once the graph is committed, so a reader cannot repopulate the
+ // cache from a transaction that later rolls back.
+ await InvalidateRunCardsInCacheAsync(runCard.DepartmentId);
+
+ return runCard;
+ }
+
+ ///
+ /// The child rows are written with SaveOrUpdateAsync, which treats a non-zero primary
+ /// key as an update keyed on that id alone — nothing scopes the statement to this card.
+ /// A submitted id belonging to another card would therefore be rewritten and reparented
+ /// here, across departments, so every non-zero child id is required to already be part
+ /// of this card's stored graph. On a new card the ids are cleared instead, since there
+ /// is no graph to belong to and every child must insert.
+ ///
+ private async Task ValidateRunCardChildOwnershipAsync(RunCard runCard)
+ {
+ var triggers = runCard.Triggers ?? new List();
+ var levels = runCard.AlarmLevels ?? new List();
+ var selections = runCard.AvailabilitySelections ?? new List();
+ var unitRequirements = levels.SelectMany(l => l.UnitRequirements ?? new List()).ToList();
+ var roleRequirements = levels.SelectMany(l => l.RoleRequirements ?? new List()).ToList();
+
+ if (runCard.RunCardId == 0)
+ {
+ foreach (var trigger in triggers)
+ trigger.RunCardTriggerId = 0;
+
+ foreach (var level in levels)
+ level.RunCardAlarmLevelId = 0;
+
+ foreach (var requirement in unitRequirements)
+ requirement.RunCardUnitRequirementId = 0;
+
+ foreach (var requirement in roleRequirements)
+ requirement.RunCardRoleRequirementId = 0;
+
+ foreach (var selection in selections)
+ selection.RunCardAvailabilitySelectionId = 0;
+
+ return;
+ }
+
+ var stored = await GetRunCardByIdAsync(runCard.RunCardId);
+
+ if (stored == null || stored.DepartmentId != runCard.DepartmentId)
+ throw new ArgumentException("The run card does not belong to this department.", nameof(runCard));
+
+ var storedLevels = stored.AlarmLevels ?? new List();
+
+ var storedTriggerIds = new HashSet((stored.Triggers ?? new List()).Select(t => t.RunCardTriggerId));
+ var storedLevelIds = new HashSet(storedLevels.Select(l => l.RunCardAlarmLevelId));
+ var storedUnitRequirementIds = new HashSet(storedLevels.SelectMany(l => l.UnitRequirements ?? new List()).Select(r => r.RunCardUnitRequirementId));
+ var storedRoleRequirementIds = new HashSet(storedLevels.SelectMany(l => l.RoleRequirements ?? new List()).Select(r => r.RunCardRoleRequirementId));
+ var storedSelectionIds = new HashSet((stored.AvailabilitySelections ?? new List()).Select(s => s.RunCardAvailabilitySelectionId));
+
+ if (triggers.Any(t => t.RunCardTriggerId != 0 && !storedTriggerIds.Contains(t.RunCardTriggerId)))
+ throw new ArgumentException("A trigger does not belong to this run card.", nameof(runCard));
+
+ if (levels.Any(l => l.RunCardAlarmLevelId != 0 && !storedLevelIds.Contains(l.RunCardAlarmLevelId)))
+ throw new ArgumentException("An alarm level does not belong to this run card.", nameof(runCard));
+
+ if (unitRequirements.Any(r => r.RunCardUnitRequirementId != 0 && !storedUnitRequirementIds.Contains(r.RunCardUnitRequirementId)))
+ throw new ArgumentException("A unit type requirement does not belong to this run card.", nameof(runCard));
+
+ if (roleRequirements.Any(r => r.RunCardRoleRequirementId != 0 && !storedRoleRequirementIds.Contains(r.RunCardRoleRequirementId)))
+ throw new ArgumentException("A personnel role requirement does not belong to this run card.", nameof(runCard));
+
+ if (selections.Any(s => s.RunCardAvailabilitySelectionId != 0 && !storedSelectionIds.Contains(s.RunCardAvailabilitySelectionId)))
+ throw new ArgumentException("A status selection does not belong to this run card.", nameof(runCard));
+ }
+
+ ///
+ /// Every id on a run card comes from the client, so each one is checked against the
+ /// card's own department before it is stored. A foreign HomeStationGroupId is the
+ /// sharpest case — the engine dereferences it to anchor the station cascade without
+ /// a department check of its own, which would let another department's station
+ /// coordinates steer selection. The rest would store as configuration that silently
+ /// matches nothing, since the engine only ever scores against its own department's
+ /// units, roles and statuses.
+ ///
+ private async Task ValidateRunCardReferencesAsync(RunCard runCard)
+ {
+ if (runCard.HomeStationGroupId.HasValue)
+ {
+ var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(runCard.DepartmentId);
+
+ if (stations == null || stations.All(s => s.DepartmentGroupId != runCard.HomeStationGroupId.Value))
+ throw new ArgumentException("The home station does not belong to this department.", nameof(runCard));
+ }
+
+ var triggerCallTypeIds = (runCard.Triggers ?? new List())
+ .Where(t => t.CallTypeId.HasValue)
+ .Select(t => t.CallTypeId.Value)
+ .Distinct()
+ .ToList();
+
+ if (triggerCallTypeIds.Any())
+ {
+ var callTypes = await _callTypesRepository.GetAllByDepartmentIdAsync(runCard.DepartmentId);
+ var callTypeIds = new HashSet((callTypes ?? Enumerable.Empty()).Select(t => t.CallTypeId));
+
+ if (triggerCallTypeIds.Any(id => !callTypeIds.Contains(id)))
+ throw new ArgumentException("A trigger references a call type from another department.", nameof(runCard));
+ }
+
+ var levels = runCard.AlarmLevels ?? new List();
+ var selections = runCard.AvailabilitySelections ?? new List();
+
+ var referencedUnitTypeIds = levels
+ .SelectMany(l => l.UnitRequirements ?? new List