From 4f968e34cfd1c799fa8fa0783965f6c330581e01 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 13 Aug 2026 17:24:12 -0700 Subject: [PATCH 1/3] RG-T54 Bug fixes, first pass of Run Cards --- .../Areas/User/Department/Department.ar.resx | 204 +++ .../Areas/User/Department/Department.de.resx | 204 +++ .../Areas/User/Department/Department.en.resx | 204 +++ .../Areas/User/Department/Department.es.resx | 204 +++ .../Areas/User/Department/Department.fr.resx | 204 +++ .../Areas/User/Department/Department.it.resx | 204 +++ .../Areas/User/Department/Department.pl.resx | 204 +++ .../Areas/User/Department/Department.resx | 204 +++ .../Areas/User/Department/Department.sv.resx | 204 +++ .../Areas/User/Department/Department.uk.resx | 204 +++ Core/Resgrid.Model/Call.cs | 16 +- Core/Resgrid.Model/DepartmentSettingTypes.cs | 3 + Core/Resgrid.Model/DispatchRecommendation.cs | 215 +++ .../DispatchRecommendationConfig.cs | 73 + .../DispatchRecommendationModes.cs | 19 + Core/Resgrid.Model/Events/RunCardEvents.cs | 50 + Core/Resgrid.Model/FeatureFlagKeys.cs | 6 + Core/Resgrid.Model/GeoMath.cs | 202 +++ .../IRunCardActivationsRepository.cs | 10 + .../IRunCardAlarmLevelsRepository.cs | 10 + ...RunCardAvailabilitySelectionsRepository.cs | 10 + .../IRunCardRoleRequirementsRepository.cs | 10 + .../IRunCardTriggersRepository.cs | 12 + .../IRunCardUnitRequirementsRepository.cs | 10 + .../Repositories/IRunCardsRepository.cs | 14 + .../IStationCoverageRequirementsRepository.cs | 6 + .../ResolvedPersonnelLocation.cs | 23 + Core/Resgrid.Model/RunCard.cs | 97 ++ Core/Resgrid.Model/RunCardActivation.cs | 69 + Core/Resgrid.Model/RunCardAlarmLevel.cs | 59 + .../RunCardAvailabilitySelection.cs | 66 + Core/Resgrid.Model/RunCardRoleRequirement.cs | 53 + Core/Resgrid.Model/RunCardSelectionTypes.cs | 13 + Core/Resgrid.Model/RunCardTrigger.cs | 60 + Core/Resgrid.Model/RunCardTriggerTypes.cs | 13 + Core/Resgrid.Model/RunCardUnitRequirement.cs | 53 + .../Services/IDepartmentSettingsService.cs | 15 + .../IDispatchRecommendationService.cs | 42 + Core/Resgrid.Model/Services/IGeoService.cs | 23 +- .../Services/IPersonnelLocationResolver.cs | 21 + .../Services/IRunCardsService.cs | 50 + .../StationCoverageRequirement.cs | 65 + Core/Resgrid.Model/StationDistanceResult.cs | 26 + Core/Resgrid.Model/UnitLastDispatchTime.cs | 15 + Core/Resgrid.Model/UserLastDispatchTime.cs | 15 + .../WorkflowTemplateVariableCatalog.cs | 45 + .../Resgrid.Model/WorkflowTriggerEventType.cs | 8 +- Core/Resgrid.Services/ChatChannelService.cs | 5 + .../DepartmentGroupsService.cs | 7 +- .../DepartmentSettingsService.cs | 100 ++ Core/Resgrid.Services/DepartmentsService.cs | 4 +- .../DispatchRecommendationService.cs | 1301 +++++++++++++++++ Core/Resgrid.Services/GeoService.cs | 66 +- .../PersonnelLocationResolver.cs | 85 ++ Core/Resgrid.Services/RunCardsService.cs | 385 +++++ Core/Resgrid.Services/ServicesModule.cs | 3 + Core/Resgrid.Services/SystemAuditsService.cs | 16 + .../WorkflowSampleDataGenerator.cs | 41 + .../WorkflowTemplateContextBuilder.cs | 65 + .../WorkflowEventProvider.cs | 6 + .../M0114_WidenSystemAuditsDataColumn.cs | 25 + .../Migrations/M0115_AddRunCards.cs | 179 +++ .../M0116_SeedRunCardsFeatureFlag.cs | 37 + .../Migrations/M0117_AddRunCardActivations.cs | 45 + .../M0114_WidenSystemAuditsDataColumnPg.cs | 20 + .../Migrations/M0115_AddRunCardsPg.cs | 179 +++ .../M0116_SeedRunCardsFeatureFlagPg.cs | 38 + .../M0117_AddRunCardActivationsPg.cs | 45 + .../Configs/SqlConfiguration.cs | 20 + .../Modules/ApiDataModule.cs | 10 + .../Modules/DataModule.cs | 10 + .../Modules/NonWebDataModule.cs | 10 + .../Modules/TestingDataModule.cs | 10 + ...tLastUnitDispatchTimesByDepartmentQuery.cs | 33 + ...tLastUserDispatchTimesByDepartmentQuery.cs | 33 + .../SelectRunCardActivationsByCallIdQuery.cs | 33 + ...electRunCardAlarmLevelsByRunCardIdQuery.cs | 33 + ...dAvailabilitySelectionsByRunCardIdQuery.cs | 33 + ...RunCardRoleRequirementsByRunCardIdQuery.cs | 33 + ...electRunCardTriggersByDepartmentIdQuery.cs | 33 + .../SelectRunCardTriggersByRunCardIdQuery.cs | 33 + ...RunCardUnitRequirementsByRunCardIdQuery.cs | 33 + .../RepositoryBase.cs | 6 + .../RunCardActivationsRepository.cs | 70 + .../RunCardAlarmLevelsRepository.cs | 70 + ...RunCardAvailabilitySelectionsRepository.cs | 70 + .../RunCardRoleRequirementsRepository.cs | 70 + .../RunCardTriggersRepository.cs | 106 ++ .../RunCardUnitRequirementsRepository.cs | 70 + .../RunCardsRepository.cs | 106 ++ .../PostgreSql/PostgreSqlConfiguration.cs | 59 + .../SqlServer/SqlServerConfiguration.cs | 59 + .../StationCoverageRequirementsRepository.cs | 17 + Tests/Resgrid.Tests/Models/CallTests.cs | 50 + .../Services/ChatChannelServiceTests.cs | 13 + .../DispatchRecommendationServiceTests.cs | 498 +++++++ Tests/Resgrid.Tests/Services/GeoMathTests.cs | 185 +++ .../Services/RunCardsServiceTests.cs | 162 ++ .../Web/Services/CallsControllerTests.cs | 92 +- .../TwilioControllerVoiceVerificationTests.cs | 3 +- .../Web/User/ProtocolsControllerTests.cs | 101 ++ .../Controllers/EmailController.cs | 29 +- .../Controllers/SignalWireController.cs | 28 +- .../Controllers/TwilioController.cs | 26 +- .../Controllers/v4/CallsController.cs | 100 +- .../Controllers/v4/ChatController.cs | 3 + .../Controllers/v4/ConfigController.cs | 25 +- .../Controllers/v4/RunCardsController.cs | 273 ++++ .../Models/v4/Configs/GetConfigResult.cs | 15 + .../Models/v4/RunCards/RunCardApiModels.cs | 105 ++ .../Resgrid.Web.Services.xml | 1266 +++++++++------- .../User/Apps/src/runtime/customElement.tsx | 42 +- .../User/Controllers/DepartmentController.cs | 136 +- .../User/Controllers/DispatchController.cs | 124 +- .../User/Controllers/GroupsController.cs | 14 + .../User/Controllers/ProtocolsController.cs | 2 +- .../User/Controllers/RunCardsController.cs | 321 ++++ .../Departments/DispatchSettingsView.cs | 22 + .../User/Models/RunCards/RunCardModels.cs | 105 ++ .../Views/Department/DispatchSettings.cshtml | 192 +++ .../User/Views/Department/Settings.cshtml | 5 + .../Areas/User/Views/Dispatch/NewCall.cshtml | 6 + .../Areas/User/Views/Dispatch/ViewCall.cshtml | 27 + .../Areas/User/Views/RunCards/Edit.cshtml | 484 ++++++ .../Areas/User/Views/RunCards/Index.cshtml | 91 ++ .../dispatch/resgrid.dispatch.newcall.js | 82 ++ .../Tasks/DispatchScheduledCallsTask.cs | 27 +- .../Logic/CallEmailImporterLogic.cs | 23 + 128 files changed, 11360 insertions(+), 566 deletions(-) create mode 100644 Core/Resgrid.Model/DispatchRecommendation.cs create mode 100644 Core/Resgrid.Model/DispatchRecommendationConfig.cs create mode 100644 Core/Resgrid.Model/DispatchRecommendationModes.cs create mode 100644 Core/Resgrid.Model/Events/RunCardEvents.cs create mode 100644 Core/Resgrid.Model/GeoMath.cs create mode 100644 Core/Resgrid.Model/Repositories/IRunCardActivationsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRunCardAlarmLevelsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRunCardAvailabilitySelectionsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRunCardRoleRequirementsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRunCardTriggersRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRunCardUnitRequirementsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IRunCardsRepository.cs create mode 100644 Core/Resgrid.Model/Repositories/IStationCoverageRequirementsRepository.cs create mode 100644 Core/Resgrid.Model/ResolvedPersonnelLocation.cs create mode 100644 Core/Resgrid.Model/RunCard.cs create mode 100644 Core/Resgrid.Model/RunCardActivation.cs create mode 100644 Core/Resgrid.Model/RunCardAlarmLevel.cs create mode 100644 Core/Resgrid.Model/RunCardAvailabilitySelection.cs create mode 100644 Core/Resgrid.Model/RunCardRoleRequirement.cs create mode 100644 Core/Resgrid.Model/RunCardSelectionTypes.cs create mode 100644 Core/Resgrid.Model/RunCardTrigger.cs create mode 100644 Core/Resgrid.Model/RunCardTriggerTypes.cs create mode 100644 Core/Resgrid.Model/RunCardUnitRequirement.cs create mode 100644 Core/Resgrid.Model/Services/IDispatchRecommendationService.cs create mode 100644 Core/Resgrid.Model/Services/IPersonnelLocationResolver.cs create mode 100644 Core/Resgrid.Model/Services/IRunCardsService.cs create mode 100644 Core/Resgrid.Model/StationCoverageRequirement.cs create mode 100644 Core/Resgrid.Model/StationDistanceResult.cs create mode 100644 Core/Resgrid.Model/UnitLastDispatchTime.cs create mode 100644 Core/Resgrid.Model/UserLastDispatchTime.cs create mode 100644 Core/Resgrid.Services/DispatchRecommendationService.cs create mode 100644 Core/Resgrid.Services/PersonnelLocationResolver.cs create mode 100644 Core/Resgrid.Services/RunCardsService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0114_WidenSystemAuditsDataColumn.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0115_AddRunCards.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0116_SeedRunCardsFeatureFlag.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0117_AddRunCardActivations.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0114_WidenSystemAuditsDataColumnPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0115_AddRunCardsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0116_SeedRunCardsFeatureFlagPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0117_AddRunCardActivationsPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUnitDispatchTimesByDepartmentQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUserDispatchTimesByDepartmentQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardActivationsByCallIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAlarmLevelsByRunCardIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAvailabilitySelectionsByRunCardIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardRoleRequirementsByRunCardIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByDepartmentIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByRunCardIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardUnitRequirementsByRunCardIdQuery.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RunCardActivationsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RunCardAlarmLevelsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RunCardAvailabilitySelectionsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RunCardRoleRequirementsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RunCardTriggersRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RunCardUnitRequirementsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/RunCardsRepository.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/StationCoverageRequirementsRepository.cs create mode 100644 Tests/Resgrid.Tests/Models/CallTests.cs create mode 100644 Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Services/GeoMathTests.cs create mode 100644 Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/ProtocolsControllerTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardApiModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/RunCards/RunCardModels.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/RunCards/Edit.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/RunCards/Index.cshtml 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..55b46df14 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] @@ -332,7 +346,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..f756c6b92 --- /dev/null +++ b/Core/Resgrid.Model/DispatchRecommendationConfig.cs @@ -0,0 +1,73 @@ +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; + + 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..0f4eab6ea --- /dev/null +++ b/Core/Resgrid.Model/GeoMath.cs @@ -0,0 +1,202 @@ +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) + 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; + + return new GeoPoint(lat, lon); + } + + /// + /// 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..13037b45f 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); 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..f5f971295 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); @@ -837,6 +840,94 @@ 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 config; + } + catch (Exception) + { + // A corrupt setting blob falls back to defaults rather than breaking dispatch. + } + } + + return new DispatchRecommendationConfig(); + } + + 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 +1150,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..c5b1f2d3b --- /dev/null +++ b/Core/Resgrid.Services/DispatchRecommendationService.cs @@ -0,0 +1,1301 @@ +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 + }; + + 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; } + 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) + { + 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) + { + 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) + { + 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, 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 (requirement.RadiusMeters.HasValue && requirement.RadiusMeters.Value > 0 && stationPoint.HasValue + && 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, HashSet committedUserIds) + { + var roleHolders = context.PersonnelCandidates + .Where(c => c.RoleIds.Contains(requirement.PersonnelRoleId.Value)) + .ToList(); + + var 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."); + } + + 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/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..23823978b --- /dev/null +++ b/Core/Resgrid.Services/RunCardsService.cs @@ -0,0 +1,385 @@ +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.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; + + public RunCardsService(IRunCardsRepository runCardsRepository, IRunCardTriggersRepository runCardTriggersRepository, + IRunCardAlarmLevelsRepository runCardAlarmLevelsRepository, IRunCardUnitRequirementsRepository runCardUnitRequirementsRepository, + IRunCardRoleRequirementsRepository runCardRoleRequirementsRepository, IRunCardAvailabilitySelectionsRepository runCardAvailabilitySelectionsRepository, + IStationCoverageRequirementsRepository stationCoverageRequirementsRepository, ICallTypesRepository callTypesRepository, + ICacheProvider cacheProvider) + { + _runCardsRepository = runCardsRepository; + _runCardTriggersRepository = runCardTriggersRepository; + _runCardAlarmLevelsRepository = runCardAlarmLevelsRepository; + _runCardUnitRequirementsRepository = runCardUnitRequirementsRepository; + _runCardRoleRequirementsRepository = runCardRoleRequirementsRepository; + _runCardAvailabilitySelectionsRepository = runCardAvailabilitySelectionsRepository; + _stationCoverageRequirementsRepository = stationCoverageRequirementsRepository; + _callTypesRepository = callTypesRepository; + _cacheProvider = cacheProvider; + } + + 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)); + + 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(); + + 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); + } + + await InvalidateRunCardsInCacheAsync(runCard.DepartmentId); + + return runCard; + } + + public async Task DeleteRunCardAsync(int runCardId, CancellationToken cancellationToken = default(CancellationToken)) + { + var card = await GetRunCardByIdAsync(runCardId); + + if (card == null) + return false; + + foreach (var level in card.AlarmLevels ?? Enumerable.Empty()) + { + foreach (var req in level.UnitRequirements ?? Enumerable.Empty()) + await _runCardUnitRequirementsRepository.DeleteAsync(req, cancellationToken); + + foreach (var req in level.RoleRequirements ?? Enumerable.Empty()) + await _runCardRoleRequirementsRepository.DeleteAsync(req, cancellationToken); + + await _runCardAlarmLevelsRepository.DeleteAsync(level, cancellationToken); + } + + foreach (var trigger in card.Triggers ?? Enumerable.Empty()) + await _runCardTriggersRepository.DeleteAsync(trigger, cancellationToken); + + foreach (var selection in card.AvailabilitySelections ?? Enumerable.Empty()) + await _runCardAvailabilitySelectionsRepository.DeleteAsync(selection, cancellationToken); + + await _runCardsRepository.DeleteAsync(card, cancellationToken); + + await InvalidateRunCardsInCacheAsync(card.DepartmentId); + + return true; + } + + public async Task GetMatchingRunCardAsync(int departmentId, int priority, string callTypeName) + { + var cards = await GetAllRunCardsForDepartmentAsync(departmentId); + + if (cards == null || !cards.Any()) + return null; + + var callTypeId = await ResolveCallTypeIdAsync(departmentId, callTypeName); + var now = DateTime.UtcNow; + + RunCard bestCard = null; + int bestSpecificity = 0; + + foreach (var card in cards.Where(c => !c.IsDisabled)) + { + var specificity = GetTriggerMatchSpecificity(card, priority, callTypeId, now); + + if (!specificity.HasValue) + continue; + + // Specificity wins; ties break to the newest card (highest id). + if (specificity.Value > bestSpecificity + || (specificity.Value == bestSpecificity && bestCard != null && card.RunCardId > bestCard.RunCardId)) + { + bestCard = card; + bestSpecificity = specificity.Value; + } + } + + return bestCard; + } + + /// + /// Evaluates a card's triggers against a call's priority and resolved call type id. + /// Returns the strongest matching trigger's specificity (3 = priority+type, + /// 2 = type, 1 = priority) or null when no trigger matches. Trigger time windows + /// follow DispatchProtocol semantics: a window bound that is null is open-ended. + /// + public static int? GetTriggerMatchSpecificity(RunCard card, int priority, int? callTypeId, DateTime utcNow) + { + if (card?.Triggers == null || !card.Triggers.Any()) + return null; + + int? best = null; + + foreach (var trigger in card.Triggers) + { + if (trigger.StartsOn.HasValue && trigger.StartsOn.Value > utcNow) + continue; + + if (trigger.EndsOn.HasValue && trigger.EndsOn.Value < utcNow) + continue; + + int? specificity = null; + + switch ((RunCardTriggerTypes)trigger.TriggerType) + { + case RunCardTriggerTypes.CallPriority: + if (trigger.Priority.HasValue && trigger.Priority.Value == priority) + specificity = 1; + break; + case RunCardTriggerTypes.CallType: + if (trigger.CallTypeId.HasValue && callTypeId.HasValue && trigger.CallTypeId.Value == callTypeId.Value) + specificity = 2; + break; + case RunCardTriggerTypes.CallPriorityAndType: + if (trigger.Priority.HasValue && trigger.Priority.Value == priority + && trigger.CallTypeId.HasValue && callTypeId.HasValue && trigger.CallTypeId.Value == callTypeId.Value) + specificity = 3; + break; + } + + if (specificity.HasValue && (!best.HasValue || specificity.Value > best.Value)) + best = specificity; + } + + return best; + } + + public async Task> GetStationCoverageRequirementsForDepartmentAsync(int departmentId) + { + var requirements = await _stationCoverageRequirementsRepository.GetAllByDepartmentIdAsync(departmentId); + + if (requirements == null) + return new List(); + + return requirements.ToList(); + } + + public async Task SaveStationCoverageRequirementAsync(StationCoverageRequirement requirement, CancellationToken cancellationToken = default(CancellationToken)) + { + if (requirement == null) + throw new ArgumentNullException(nameof(requirement)); + + if (!requirement.UnitTypeId.HasValue && !requirement.PersonnelRoleId.HasValue) + throw new ArgumentException("A station coverage requirement needs a unit type or a personnel role.", nameof(requirement)); + + if (requirement.UnitTypeId.HasValue && requirement.PersonnelRoleId.HasValue) + throw new ArgumentException("A station coverage requirement cannot target both a unit type and a personnel role.", nameof(requirement)); + + return await _stationCoverageRequirementsRepository.SaveOrUpdateAsync(requirement, cancellationToken, true); + } + + public async Task DeleteStationCoverageRequirementAsync(int stationCoverageRequirementId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + { + var requirement = await _stationCoverageRequirementsRepository.GetByIdAsync(stationCoverageRequirementId); + + if (requirement == null || requirement.DepartmentId != departmentId) + return false; + + return await _stationCoverageRequirementsRepository.DeleteAsync(requirement, cancellationToken); + } + + public async Task> GetLastUnitDispatchTimesAsync(int departmentId) + { + var rows = await _runCardsRepository.GetLastUnitDispatchTimesByDepartmentAsync(departmentId); + + if (rows == null) + return new Dictionary(); + + return rows.ToDictionary(x => x.UnitId, x => x.LastDispatchedOn); + } + + public async Task> GetLastUserDispatchTimesAsync(int departmentId) + { + var rows = await _runCardsRepository.GetLastUserDispatchTimesByDepartmentAsync(departmentId); + + if (rows == null) + return new Dictionary(); + + return rows.Where(x => !string.IsNullOrWhiteSpace(x.UserId)) + .ToDictionary(x => x.UserId, x => x.LastDispatchedOn); + } + + private async Task HydrateRunCardAsync(RunCard card) + { + var triggers = await _runCardTriggersRepository.GetTriggersByRunCardIdAsync(card.RunCardId); + card.Triggers = triggers?.ToList() ?? new List(); + + var levels = (await _runCardAlarmLevelsRepository.GetAlarmLevelsByRunCardIdAsync(card.RunCardId))?.ToList() + ?? new List(); + var unitReqs = (await _runCardUnitRequirementsRepository.GetUnitRequirementsByRunCardIdAsync(card.RunCardId))?.ToList() + ?? new List(); + var roleReqs = (await _runCardRoleRequirementsRepository.GetRoleRequirementsByRunCardIdAsync(card.RunCardId))?.ToList() + ?? new List(); + + foreach (var level in levels) + { + level.UnitRequirements = unitReqs.Where(r => r.RunCardAlarmLevelId == level.RunCardAlarmLevelId).ToList(); + level.RoleRequirements = roleReqs.Where(r => r.RunCardAlarmLevelId == level.RunCardAlarmLevelId).ToList(); + } + + card.AlarmLevels = levels; + + var selections = await _runCardAvailabilitySelectionsRepository.GetSelectionsByRunCardIdAsync(card.RunCardId); + card.AvailabilitySelections = selections?.ToList() ?? new List(); + } + + private async Task ResolveCallTypeIdAsync(int departmentId, string callTypeName) + { + if (string.IsNullOrWhiteSpace(callTypeName)) + return null; + + var types = await _callTypesRepository.GetAllByDepartmentIdAsync(departmentId); + + var match = types?.FirstOrDefault(t => string.Equals(t.Type?.Trim(), callTypeName.Trim(), StringComparison.OrdinalIgnoreCase)); + + return match?.CallTypeId; + } + + private async Task InvalidateRunCardsInCacheAsync(int departmentId) + { + await _cacheProvider.RemoveAsync(string.Format(RunCardsCacheKey, departmentId)); + } + } +} diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 3be41aa8d..75d186fd4 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -159,6 +159,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // UDF Services builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/SystemAuditsService.cs b/Core/Resgrid.Services/SystemAuditsService.cs index c4d2cdfe0..6be172bc5 100644 --- a/Core/Resgrid.Services/SystemAuditsService.cs +++ b/Core/Resgrid.Services/SystemAuditsService.cs @@ -23,7 +23,23 @@ public SystemAuditsService(ISystemAuditsRepository systemAuditsRepository) if (auditLog.Data == null) auditLog.Data = ""; + // Several fields carry caller-controlled values (route ids, X-Forwarded-For). Clamp to + // the SystemAudits column sizes so a hostile over-length value degrades to a truncated + // audit row instead of a SqlException that loses the audit entirely. + auditLog.UserId = Truncate(auditLog.UserId, 128); + auditLog.Username = Truncate(auditLog.Username, 512); + auditLog.IpAddress = Truncate(auditLog.IpAddress, 512); + auditLog.ServerName = Truncate(auditLog.ServerName, 512); + return await _systemAuditsRepository.SaveOrUpdateAsync(auditLog, cancellationToken); } + + private static string Truncate(string value, int maxLength) + { + if (value == null || value.Length <= maxLength) + return value; + + return value.Substring(0, maxLength); + } } } diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 46aeaa60e..f7d11e4d2 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -461,6 +461,47 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve incident["enabled"] = eventType == WorkflowTriggerEventType.IncidentPublicSharingEnabled; obj["incident"] = incident; break; + + case WorkflowTriggerEventType.RunCardActivated: + var runCard = new ScriptObject(); + runCard["call_id"] = 1001; + runCard["run_card_id"] = 7; + runCard["run_card_name"] = "Structure Fire - First Alarm"; + runCard["alarm_level"] = 1; + runCard["mode"] = 1; + runCard["was_auto_dispatched"] = true; + runCard["unit_count"] = 3; + runCard["personnel_count"] = 6; + obj["run_card"] = runCard; + break; + + case WorkflowTriggerEventType.CallAlarmEscalated: + var escalation = new ScriptObject(); + escalation["call_id"] = 1001; + escalation["previous_alarm_level"] = 1; + escalation["new_alarm_level"] = 2; + escalation["added_unit_count"] = 2; + escalation["added_personnel_count"] = 4; + obj["escalation"] = escalation; + break; + + case WorkflowTriggerEventType.DispatchShortfallDetected: + var shortfall = new ScriptObject(); + shortfall["call_id"] = 1001; + shortfall["run_card_id"] = 7; + shortfall["alarm_level"] = 1; + shortfall["shortfall_count"] = 1; + shortfall["summary"] = "Unit type Ladder: 0/1"; + obj["shortfall"] = shortfall; + break; + + case WorkflowTriggerEventType.StationCoverageGapDetected: + var coverageGap = new ScriptObject(); + coverageGap["call_id"] = 1001; + coverageGap["gap_count"] = 1; + coverageGap["summary"] = "Station 1: 0/1 Engine"; + obj["coverage_gap"] = coverageGap; + break; } } } diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 1bedc3c69..4c96a487f 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -328,6 +328,71 @@ public async Task BuildContextAsync( triggeringUserId = MapIncidentVariables(scriptObject, eventPayloadJson); break; } + case WorkflowTriggerEventType.RunCardActivated: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt != null) + { + var rc = new ScriptObject(); + rc["call_id"] = evt.CallId; + rc["run_card_id"] = evt.RunCardId; + rc["run_card_name"] = evt.RunCardName ?? string.Empty; + rc["alarm_level"] = evt.AlarmLevel; + rc["mode"] = evt.ModeUsed; + rc["was_auto_dispatched"] = evt.WasAutoDispatched; + rc["unit_count"] = evt.UnitIds?.Count ?? 0; + rc["personnel_count"] = evt.UserIds?.Count ?? 0; + scriptObject["run_card"] = rc; + } + break; + } + case WorkflowTriggerEventType.CallAlarmEscalated: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt != null) + { + var esc = new ScriptObject(); + esc["call_id"] = evt.CallId; + esc["previous_alarm_level"] = evt.PreviousAlarmLevel; + esc["new_alarm_level"] = evt.NewAlarmLevel; + esc["added_unit_count"] = evt.AddedUnitIds?.Count ?? 0; + esc["added_personnel_count"] = evt.AddedUserIds?.Count ?? 0; + scriptObject["escalation"] = esc; + } + break; + } + case WorkflowTriggerEventType.DispatchShortfallDetected: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt != null) + { + var sf = new ScriptObject(); + sf["call_id"] = evt.CallId; + sf["run_card_id"] = evt.RunCardId; + sf["alarm_level"] = evt.AlarmLevel; + sf["shortfall_count"] = evt.Shortfalls?.Count ?? 0; + sf["summary"] = evt.Shortfalls != null + ? string.Join("; ", evt.Shortfalls.Select(s => $"{(s.IsUnitRequirement ? "Unit type" : "Role")} {s.TypeOrRoleName ?? s.TypeOrRoleId.ToString()}: {s.FilledCount}/{s.RequiredCount}")) + : string.Empty; + scriptObject["shortfall"] = sf; + } + break; + } + case WorkflowTriggerEventType.StationCoverageGapDetected: + { + var evt = TryDeserialize(eventPayloadJson); + if (evt != null) + { + var gap = new ScriptObject(); + gap["call_id"] = evt.CallId ?? 0; + gap["gap_count"] = evt.MoveUps?.Count ?? 0; + gap["summary"] = evt.MoveUps != null + ? string.Join("; ", evt.MoveUps.Select(m => $"{m.StationGroupName}: {m.AvailableAfterDispatch}/{m.MinimumRequired} {(m.UnitTypeName ?? m.PersonnelRoleName ?? string.Empty)}")) + : string.Empty; + scriptObject["coverage_gap"] = gap; + } + break; + } } await AddCommonUserVariablesAsync(scriptObject, triggeringUserId); diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index 3ea854e01..e7cbf6ed3 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -116,6 +116,12 @@ private void RegisterListeners() _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.IncidentCommandPostUpdated, e)); _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, e.Enabled ? WorkflowTriggerEventType.IncidentPublicSharingEnabled : WorkflowTriggerEventType.IncidentPublicSharingDisabled, e)); + + // Run card dispatch system + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.RunCardActivated, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.CallAlarmEscalated, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.DispatchShortfallDetected, e)); + _eventAggregator.AddListener(e => HandleEvent(e.DepartmentId, WorkflowTriggerEventType.StationCoverageGapDetected, e)); } private static async void HandleEvent(int departmentId, WorkflowTriggerEventType eventType, object eventObj) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0114_WidenSystemAuditsDataColumn.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0114_WidenSystemAuditsDataColumn.cs new file mode 100644 index 000000000..10a0b8632 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0114_WidenSystemAuditsDataColumn.cs @@ -0,0 +1,25 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// SystemAudits.Data was created as the FluentMigrator default nvarchar(255). Audit writers + /// (SCIM operations, email receive, auth failures) build free-form payloads that routinely + /// exceed that, and SQL Server then fails the whole insert with "String or binary data would + /// be truncated" — losing the audit row entirely. Data is never indexed or filtered, only + /// displayed, so nvarchar(max) is safe. + /// + [Migration(114)] + public class M0114_WidenSystemAuditsDataColumn : Migration + { + public override void Up() + { + Alter.Table("SystemAudits").AlterColumn("Data").AsCustom("nvarchar(max)").Nullable(); + } + + public override void Down() + { + // Shrinking back to nvarchar(255) would truncate stored audit payloads; one-way. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0115_AddRunCards.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0115_AddRunCards.cs new file mode 100644 index 000000000..4a61f7a27 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0115_AddRunCards.cs @@ -0,0 +1,179 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Run card dispatch system tables: RunCards (response plan header with per-card mode/auto-dispatch/ + /// staffing overrides), RunCardTriggers (OR'd priority/type match conditions with optional time + /// windows), RunCardAlarmLevels (1..N additive escalation levels), RunCardUnitRequirements / + /// RunCardRoleRequirements (unit-type and personnel-role counts per level), + /// RunCardAvailabilitySelections (which unit/personnel statuses and staffing levels count as + /// dispatchable) and StationCoverageRequirements (minimum station coverage driving move-up/backfill + /// recommendations). Also adds Calls.AlarmLevel and Calls.ActiveRunCardId for escalation tracking. + /// + [Migration(115)] + public class M0115_AddRunCards : Migration + { + public override void Up() + { + if (!Schema.Table("RunCards").Exists()) + { + Create.Table("RunCards") + .WithColumn("RunCardId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Name").AsString(100).NotNullable() + .WithColumn("Description").AsString(500).Nullable() + .WithColumn("IsDisabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DispatchModeOverride").AsInt32().Nullable() + .WithColumn("AutoDispatchOverride").AsInt32().Nullable() + .WithColumn("MinimumStaffingLevelOverride").AsInt32().Nullable() + .WithColumn("HomeStationGroupId").AsInt32().Nullable() + .WithColumn("AddedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("AddedByUserId").AsString(450).NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable() + .WithColumn("UpdatedByUserId").AsString(450).Nullable(); + + Create.Index("IX_RunCards_DepartmentId") + .OnTable("RunCards") + .OnColumn("DepartmentId").Ascending(); + } + + if (!Schema.Table("RunCardTriggers").Exists()) + { + Create.Table("RunCardTriggers") + .WithColumn("RunCardTriggerId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardId").AsInt32().NotNullable() + .WithColumn("TriggerType").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Priority").AsInt32().Nullable() + .WithColumn("CallTypeId").AsInt32().Nullable() + .WithColumn("StartsOn").AsDateTime2().Nullable() + .WithColumn("EndsOn").AsDateTime2().Nullable(); + + Create.Index("IX_RunCardTriggers_RunCardId") + .OnTable("RunCardTriggers") + .OnColumn("RunCardId").Ascending(); + } + + if (!Schema.Table("RunCardAlarmLevels").Exists()) + { + Create.Table("RunCardAlarmLevels") + .WithColumn("RunCardAlarmLevelId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardId").AsInt32().NotNullable() + .WithColumn("AlarmLevel").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("Name").AsString(100).Nullable(); + + Create.Index("IX_RunCardAlarmLevels_RunCardId") + .OnTable("RunCardAlarmLevels") + .OnColumn("RunCardId").Ascending(); + + // One row per level number per card. + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_RunCardAlarmLevels_Card_Level ON RunCardAlarmLevels (RunCardId, AlarmLevel);"); + } + + if (!Schema.Table("RunCardUnitRequirements").Exists()) + { + Create.Table("RunCardUnitRequirements") + .WithColumn("RunCardUnitRequirementId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardAlarmLevelId").AsInt32().NotNullable() + .WithColumn("UnitTypeId").AsInt32().NotNullable() + .WithColumn("RequiredCount").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RunCardUnitRequirements_LevelId") + .OnTable("RunCardUnitRequirements") + .OnColumn("RunCardAlarmLevelId").Ascending(); + } + + if (!Schema.Table("RunCardRoleRequirements").Exists()) + { + Create.Table("RunCardRoleRequirements") + .WithColumn("RunCardRoleRequirementId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardAlarmLevelId").AsInt32().NotNullable() + .WithColumn("PersonnelRoleId").AsInt32().NotNullable() + .WithColumn("RequiredCount").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RunCardRoleRequirements_LevelId") + .OnTable("RunCardRoleRequirements") + .OnColumn("RunCardAlarmLevelId").Ascending(); + } + + if (!Schema.Table("RunCardAvailabilitySelections").Exists()) + { + Create.Table("RunCardAvailabilitySelections") + .WithColumn("RunCardAvailabilitySelectionId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardId").AsInt32().NotNullable() + .WithColumn("SelectionType").AsInt32().NotNullable() + .WithColumn("UnitTypeId").AsInt32().Nullable() + .WithColumn("IsCustomState").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("StateId").AsInt32().NotNullable(); + + Create.Index("IX_RunCardAvailabilitySelections_RunCardId") + .OnTable("RunCardAvailabilitySelections") + .OnColumn("RunCardId").Ascending(); + } + + if (!Schema.Table("StationCoverageRequirements").Exists()) + { + Create.Table("StationCoverageRequirements") + .WithColumn("StationCoverageRequirementId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DepartmentGroupId").AsInt32().NotNullable() + .WithColumn("UnitTypeId").AsInt32().Nullable() + .WithColumn("PersonnelRoleId").AsInt32().Nullable() + .WithColumn("MinimumAvailableCount").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("RadiusMeters").AsInt32().Nullable() + .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(true); + + Create.Index("IX_StationCoverageRequirements_DepartmentId") + .OnTable("StationCoverageRequirements") + .OnColumn("DepartmentId").Ascending(); + } + + if (!Schema.Table("Calls").Column("AlarmLevel").Exists()) + { + Alter.Table("Calls") + .AddColumn("AlarmLevel").AsInt32().NotNullable().WithDefaultValue(1); + } + + if (!Schema.Table("Calls").Column("ActiveRunCardId").Exists()) + { + Alter.Table("Calls") + .AddColumn("ActiveRunCardId").AsInt32().Nullable(); + } + } + + public override void Down() + { + if (Schema.Table("Calls").Column("ActiveRunCardId").Exists()) + Delete.Column("ActiveRunCardId").FromTable("Calls"); + + if (Schema.Table("Calls").Column("AlarmLevel").Exists()) + Delete.Column("AlarmLevel").FromTable("Calls"); + + if (Schema.Table("StationCoverageRequirements").Exists()) + Delete.Table("StationCoverageRequirements"); + + if (Schema.Table("RunCardAvailabilitySelections").Exists()) + Delete.Table("RunCardAvailabilitySelections"); + + if (Schema.Table("RunCardRoleRequirements").Exists()) + Delete.Table("RunCardRoleRequirements"); + + if (Schema.Table("RunCardUnitRequirements").Exists()) + Delete.Table("RunCardUnitRequirements"); + + if (Schema.Table("RunCardAlarmLevels").Exists()) + { + Execute.Sql("DROP INDEX IF EXISTS UX_RunCardAlarmLevels_Card_Level ON RunCardAlarmLevels;"); + Delete.Table("RunCardAlarmLevels"); + } + + if (Schema.Table("RunCardTriggers").Exists()) + Delete.Table("RunCardTriggers"); + + if (Schema.Table("RunCards").Exists()) + Delete.Table("RunCards"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0116_SeedRunCardsFeatureFlag.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0116_SeedRunCardsFeatureFlag.cs new file mode 100644 index 000000000..3e3598356 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0116_SeedRunCardsFeatureFlag.cs @@ -0,0 +1,37 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Seeds the "Dispatch.RunCards" feature flag (off by default) gating the run card dispatch + /// system (run cards, station-based dispatching, closest unit response, move-up + /// recommendations); enable globally or via a per-department override to roll out. + /// + [Migration(116)] + public class M0116_SeedRunCardsFeatureFlag : Migration + { + // Keep FlagKey in sync with Resgrid.Model.FeatureFlagKeys.DispatchRunCards. + private const string FlagKey = "Dispatch.RunCards"; + + public override void Up() + { + // Seeded OFF (IsEnabledGlobally = false). Run cards stay hidden until this flag is + // enabled globally or via a per-department override. FlagType, IsArchived, IsPermanent + // and CreatedOn fall back to their table defaults. + // Guarded with IF NOT EXISTS so re-running the migration does not violate the unique + // FlagKey index. + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = '" + FlagKey + "') " + + "INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally]) " + + "VALUES ('" + FlagKey + "', " + + "'Run Card Dispatch', " + + "'CAD-style run cards with station-based and closest-unit automatic resource selection, multi-alarm escalation and move-up recommendations. Seeded off; enable globally or per-department to roll out.', " + + "'Dispatch', 0);"); + } + + public override void Down() + { + Delete.FromTable("FeatureFlags").Row(new { FlagKey = FlagKey }); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0117_AddRunCardActivations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0117_AddRunCardActivations.cs new file mode 100644 index 000000000..2a9915e6c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0117_AddRunCardActivations.cs @@ -0,0 +1,45 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// RunCardActivations: audit trail of run card activations against calls (card, + /// alarm level, mode, auto-dispatch flag and the serialized recommendation result + /// with per-pick reasons, shortfalls and move-up recommendations). + /// + [Migration(117)] + public class M0117_AddRunCardActivations : Migration + { + public override void Up() + { + if (!Schema.Table("RunCardActivations").Exists()) + { + Create.Table("RunCardActivations") + .WithColumn("RunCardActivationId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("CallId").AsInt32().NotNullable() + .WithColumn("RunCardId").AsInt32().NotNullable() + .WithColumn("AlarmLevel").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("ModeUsed").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("WasAutoDispatched").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ResultJson").AsString(int.MaxValue).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("CreatedByUserId").AsString(450).Nullable(); + + Create.Index("IX_RunCardActivations_CallId") + .OnTable("RunCardActivations") + .OnColumn("CallId").Ascending(); + + Create.Index("IX_RunCardActivations_DepartmentId") + .OnTable("RunCardActivations") + .OnColumn("DepartmentId").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("RunCardActivations").Exists()) + Delete.Table("RunCardActivations"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0114_WidenSystemAuditsDataColumnPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0114_WidenSystemAuditsDataColumnPg.cs new file mode 100644 index 000000000..ca052912c --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0114_WidenSystemAuditsDataColumnPg.cs @@ -0,0 +1,20 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Number-parity no-op for the SQL Server M0114. Postgres created systemaudits.data as + /// citext, which is unbounded, so there is nothing to widen. + /// + [Migration(114)] + public class M0114_WidenSystemAuditsDataColumnPg : Migration + { + public override void Up() + { + } + + public override void Down() + { + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0115_AddRunCardsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0115_AddRunCardsPg.cs new file mode 100644 index 000000000..769f79ed0 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0115_AddRunCardsPg.cs @@ -0,0 +1,179 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Run card dispatch system tables: RunCards (response plan header with per-card mode/auto-dispatch/ + /// staffing overrides), RunCardTriggers (OR'd priority/type match conditions with optional time + /// windows), RunCardAlarmLevels (1..N additive escalation levels), RunCardUnitRequirements / + /// RunCardRoleRequirements (unit-type and personnel-role counts per level), + /// RunCardAvailabilitySelections (which unit/personnel statuses and staffing levels count as + /// dispatchable) and StationCoverageRequirements (minimum station coverage driving move-up/backfill + /// recommendations). Also adds Calls.AlarmLevel and Calls.ActiveRunCardId for escalation tracking. + /// + [Migration(115)] + public class M0115_AddRunCardsPg : Migration + { + public override void Up() + { + if (!Schema.Table("RunCards".ToLower()).Exists()) + { + Create.Table("RunCards".ToLower()) + .WithColumn("RunCardId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable() + .WithColumn("Name".ToLower()).AsCustom("citext").NotNullable() + .WithColumn("Description".ToLower()).AsCustom("citext").Nullable() + .WithColumn("IsDisabled".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DispatchModeOverride".ToLower()).AsInt32().Nullable() + .WithColumn("AutoDispatchOverride".ToLower()).AsInt32().Nullable() + .WithColumn("MinimumStaffingLevelOverride".ToLower()).AsInt32().Nullable() + .WithColumn("HomeStationGroupId".ToLower()).AsInt32().Nullable() + .WithColumn("AddedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("AddedByUserId".ToLower()).AsCustom("citext").NotNullable() + .WithColumn("UpdatedOn".ToLower()).AsDateTime2().Nullable() + .WithColumn("UpdatedByUserId".ToLower()).AsCustom("citext").Nullable(); + + Create.Index("IX_RunCards_DepartmentId".ToLower()) + .OnTable("RunCards".ToLower()) + .OnColumn("DepartmentId".ToLower()).Ascending(); + } + + if (!Schema.Table("RunCardTriggers".ToLower()).Exists()) + { + Create.Table("RunCardTriggers".ToLower()) + .WithColumn("RunCardTriggerId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardId".ToLower()).AsInt32().NotNullable() + .WithColumn("TriggerType".ToLower()).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Priority".ToLower()).AsInt32().Nullable() + .WithColumn("CallTypeId".ToLower()).AsInt32().Nullable() + .WithColumn("StartsOn".ToLower()).AsDateTime2().Nullable() + .WithColumn("EndsOn".ToLower()).AsDateTime2().Nullable(); + + Create.Index("IX_RunCardTriggers_RunCardId".ToLower()) + .OnTable("RunCardTriggers".ToLower()) + .OnColumn("RunCardId".ToLower()).Ascending(); + } + + if (!Schema.Table("RunCardAlarmLevels".ToLower()).Exists()) + { + Create.Table("RunCardAlarmLevels".ToLower()) + .WithColumn("RunCardAlarmLevelId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardId".ToLower()).AsInt32().NotNullable() + .WithColumn("AlarmLevel".ToLower()).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("Name".ToLower()).AsCustom("citext").Nullable(); + + Create.Index("IX_RunCardAlarmLevels_RunCardId".ToLower()) + .OnTable("RunCardAlarmLevels".ToLower()) + .OnColumn("RunCardId".ToLower()).Ascending(); + + // One row per level number per card. + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_runcardalarmlevels_card_level ON runcardalarmlevels (runcardid, alarmlevel);"); + } + + if (!Schema.Table("RunCardUnitRequirements".ToLower()).Exists()) + { + Create.Table("RunCardUnitRequirements".ToLower()) + .WithColumn("RunCardUnitRequirementId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardAlarmLevelId".ToLower()).AsInt32().NotNullable() + .WithColumn("UnitTypeId".ToLower()).AsInt32().NotNullable() + .WithColumn("RequiredCount".ToLower()).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SortOrder".ToLower()).AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RunCardUnitRequirements_LevelId".ToLower()) + .OnTable("RunCardUnitRequirements".ToLower()) + .OnColumn("RunCardAlarmLevelId".ToLower()).Ascending(); + } + + if (!Schema.Table("RunCardRoleRequirements".ToLower()).Exists()) + { + Create.Table("RunCardRoleRequirements".ToLower()) + .WithColumn("RunCardRoleRequirementId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardAlarmLevelId".ToLower()).AsInt32().NotNullable() + .WithColumn("PersonnelRoleId".ToLower()).AsInt32().NotNullable() + .WithColumn("RequiredCount".ToLower()).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("SortOrder".ToLower()).AsInt32().NotNullable().WithDefaultValue(0); + + Create.Index("IX_RunCardRoleRequirements_LevelId".ToLower()) + .OnTable("RunCardRoleRequirements".ToLower()) + .OnColumn("RunCardAlarmLevelId".ToLower()).Ascending(); + } + + if (!Schema.Table("RunCardAvailabilitySelections".ToLower()).Exists()) + { + Create.Table("RunCardAvailabilitySelections".ToLower()) + .WithColumn("RunCardAvailabilitySelectionId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("RunCardId".ToLower()).AsInt32().NotNullable() + .WithColumn("SelectionType".ToLower()).AsInt32().NotNullable() + .WithColumn("UnitTypeId".ToLower()).AsInt32().Nullable() + .WithColumn("IsCustomState".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("StateId".ToLower()).AsInt32().NotNullable(); + + Create.Index("IX_RunCardAvailabilitySelections_RunCardId".ToLower()) + .OnTable("RunCardAvailabilitySelections".ToLower()) + .OnColumn("RunCardId".ToLower()).Ascending(); + } + + if (!Schema.Table("StationCoverageRequirements".ToLower()).Exists()) + { + Create.Table("StationCoverageRequirements".ToLower()) + .WithColumn("StationCoverageRequirementId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable() + .WithColumn("DepartmentGroupId".ToLower()).AsInt32().NotNullable() + .WithColumn("UnitTypeId".ToLower()).AsInt32().Nullable() + .WithColumn("PersonnelRoleId".ToLower()).AsInt32().Nullable() + .WithColumn("MinimumAvailableCount".ToLower()).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("RadiusMeters".ToLower()).AsInt32().Nullable() + .WithColumn("IsEnabled".ToLower()).AsBoolean().NotNullable().WithDefaultValue(true); + + Create.Index("IX_StationCoverageRequirements_DepartmentId".ToLower()) + .OnTable("StationCoverageRequirements".ToLower()) + .OnColumn("DepartmentId".ToLower()).Ascending(); + } + + if (!Schema.Table("Calls".ToLower()).Column("AlarmLevel".ToLower()).Exists()) + { + Alter.Table("Calls".ToLower()) + .AddColumn("AlarmLevel".ToLower()).AsInt32().NotNullable().WithDefaultValue(1); + } + + if (!Schema.Table("Calls".ToLower()).Column("ActiveRunCardId".ToLower()).Exists()) + { + Alter.Table("Calls".ToLower()) + .AddColumn("ActiveRunCardId".ToLower()).AsInt32().Nullable(); + } + } + + public override void Down() + { + if (Schema.Table("Calls".ToLower()).Column("ActiveRunCardId".ToLower()).Exists()) + Delete.Column("ActiveRunCardId".ToLower()).FromTable("Calls".ToLower()); + + if (Schema.Table("Calls".ToLower()).Column("AlarmLevel".ToLower()).Exists()) + Delete.Column("AlarmLevel".ToLower()).FromTable("Calls".ToLower()); + + if (Schema.Table("StationCoverageRequirements".ToLower()).Exists()) + Delete.Table("StationCoverageRequirements".ToLower()); + + if (Schema.Table("RunCardAvailabilitySelections".ToLower()).Exists()) + Delete.Table("RunCardAvailabilitySelections".ToLower()); + + if (Schema.Table("RunCardRoleRequirements".ToLower()).Exists()) + Delete.Table("RunCardRoleRequirements".ToLower()); + + if (Schema.Table("RunCardUnitRequirements".ToLower()).Exists()) + Delete.Table("RunCardUnitRequirements".ToLower()); + + if (Schema.Table("RunCardAlarmLevels".ToLower()).Exists()) + { + Execute.Sql("DROP INDEX IF EXISTS ux_runcardalarmlevels_card_level;"); + Delete.Table("RunCardAlarmLevels".ToLower()); + } + + if (Schema.Table("RunCardTriggers".ToLower()).Exists()) + Delete.Table("RunCardTriggers".ToLower()); + + if (Schema.Table("RunCards".ToLower()).Exists()) + Delete.Table("RunCards".ToLower()); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0116_SeedRunCardsFeatureFlagPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0116_SeedRunCardsFeatureFlagPg.cs new file mode 100644 index 000000000..321377cc3 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0116_SeedRunCardsFeatureFlagPg.cs @@ -0,0 +1,38 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Seeds the "Dispatch.RunCards" feature flag (off by default) gating the run card dispatch + /// system (run cards, station-based dispatching, closest unit response, move-up + /// recommendations); enable globally or via a per-department override to roll out. + /// + [Migration(116)] + public class M0116_SeedRunCardsFeatureFlagPg : Migration + { + // Keep FlagKey in sync with Resgrid.Model.FeatureFlagKeys.DispatchRunCards. + private const string FlagKey = "Dispatch.RunCards"; + + public override void Up() + { + // Seeded OFF (isenabledglobally = false). Run cards stay hidden until this flag is + // enabled globally or via a per-department override. flagtype, isarchived, ispermanent + // and createdon fall back to their table defaults; the identity PK is omitted so + // Postgres assigns it. + // Guarded with WHERE NOT EXISTS so re-running the migration does not violate the unique + // flagkey index. + Execute.Sql( + "INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally) " + + "SELECT '" + FlagKey + "', " + + "'Run Card Dispatch', " + + "'CAD-style run cards with station-based and closest-unit automatic resource selection, multi-alarm escalation and move-up recommendations. Seeded off; enable globally or per-department to roll out.', " + + "'Dispatch', false " + + "WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = '" + FlagKey + "');"); + } + + public override void Down() + { + Delete.FromTable("FeatureFlags".ToLower()).Row(new { flagkey = FlagKey }); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0117_AddRunCardActivationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0117_AddRunCardActivationsPg.cs new file mode 100644 index 000000000..ac65a9407 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0117_AddRunCardActivationsPg.cs @@ -0,0 +1,45 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// RunCardActivations: audit trail of run card activations against calls (card, + /// alarm level, mode, auto-dispatch flag and the serialized recommendation result + /// with per-pick reasons, shortfalls and move-up recommendations). + /// + [Migration(117)] + public class M0117_AddRunCardActivationsPg : Migration + { + public override void Up() + { + if (!Schema.Table("RunCardActivations".ToLower()).Exists()) + { + Create.Table("RunCardActivations".ToLower()) + .WithColumn("RunCardActivationId".ToLower()).AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId".ToLower()).AsInt32().NotNullable() + .WithColumn("CallId".ToLower()).AsInt32().NotNullable() + .WithColumn("RunCardId".ToLower()).AsInt32().NotNullable() + .WithColumn("AlarmLevel".ToLower()).AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("ModeUsed".ToLower()).AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("WasAutoDispatched".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ResultJson".ToLower()).AsCustom("citext").Nullable() + .WithColumn("CreatedOn".ToLower()).AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime) + .WithColumn("CreatedByUserId".ToLower()).AsCustom("citext").Nullable(); + + Create.Index("IX_RunCardActivations_CallId".ToLower()) + .OnTable("RunCardActivations".ToLower()) + .OnColumn("CallId".ToLower()).Ascending(); + + Create.Index("IX_RunCardActivations_DepartmentId".ToLower()) + .OnTable("RunCardActivations".ToLower()) + .OnColumn("DepartmentId".ToLower()).Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("RunCardActivations".ToLower()).Exists()) + Delete.Table("RunCardActivations".ToLower()); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index 1787e2369..6322126e1 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -552,6 +552,26 @@ protected SqlConfiguration() { } public string SelectCheckInRecordsByDepartmentIdAndDateRangeQuery { get; set; } #endregion CheckIns + #region RunCards + public string RunCardsTableName { get; set; } + public string RunCardTriggersTableName { get; set; } + public string RunCardAlarmLevelsTableName { get; set; } + public string RunCardUnitRequirementsTableName { get; set; } + public string RunCardRoleRequirementsTableName { get; set; } + public string RunCardAvailabilitySelectionsTableName { get; set; } + public string StationCoverageRequirementsTableName { get; set; } + public string SelectRunCardTriggersByRunCardIdQuery { get; set; } + public string SelectRunCardTriggersByDepartmentIdQuery { get; set; } + public string SelectRunCardAlarmLevelsByRunCardIdQuery { get; set; } + public string SelectRunCardUnitRequirementsByRunCardIdQuery { get; set; } + public string SelectRunCardRoleRequirementsByRunCardIdQuery { get; set; } + public string SelectRunCardAvailabilitySelectionsByRunCardIdQuery { get; set; } + public string SelectLastUnitDispatchTimesByDepartmentQuery { get; set; } + public string SelectLastUserDispatchTimesByDepartmentQuery { get; set; } + public string RunCardActivationsTableName { get; set; } + public string SelectRunCardActivationsByCallIdQuery { get; set; } + #endregion RunCards + #region CalendarItemCheckIns public string CalendarItemCheckInsTableName { get; set; } public string SelectCalendarItemCheckInByItemAndUserQuery { get; set; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index d20dfd91d..ab8a34a24 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -230,6 +230,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Run Card Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index bbd532213..b454f2188 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -242,6 +242,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Run Card Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index cc8085732..fe6c681c6 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -217,6 +217,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Run Card Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 1a81268ba..520192505 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -230,6 +230,16 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Run Card Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Calendar Check-In Repositories builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUnitDispatchTimesByDepartmentQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUnitDispatchTimesByDepartmentQuery.cs new file mode 100644 index 000000000..43f573236 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUnitDispatchTimesByDepartmentQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectLastUnitDispatchTimesByDepartmentQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectLastUnitDispatchTimesByDepartmentQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectLastUnitDispatchTimesByDepartmentQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUserDispatchTimesByDepartmentQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUserDispatchTimesByDepartmentQuery.cs new file mode 100644 index 000000000..46d26db89 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectLastUserDispatchTimesByDepartmentQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectLastUserDispatchTimesByDepartmentQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectLastUserDispatchTimesByDepartmentQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectLastUserDispatchTimesByDepartmentQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardActivationsByCallIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardActivationsByCallIdQuery.cs new file mode 100644 index 000000000..795182e9e --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardActivationsByCallIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectRunCardActivationsByCallIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectRunCardActivationsByCallIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectRunCardActivationsByCallIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardActivationsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%CALLID%" }, + new string[] { "CallId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAlarmLevelsByRunCardIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAlarmLevelsByRunCardIdQuery.cs new file mode 100644 index 000000000..6624fb63c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAlarmLevelsByRunCardIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectRunCardAlarmLevelsByRunCardIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectRunCardAlarmLevelsByRunCardIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectRunCardAlarmLevelsByRunCardIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardAlarmLevelsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%RCID%" }, + new string[] { "RunCardId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAvailabilitySelectionsByRunCardIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAvailabilitySelectionsByRunCardIdQuery.cs new file mode 100644 index 000000000..02b9eccee --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardAvailabilitySelectionsByRunCardIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectRunCardAvailabilitySelectionsByRunCardIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectRunCardAvailabilitySelectionsByRunCardIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectRunCardAvailabilitySelectionsByRunCardIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardAvailabilitySelectionsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%RCID%" }, + new string[] { "RunCardId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardRoleRequirementsByRunCardIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardRoleRequirementsByRunCardIdQuery.cs new file mode 100644 index 000000000..8399e0949 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardRoleRequirementsByRunCardIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectRunCardRoleRequirementsByRunCardIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectRunCardRoleRequirementsByRunCardIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectRunCardRoleRequirementsByRunCardIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardRoleRequirementsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%RCID%" }, + new string[] { "RunCardId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByDepartmentIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByDepartmentIdQuery.cs new file mode 100644 index 000000000..f3edab7d2 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByDepartmentIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectRunCardTriggersByDepartmentIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectRunCardTriggersByDepartmentIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectRunCardTriggersByDepartmentIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardTriggersTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%DID%" }, + new string[] { "DepartmentId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByRunCardIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByRunCardIdQuery.cs new file mode 100644 index 000000000..104ae142f --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardTriggersByRunCardIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectRunCardTriggersByRunCardIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectRunCardTriggersByRunCardIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectRunCardTriggersByRunCardIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardTriggersTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%RCID%" }, + new string[] { "RunCardId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardUnitRequirementsByRunCardIdQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardUnitRequirementsByRunCardIdQuery.cs new file mode 100644 index 000000000..bcf3565d9 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/RunCards/SelectRunCardUnitRequirementsByRunCardIdQuery.cs @@ -0,0 +1,33 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; + +namespace Resgrid.Repositories.DataRepository.Queries.RunCards +{ + public class SelectRunCardUnitRequirementsByRunCardIdQuery : ISelectQuery + { + private readonly SqlConfiguration _sqlConfiguration; + public SelectRunCardUnitRequirementsByRunCardIdQuery(SqlConfiguration sqlConfiguration) + { + _sqlConfiguration = sqlConfiguration; + } + + public string GetQuery() + { + var query = _sqlConfiguration.SelectRunCardUnitRequirementsByRunCardIdQuery + .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, + _sqlConfiguration.RunCardUnitRequirementsTableName, + _sqlConfiguration.ParameterNotation, + new string[] { "%RCID%" }, + new string[] { "RunCardId" }); + + return query; + } + + public string GetQuery() where TEntity : class, IEntity + { + return GetQuery(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RepositoryBase.cs b/Repositories/Resgrid.Repositories.DataRepository/RepositoryBase.cs index ef44781b3..44352ec8b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RepositoryBase.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RepositoryBase.cs @@ -502,6 +502,12 @@ private async Task SyncChildArrayUpdates(T entity, CancellationToken cancellatio if (property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() == typeof(ICollection<>)) { + // Self-referencing collections (e.g. DepartmentGroup.Children) cannot be synced + // here: the generated DELETE filters on the parent's own PK column, so it would + // delete the parent row itself rather than removed children. + if (property.PropertyType.GetGenericArguments()[0] == entity.GetType()) + continue; + var collection = (IEnumerable)property.GetValue(entity, null); object obj = null; diff --git a/Repositories/Resgrid.Repositories.DataRepository/RunCardActivationsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RunCardActivationsRepository.cs new file mode 100644 index 000000000..e60b8f2a5 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RunCardActivationsRepository.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.RunCards; + +namespace Resgrid.Repositories.DataRepository +{ + public class RunCardActivationsRepository : RepositoryBase, IRunCardActivationsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public RunCardActivationsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetActivationsByCallIdAsync(int callId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("CallId", callId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RunCardAlarmLevelsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RunCardAlarmLevelsRepository.cs new file mode 100644 index 000000000..21ac47473 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RunCardAlarmLevelsRepository.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.RunCards; + +namespace Resgrid.Repositories.DataRepository +{ + public class RunCardAlarmLevelsRepository : RepositoryBase, IRunCardAlarmLevelsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public RunCardAlarmLevelsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetAlarmLevelsByRunCardIdAsync(int runCardId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("RunCardId", runCardId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RunCardAvailabilitySelectionsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RunCardAvailabilitySelectionsRepository.cs new file mode 100644 index 000000000..c9dc8d380 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RunCardAvailabilitySelectionsRepository.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.RunCards; + +namespace Resgrid.Repositories.DataRepository +{ + public class RunCardAvailabilitySelectionsRepository : RepositoryBase, IRunCardAvailabilitySelectionsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public RunCardAvailabilitySelectionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetSelectionsByRunCardIdAsync(int runCardId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("RunCardId", runCardId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RunCardRoleRequirementsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RunCardRoleRequirementsRepository.cs new file mode 100644 index 000000000..71194224b --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RunCardRoleRequirementsRepository.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.RunCards; + +namespace Resgrid.Repositories.DataRepository +{ + public class RunCardRoleRequirementsRepository : RepositoryBase, IRunCardRoleRequirementsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public RunCardRoleRequirementsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetRoleRequirementsByRunCardIdAsync(int runCardId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("RunCardId", runCardId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RunCardTriggersRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RunCardTriggersRepository.cs new file mode 100644 index 000000000..53104f593 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RunCardTriggersRepository.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.RunCards; + +namespace Resgrid.Repositories.DataRepository +{ + public class RunCardTriggersRepository : RepositoryBase, IRunCardTriggersRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public RunCardTriggersRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetTriggersByRunCardIdAsync(int runCardId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("RunCardId", runCardId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetTriggersByDepartmentIdAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RunCardUnitRequirementsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RunCardUnitRequirementsRepository.cs new file mode 100644 index 000000000..bf14f831f --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RunCardUnitRequirementsRepository.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.RunCards; + +namespace Resgrid.Repositories.DataRepository +{ + public class RunCardUnitRequirementsRepository : RepositoryBase, IRunCardUnitRequirementsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public RunCardUnitRequirementsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetUnitRequirementsByRunCardIdAsync(int runCardId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("RunCardId", runCardId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RunCardsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RunCardsRepository.cs new file mode 100644 index 000000000..d0edaa117 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RunCardsRepository.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Framework; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Queries.RunCards; + +namespace Resgrid.Repositories.DataRepository +{ + public class RunCardsRepository : RepositoryBase, IRunCardsRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly SqlConfiguration _sqlConfiguration; + private readonly IQueryFactory _queryFactory; + private readonly IUnitOfWork _unitOfWork; + + public RunCardsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _sqlConfiguration = sqlConfiguration; + _queryFactory = queryFactory; + _unitOfWork = unitOfWork; + } + + public async Task> GetLastUnitDispatchTimesByDepartmentAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + + public async Task> GetLastUserDispatchTimesByDepartmentAsync(int departmentId) + { + try + { + var selectFunction = new Func>>(async x => + { + var dynamicParameters = new DynamicParametersExtension(); + dynamicParameters.Add("DepartmentId", departmentId); + + var query = _queryFactory.GetQuery(); + + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); + }); + + DbConnection conn = null; + if (_unitOfWork?.Connection == null) + { + using (conn = _connectionProvider.Create()) + { + await conn.OpenAsync(); + return await selectFunction(conn); + } + } + else + { + conn = _unitOfWork.CreateOrGetConnection(); + return await selectFunction(conn); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + throw; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 2350a0913..4891d7621 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -1761,6 +1761,65 @@ ORDER BY Timestamp DESC ORDER BY Timestamp DESC"; #endregion CheckIns + #region RunCards + RunCardsTableName = "RunCards"; + RunCardTriggersTableName = "RunCardTriggers"; + RunCardAlarmLevelsTableName = "RunCardAlarmLevels"; + RunCardUnitRequirementsTableName = "RunCardUnitRequirements"; + RunCardRoleRequirementsTableName = "RunCardRoleRequirements"; + RunCardAvailabilitySelectionsTableName = "RunCardAvailabilitySelections"; + StationCoverageRequirementsTableName = "StationCoverageRequirements"; + + SelectRunCardTriggersByRunCardIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE RunCardId = %RCID%"; + SelectRunCardTriggersByDepartmentIdQuery = @" + SELECT t.* + FROM %SCHEMA%.RunCardTriggers t + INNER JOIN %SCHEMA%.RunCards rc ON t.RunCardId = rc.RunCardId + WHERE rc.DepartmentId = %DID%"; + SelectRunCardAlarmLevelsByRunCardIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE RunCardId = %RCID% + ORDER BY AlarmLevel ASC"; + SelectRunCardUnitRequirementsByRunCardIdQuery = @" + SELECT r.* + FROM %SCHEMA%.RunCardUnitRequirements r + INNER JOIN %SCHEMA%.RunCardAlarmLevels l ON r.RunCardAlarmLevelId = l.RunCardAlarmLevelId + WHERE l.RunCardId = %RCID% + ORDER BY r.SortOrder ASC"; + SelectRunCardRoleRequirementsByRunCardIdQuery = @" + SELECT r.* + FROM %SCHEMA%.RunCardRoleRequirements r + INNER JOIN %SCHEMA%.RunCardAlarmLevels l ON r.RunCardAlarmLevelId = l.RunCardAlarmLevelId + WHERE l.RunCardId = %RCID% + ORDER BY r.SortOrder ASC"; + SelectRunCardAvailabilitySelectionsByRunCardIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE RunCardId = %RCID%"; + SelectLastUnitDispatchTimesByDepartmentQuery = @" + SELECT du.UnitId, MAX(du.DispatchedOn) AS LastDispatchedOn + FROM %SCHEMA%.CallDispatchUnits du + INNER JOIN %SCHEMA%.Calls c ON du.CallId = c.CallId + WHERE c.DepartmentId = %DID% AND du.DispatchedOn IS NOT NULL + GROUP BY du.UnitId"; + SelectLastUserDispatchTimesByDepartmentQuery = @" + SELECT d.UserId, MAX(d.DispatchedOn) AS LastDispatchedOn + FROM %SCHEMA%.CallDispatches d + INNER JOIN %SCHEMA%.Calls c ON d.CallId = c.CallId + WHERE c.DepartmentId = %DID% AND d.DispatchedOn IS NOT NULL + GROUP BY d.UserId"; + RunCardActivationsTableName = "RunCardActivations"; + SelectRunCardActivationsByCallIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE CallId = %CALLID% + ORDER BY CreatedOn DESC"; + #endregion RunCards + #region CalendarItemCheckIns CalendarItemCheckInsTableName = "CalendarItemCheckIns"; SelectCalendarItemCheckInByItemAndUserQuery = @" diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 821b7d368..451f75205 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1712,6 +1712,65 @@ SELECT TOP 1 * ORDER BY [Timestamp] DESC"; #endregion CheckIns + #region RunCards + RunCardsTableName = "RunCards"; + RunCardTriggersTableName = "RunCardTriggers"; + RunCardAlarmLevelsTableName = "RunCardAlarmLevels"; + RunCardUnitRequirementsTableName = "RunCardUnitRequirements"; + RunCardRoleRequirementsTableName = "RunCardRoleRequirements"; + RunCardAvailabilitySelectionsTableName = "RunCardAvailabilitySelections"; + StationCoverageRequirementsTableName = "StationCoverageRequirements"; + + SelectRunCardTriggersByRunCardIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [RunCardId] = %RCID%"; + SelectRunCardTriggersByDepartmentIdQuery = @" + SELECT t.* + FROM %SCHEMA%.RunCardTriggers t + INNER JOIN %SCHEMA%.RunCards rc ON t.[RunCardId] = rc.[RunCardId] + WHERE rc.[DepartmentId] = %DID%"; + SelectRunCardAlarmLevelsByRunCardIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [RunCardId] = %RCID% + ORDER BY [AlarmLevel] ASC"; + SelectRunCardUnitRequirementsByRunCardIdQuery = @" + SELECT r.* + FROM %SCHEMA%.RunCardUnitRequirements r + INNER JOIN %SCHEMA%.RunCardAlarmLevels l ON r.[RunCardAlarmLevelId] = l.[RunCardAlarmLevelId] + WHERE l.[RunCardId] = %RCID% + ORDER BY r.[SortOrder] ASC"; + SelectRunCardRoleRequirementsByRunCardIdQuery = @" + SELECT r.* + FROM %SCHEMA%.RunCardRoleRequirements r + INNER JOIN %SCHEMA%.RunCardAlarmLevels l ON r.[RunCardAlarmLevelId] = l.[RunCardAlarmLevelId] + WHERE l.[RunCardId] = %RCID% + ORDER BY r.[SortOrder] ASC"; + SelectRunCardAvailabilitySelectionsByRunCardIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [RunCardId] = %RCID%"; + SelectLastUnitDispatchTimesByDepartmentQuery = @" + SELECT du.[UnitId], MAX(du.[DispatchedOn]) AS [LastDispatchedOn] + FROM %SCHEMA%.CallDispatchUnits du + INNER JOIN %SCHEMA%.Calls c ON du.[CallId] = c.[CallId] + WHERE c.[DepartmentId] = %DID% AND du.[DispatchedOn] IS NOT NULL + GROUP BY du.[UnitId]"; + SelectLastUserDispatchTimesByDepartmentQuery = @" + SELECT d.[UserId], MAX(d.[DispatchedOn]) AS [LastDispatchedOn] + FROM %SCHEMA%.CallDispatches d + INNER JOIN %SCHEMA%.Calls c ON d.[CallId] = c.[CallId] + WHERE c.[DepartmentId] = %DID% AND d.[DispatchedOn] IS NOT NULL + GROUP BY d.[UserId]"; + RunCardActivationsTableName = "RunCardActivations"; + SelectRunCardActivationsByCallIdQuery = @" + SELECT * + FROM %SCHEMA%.%TABLENAME% + WHERE [CallId] = %CALLID% + ORDER BY [CreatedOn] DESC"; + #endregion RunCards + #region CalendarItemCheckIns CalendarItemCheckInsTableName = "CalendarItemCheckIns"; SelectCalendarItemCheckInByItemAndUserQuery = @" diff --git a/Repositories/Resgrid.Repositories.DataRepository/StationCoverageRequirementsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/StationCoverageRequirementsRepository.cs new file mode 100644 index 000000000..b635ed2e9 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/StationCoverageRequirementsRepository.cs @@ -0,0 +1,17 @@ +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class StationCoverageRequirementsRepository : RepositoryBase, IStationCoverageRequirementsRepository + { + public StationCoverageRequirementsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + } + } +} diff --git a/Tests/Resgrid.Tests/Models/CallTests.cs b/Tests/Resgrid.Tests/Models/CallTests.cs new file mode 100644 index 000000000..90418a036 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/CallTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class CallTests + { + [Test] + public void DidDispatchCountChange_ReturnsFalse_ForFreshCall() + { + var call = new Call(); + + call.DidDispatchCountChange().Should().BeFalse(); + } + + [Test] + public void DidDispatchCountChange_ReturnsFalse_AfterFirstDispatchIncrease() + { + var call = new Call(); + + call.IncreaseDispatchCount(); + + call.PreviousDispatchCount.Should().Be(0); + call.DispatchCount.Should().Be(1); + call.DidDispatchCountChange().Should().BeFalse(); + } + + [Test] + public void DidDispatchCountChange_ReturnsTrue_AfterEscalationIncrease() + { + var call = new Call { DispatchCount = 1 }; + + call.IncreaseDispatchCount(); + + call.PreviousDispatchCount.Should().Be(1); + call.DispatchCount.Should().Be(2); + call.DidDispatchCountChange().Should().BeTrue(); + } + + [Test] + public void DidDispatchCountChange_ReturnsFalse_WhenCountUnchanged() + { + var call = new Call { PreviousDispatchCount = 2, DispatchCount = 2 }; + + call.DidDispatchCountChange().Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs index 316a93869..871c32d99 100644 --- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs @@ -241,6 +241,19 @@ public async Task existing_dm_key_should_return_existing_channel_without_insert( _chatChannelMemberRepositoryMock.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Test] + public async Task self_target_dm_should_return_null_without_insert() + { + var result = await _chatChannelService.GetOrCreateDirectMessageChannelAsync(1, "user-a", "USER-A", null); + + result.Should().BeNull(); + _chatChannelRepositoryMock.Verify(x => x.GetByDmKeyAsync(It.IsAny(), It.IsAny()), Times.Never); + _chatChannelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), Times.Never); + } + [Test] public async Task dm_key_should_be_sorted_regardless_of_initiator() { diff --git a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs new file mode 100644 index 000000000..38ab1e8c3 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs @@ -0,0 +1,498 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class DispatchRecommendationServiceTests + { + private const int DepartmentId = 1; + private const int EngineTypeId = 100; + private const int LadderTypeId = 101; + private const int FirefighterRoleId = 200; + private const int StationAId = 10; + private const int StationBId = 20; + + private Mock _runCardsService; + private Mock _unitsService; + private Mock _actionLogsService; + private Mock _userStateService; + private Mock _personnelRolesService; + private Mock _customStateService; + private Mock _departmentGroupsService; + private Mock _departmentSettingsService; + private Mock _geoService; + private Mock _personnelLocationResolver; + private Mock _shiftsService; + private Mock _runCardActivationsRepository; + private Mock _eventAggregator; + private DispatchRecommendationService _service; + + private DepartmentGroup _stationA; + private DepartmentGroup _stationB; + private DispatchRecommendationConfig _config; + + [SetUp] + public void SetUp() + { + _runCardsService = new Mock(); + _unitsService = new Mock(); + _actionLogsService = new Mock(); + _userStateService = new Mock(); + _personnelRolesService = new Mock(); + _customStateService = new Mock(); + _departmentGroupsService = new Mock(); + _departmentSettingsService = new Mock(); + _geoService = new Mock(); + _personnelLocationResolver = new Mock(); + _shiftsService = new Mock(); + _runCardActivationsRepository = new Mock(); + _eventAggregator = new Mock(); + + _stationA = new DepartmentGroup { DepartmentGroupId = StationAId, DepartmentId = DepartmentId, Name = "Station 1", Type = (int)DepartmentGroupTypes.Station }; + _stationB = new DepartmentGroup { DepartmentGroupId = StationBId, DepartmentId = DepartmentId, Name = "Station 2", Type = (int)DepartmentGroupTypes.Station }; + _config = new DispatchRecommendationConfig(); + + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.StationBased); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationAutoDispatchAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(false); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationConfigAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(() => _config); + _departmentSettingsService.Setup(x => x.GetDispatchShiftInsteadOfGroupAsync(DepartmentId)) + .ReturnsAsync(false); + + _unitsService.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(DepartmentId)) + .ReturnsAsync(new List + { + new Unit { UnitId = 1, DepartmentId = DepartmentId, Name = "Engine 1", Type = "Engine", StationGroupId = StationAId, StationGroup = _stationA }, + new Unit { UnitId = 2, DepartmentId = DepartmentId, Name = "Engine 2", Type = "Engine", StationGroupId = StationBId, StationGroup = _stationB }, + new Unit { UnitId = 3, DepartmentId = DepartmentId, Name = "Ladder 1", Type = "Ladder", StationGroupId = StationAId, StationGroup = _stationA } + }); + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)) + .ReturnsAsync(new List()); + _unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new UnitType { UnitTypeId = EngineTypeId, DepartmentId = DepartmentId, Type = "Engine" }, + new UnitType { UnitTypeId = LadderTypeId, DepartmentId = DepartmentId, Type = "Ladder" } + }); + _unitsService.Setup(x => x.GetLatestUnitLocationsAsync(DepartmentId)) + .ReturnsAsync(new List()); + _unitsService.Setup(x => x.GetUnitStaffingForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new Dictionary()); + + _customStateService.Setup(x => x.GetAllActiveUnitStatesForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List()); + _customStateService.Setup(x => x.GetActivePersonnelStateForDepartmentAsync(DepartmentId)) + .ReturnsAsync((CustomState)null); + _customStateService.Setup(x => x.GetActiveStaffingLevelsForDepartmentAsync(DepartmentId)) + .ReturnsAsync((CustomState)null); + + _personnelRolesService.Setup(x => x.GetAllRolesForUsersInDepartmentAsync(DepartmentId)) + .ReturnsAsync(new Dictionary> + { + { "user-1", new List { new PersonnelRole { PersonnelRoleId = FirefighterRoleId, Name = "Firefighter" } } }, + { "user-2", new List { new PersonnelRole { PersonnelRoleId = FirefighterRoleId, Name = "Firefighter" } } } + }); + _actionLogsService.Setup(x => x.GetLastActionLogsForDepartmentAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + _userStateService.Setup(x => x.GetLatestStatesForDepartmentAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new List()); + _departmentGroupsService.Setup(x => x.GetAllDepartmentGroupsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new Dictionary + { + { "user-1", _stationA }, + { "user-2", _stationB } + }); + _departmentGroupsService.Setup(x => x.GetGroupByIdAsync(StationAId, It.IsAny())).ReturnsAsync(_stationA); + _departmentGroupsService.Setup(x => x.GetGroupByIdAsync(StationBId, It.IsAny())).ReturnsAsync(_stationB); + + _geoService.Setup(x => x.OrderStationsByDistanceAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new StationDistanceResult { Station = _stationA, Latitude = 39.75, Longitude = -104.95, DistanceMeters = 100, ContainsPoint = true, HasGeofence = true }, + new StationDistanceResult { Station = _stationB, Latitude = 39.70, Longitude = -104.90, DistanceMeters = 5000, ContainsPoint = false, HasGeofence = true } + }); + _geoService.Setup(x => x.GetStationCoordinatesAsync(It.IsAny())) + .ReturnsAsync(new GeoMath.GeoPoint(39.75, -104.95)); + + _personnelLocationResolver.Setup(x => x.GetLatestLocationsAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new Dictionary()); + + _runCardsService.Setup(x => x.GetLastUnitDispatchTimesAsync(DepartmentId)) + .ReturnsAsync(new Dictionary()); + _runCardsService.Setup(x => x.GetLastUserDispatchTimesAsync(DepartmentId)) + .ReturnsAsync(new Dictionary()); + _runCardsService.Setup(x => x.GetStationCoverageRequirementsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List()); + + _service = new DispatchRecommendationService( + _runCardsService.Object, + _unitsService.Object, + _actionLogsService.Object, + _userStateService.Object, + _personnelRolesService.Object, + _customStateService.Object, + _departmentGroupsService.Object, + _departmentSettingsService.Object, + _geoService.Object, + _personnelLocationResolver.Object, + _shiftsService.Object, + _runCardActivationsRepository.Object, + _eventAggregator.Object); + } + + private RunCard BuildCard(int engineCount = 0, int roleCount = 0, int alarmLevel = 1) + { + var level = new RunCardAlarmLevel + { + RunCardAlarmLevelId = 50, + RunCardId = 5, + AlarmLevel = alarmLevel, + UnitRequirements = new List(), + RoleRequirements = new List() + }; + + if (engineCount > 0) + level.UnitRequirements.Add(new RunCardUnitRequirement { RunCardUnitRequirementId = 1000, RunCardAlarmLevelId = 50, UnitTypeId = EngineTypeId, RequiredCount = engineCount }); + + if (roleCount > 0) + level.RoleRequirements.Add(new RunCardRoleRequirement { RunCardRoleRequirementId = 2000, RunCardAlarmLevelId = 50, PersonnelRoleId = FirefighterRoleId, RequiredCount = roleCount }); + + var card = new RunCard + { + RunCardId = 5, + DepartmentId = DepartmentId, + Name = "Structure Fire", + AlarmLevels = new List { level }, + Triggers = new List(), + AvailabilitySelections = new List() + }; + + _runCardsService.Setup(x => x.GetMatchingRunCardAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(card); + + return card; + } + + private static DispatchRecommendationRequest BuildRequest(double? lat = 39.75, double? lon = -104.95) + { + return new DispatchRecommendationRequest + { + DepartmentId = DepartmentId, + Priority = 3, + CallTypeName = "Structure Fire", + Latitude = lat, + Longitude = lon, + TargetAlarmLevel = 1 + }; + } + + [Test] + public async Task returns_noop_result_when_no_card_matches() + { + _runCardsService.Setup(x => x.GetMatchingRunCardAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync((RunCard)null); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.MatchedRunCardId.Should().BeNull(); + result.HasRecommendations.Should().BeFalse(); + } + + [Test] + public async Task station_based_fills_from_containing_station_then_cascades() + { + BuildCard(engineCount: 2); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().HaveCount(2); + result.Units[0].UnitId.Should().Be(1); + result.Units[0].SelectionReason.Should().Be(RecommendationSelectionReasons.InGeofence); + result.Units[0].CascadeDepth.Should().Be(0); + result.Units[1].UnitId.Should().Be(2); + result.Units[1].SelectionReason.Should().Be(RecommendationSelectionReasons.CascadeStation); + result.Units[1].CascadeDepth.Should().Be(1); + result.Shortfalls.Should().BeEmpty(); + } + + [Test] + public async Task station_based_reports_shortfall_when_stations_exhausted() + { + BuildCard(engineCount: 3); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().HaveCount(2); + result.Shortfalls.Should().ContainSingle(s => + s.IsUnitRequirement && s.RequiredCount == 3 && s.FilledCount == 2 && s.Reason == RequirementShortfallReasons.StationsExhausted); + } + + [Test] + public async Task committed_units_are_not_candidates() + { + BuildCard(engineCount: 2); + + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)) + .ReturnsAsync(new List + { + new UnitState { UnitId = 1, State = (int)UnitStateTypes.Committed, Timestamp = DateTime.UtcNow } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().ContainSingle(u => u.UnitId == 2); + result.Shortfalls.Should().ContainSingle(s => s.FilledCount == 1); + } + + [Test] + public async Task custom_status_selections_override_matrix_availability() + { + var card = BuildCard(engineCount: 2); + + // The card only counts built-in Available as dispatchable; Engine 2 sits + // Delayed which the matrix would allow but the selection set excludes. + card.AvailabilitySelections.Add(new RunCardAvailabilitySelection + { + RunCardId = 5, + SelectionType = (int)RunCardSelectionTypes.UnitStatus, + IsCustomState = false, + StateId = (int)UnitStateTypes.Available + }); + + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)) + .ReturnsAsync(new List + { + new UnitState { UnitId = 2, State = (int)UnitStateTypes.Delayed, Timestamp = DateTime.UtcNow } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().ContainSingle(u => u.UnitId == 1); + } + + [Test] + public async Task no_location_and_no_home_station_shortfalls_everything() + { + BuildCard(engineCount: 1, roleCount: 1); + + var result = await _service.GetRecommendationAsync(BuildRequest(lat: null, lon: null)); + + result.Units.Should().BeEmpty(); + result.Personnel.Should().BeEmpty(); + result.Shortfalls.Should().HaveCount(2); + result.Shortfalls.Should().OnlyContain(s => s.Reason == RequirementShortfallReasons.NoLocationData); + } + + [Test] + public async Task staffing_gate_excludes_understaffed_units_but_unknown_passes() + { + BuildCard(engineCount: 2); + _config.UnitMinimumStaffingLevel = (int)UnitStaffingLevel.FullyStaffed; + + _unitsService.Setup(x => x.GetUnitStaffingForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new Dictionary + { + { 1, new UnitRoleStaffingResult { UnitId = 1, Level = UnitStaffingLevel.NotStaffed, DefinedRoleCount = 4 } } + // Unit 2 has no entry -> Unknown -> passes. + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().ContainSingle(u => u.UnitId == 2); + result.Shortfalls.Should().ContainSingle(s => s.Reason == RequirementShortfallReasons.UnitsNotStaffed); + } + + [Test] + public async Task rest_period_prefers_rested_unit_from_farther_station() + { + BuildCard(engineCount: 1); + _config.RestPeriodMinutes = 60; + + _runCardsService.Setup(x => x.GetLastUnitDispatchTimesAsync(DepartmentId)) + .ReturnsAsync(new Dictionary + { + { 1, DateTime.UtcNow.AddMinutes(-10) } // Engine 1 dispatched 10 minutes ago. + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().ContainSingle(u => u.UnitId == 2 && u.SelectionReason == RecommendationSelectionReasons.CascadeStation); + } + + [Test] + public async Task rest_period_unit_still_picked_when_nothing_else_can_fill() + { + BuildCard(engineCount: 2); + _config.RestPeriodMinutes = 60; + + _runCardsService.Setup(x => x.GetLastUnitDispatchTimesAsync(DepartmentId)) + .ReturnsAsync(new Dictionary + { + { 1, DateTime.UtcNow.AddMinutes(-10) } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().HaveCount(2); + result.Units.Should().ContainSingle(u => u.UnitId == 1 && u.SelectionReason == RecommendationSelectionReasons.RestPeriodOverridden); + } + + [Test] + public async Task already_dispatched_units_are_excluded_for_escalation() + { + BuildCard(engineCount: 1); + + var request = BuildRequest(); + request.AlreadyDispatchedUnitIds.Add(1); + + var result = await _service.GetRecommendationAsync(request); + + result.Units.Should().ContainSingle(u => u.UnitId == 2); + } + + [Test] + public async Task station_based_fills_role_requirements_from_group_membership() + { + BuildCard(roleCount: 2); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Personnel.Should().HaveCount(2); + result.Personnel.Should().ContainSingle(p => p.UserId == "user-1" && p.SelectionReason == RecommendationSelectionReasons.InGeofence); + result.Personnel.Should().ContainSingle(p => p.UserId == "user-2" && p.SelectionReason == RecommendationSelectionReasons.CascadeStation); + } + + [Test] + public async Task closest_unit_orders_by_distance_and_flags_radius_exclusions() + { + BuildCard(engineCount: 1); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + + var now = DateTime.UtcNow; + _unitsService.Setup(x => x.GetLatestUnitLocationsAsync(DepartmentId)) + .ReturnsAsync(new List + { + // Engine 2 is much closer to the call than Engine 1. + new UnitsLocation { UnitId = 1, Latitude = 40.5m, Longitude = -105.5m, Timestamp = now }, + new UnitsLocation { UnitId = 2, Latitude = 39.7501m, Longitude = -104.9501m, Timestamp = now } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().ContainSingle(u => u.UnitId == 2 && u.SelectionReason == RecommendationSelectionReasons.ClosestByDistance); + + // Now cap the radius so tight that nothing qualifies. + _config.MaxRadiusMeters = 1; + + var capped = await _service.GetRecommendationAsync(BuildRequest()); + + capped.Units.Should().BeEmpty(); + capped.Shortfalls.Should().ContainSingle(s => s.Reason == RequirementShortfallReasons.OutsideRadius); + } + + [Test] + public async Task closest_unit_excludes_stale_fixes_unless_configured_in() + { + BuildCard(engineCount: 2); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + _config.MaxLocationAgeSeconds = 600; + + var now = DateTime.UtcNow; + _unitsService.Setup(x => x.GetLatestUnitLocationsAsync(DepartmentId)) + .ReturnsAsync(new List + { + new UnitsLocation { UnitId = 1, Latitude = 39.7501m, Longitude = -104.9501m, Timestamp = now.AddHours(-2) }, + new UnitsLocation { UnitId = 2, Latitude = 39.76m, Longitude = -104.96m, Timestamp = now } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().ContainSingle(u => u.UnitId == 2); + result.Shortfalls.Should().ContainSingle(s => s.Reason == RequirementShortfallReasons.LocationsTooStale); + + _config.IncludeStaleLocations = true; + + var withStale = await _service.GetRecommendationAsync(BuildRequest()); + + withStale.Units.Should().HaveCount(2); + withStale.Units.Should().ContainSingle(u => u.UnitId == 1 && u.LocationIsStale); + } + + [Test] + public async Task move_up_pass_flags_station_coverage_gap_with_donor() + { + BuildCard(engineCount: 1); + _config.MoveUpRecommendationsEnabled = true; + + _runCardsService.Setup(x => x.GetStationCoverageRequirementsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new StationCoverageRequirement + { + StationCoverageRequirementId = 1, + DepartmentId = DepartmentId, + DepartmentGroupId = StationAId, + UnitTypeId = EngineTypeId, + MinimumAvailableCount = 1, + IsEnabled = true + } + }); + + // Engine 1 (Station A's only engine) gets recommended for the call, leaving + // Station A at zero engines -> move-up from Station B. + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().ContainSingle(u => u.UnitId == 1); + result.MoveUps.Should().ContainSingle(m => + m.StationGroupId == StationAId && m.SuggestedUnitId == 2 && m.FromStationGroupId == StationBId); + } + + [Test] + public async Task enrich_call_adds_dispatch_rows_without_duplicates_and_stamps_run_card() + { + BuildCard(engineCount: 2, roleCount: 1); + + var call = new Call + { + CallId = 77, + DepartmentId = DepartmentId, + Priority = 3, + Type = "Structure Fire", + GeoLocationData = "39.75,-104.95", + AlarmLevel = 0, + UnitDispatches = new System.Collections.ObjectModel.Collection + { + new CallDispatchUnit { CallId = 77, UnitId = 1 } // Engine 1 already on the call. + } + }; + + var result = await _service.EnrichCallForDispatchAsync(call, 1); + + result.MatchedRunCardId.Should().Be(5); + call.ActiveRunCardId.Should().Be(5); + call.AlarmLevel.Should().Be(1); + + // Engine 1 was excluded from recommendations (already dispatched) and must not + // be duplicated; Engine 2 gets added. + call.UnitDispatches.Should().HaveCount(2); + call.UnitDispatches.Count(d => d.UnitId == 1).Should().Be(1); + call.UnitDispatches.Should().Contain(d => d.UnitId == 2); + call.Dispatches.Should().NotBeNull(); + call.Dispatches.Should().ContainSingle(d => d.UserId == "user-1"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/GeoMathTests.cs b/Tests/Resgrid.Tests/Services/GeoMathTests.cs new file mode 100644 index 000000000..c813c7780 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/GeoMathTests.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Services +{ + namespace GeoMathTests + { + [TestFixture] + public class when_parsing_geofences + { + [Test] + public void should_parse_current_lat_lng_format() + { + var json = "[{\"lat\":39.7,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.8}]"; + + var polygon = GeoMath.ParseGeofence(json); + + polygon.Should().NotBeNull(); + polygon.Count.Should().Be(3); + polygon[0].Latitude.Should().BeApproximately(39.7, 0.0001); + polygon[0].Longitude.Should().BeApproximately(-104.9, 0.0001); + } + + [Test] + public void should_parse_legacy_k_A_format() + { + var json = "[{\"k\":39.7,\"A\":-104.9},{\"k\":39.8,\"A\":-104.9},{\"k\":39.8,\"A\":-104.8}]"; + + var polygon = GeoMath.ParseGeofence(json); + + polygon.Should().NotBeNull(); + polygon.Count.Should().Be(3); + polygon[1].Latitude.Should().BeApproximately(39.8, 0.0001); + } + + [Test] + public void should_parse_string_encoded_numbers() + { + var json = "[{\"lat\":\"39.7\",\"lng\":\"-104.9\"},{\"lat\":\"39.8\",\"lng\":\"-104.9\"},{\"lat\":\"39.8\",\"lng\":\"-104.8\"}]"; + + GeoMath.ParseGeofence(json).Should().NotBeNull(); + } + + [Test] + public void should_return_null_for_null_empty_or_garbage() + { + GeoMath.ParseGeofence(null).Should().BeNull(); + GeoMath.ParseGeofence("").Should().BeNull(); + GeoMath.ParseGeofence(" ").Should().BeNull(); + GeoMath.ParseGeofence("not json").Should().BeNull(); + GeoMath.ParseGeofence("{\"lat\":1}").Should().BeNull(); + GeoMath.ParseGeofence("[{\"foo\":1,\"bar\":2},{\"foo\":1,\"bar\":2},{\"foo\":1,\"bar\":2}]").Should().BeNull(); + } + + [Test] + public void should_return_null_for_degenerate_polygons() + { + GeoMath.ParseGeofence("[]").Should().BeNull(); + GeoMath.ParseGeofence("[{\"lat\":39.7,\"lng\":-104.9}]").Should().BeNull(); + GeoMath.ParseGeofence("[{\"lat\":39.7,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.9}]").Should().BeNull(); + } + } + + [TestFixture] + public class when_testing_point_in_polygon + { + // A simple square around downtown Denver. + private static readonly List Square = new List + { + new GeoMath.GeoPoint(39.70, -105.00), + new GeoMath.GeoPoint(39.80, -105.00), + new GeoMath.GeoPoint(39.80, -104.90), + new GeoMath.GeoPoint(39.70, -104.90) + }; + + [Test] + public void should_detect_point_inside() + { + GeoMath.IsPointInPolygon(39.75, -104.95, Square).Should().BeTrue(); + } + + [Test] + public void should_detect_point_outside() + { + GeoMath.IsPointInPolygon(39.85, -104.95, Square).Should().BeFalse(); + GeoMath.IsPointInPolygon(39.75, -104.85, Square).Should().BeFalse(); + GeoMath.IsPointInPolygon(0, 0, Square).Should().BeFalse(); + } + + [Test] + public void should_handle_concave_polygons() + { + // A "U" shape: the notch between the arms is outside. + var u = new List + { + new GeoMath.GeoPoint(0, 0), + new GeoMath.GeoPoint(0, 10), + new GeoMath.GeoPoint(10, 10), + new GeoMath.GeoPoint(10, 7), + new GeoMath.GeoPoint(2, 7), + new GeoMath.GeoPoint(2, 3), + new GeoMath.GeoPoint(10, 3), + new GeoMath.GeoPoint(10, 0) + }; + + GeoMath.IsPointInPolygon(1, 5, u).Should().BeTrue(); // bottom of the U + GeoMath.IsPointInPolygon(5, 5, u).Should().BeFalse(); // inside the notch + GeoMath.IsPointInPolygon(5, 8, u).Should().BeTrue(); // right arm + } + + [Test] + public void should_return_false_for_missing_or_degenerate_polygon() + { + GeoMath.IsPointInPolygon(39.75, -104.95, null).Should().BeFalse(); + GeoMath.IsPointInPolygon(39.75, -104.95, new List()).Should().BeFalse(); + } + } + + [TestFixture] + public class when_computing_centroid_and_distance + { + [Test] + public void centroid_of_square_is_its_center() + { + var square = new List + { + new GeoMath.GeoPoint(0, 0), + new GeoMath.GeoPoint(0, 10), + new GeoMath.GeoPoint(10, 10), + new GeoMath.GeoPoint(10, 0) + }; + + var centroid = GeoMath.Centroid(square); + + centroid.Latitude.Should().BeApproximately(5, 0.0001); + centroid.Longitude.Should().BeApproximately(5, 0.0001); + } + + [Test] + public void haversine_matches_known_distance() + { + // Denver (39.7392, -104.9903) to Colorado Springs (38.8339, -104.8214) ≈ 101.6 km. + var meters = GeoMath.HaversineMeters(39.7392, -104.9903, 38.8339, -104.8214); + + meters.Should().BeInRange(99000, 104000); + } + + [Test] + public void haversine_is_zero_for_identical_points() + { + GeoMath.HaversineMeters(39.7392, -104.9903, 39.7392, -104.9903).Should().BeApproximately(0, 0.001); + } + } + + [TestFixture] + public class when_parsing_coordinate_strings + { + [Test] + public void should_parse_valid_pairs_and_lat_lon_blobs() + { + var pair = GeoMath.ParseCoordinatePair("39.7392", "-104.9903"); + pair.Should().NotBeNull(); + pair.Value.Latitude.Should().BeApproximately(39.7392, 0.0001); + + var blob = GeoMath.ParseLatLonString("39.7392,-104.9903"); + blob.Should().NotBeNull(); + blob.Value.Longitude.Should().BeApproximately(-104.9903, 0.0001); + } + + [Test] + public void should_reject_missing_zero_or_garbage_input() + { + GeoMath.ParseCoordinatePair(null, "-104.9").Should().BeNull(); + GeoMath.ParseCoordinatePair("39.7", "").Should().BeNull(); + GeoMath.ParseCoordinatePair("abc", "def").Should().BeNull(); + GeoMath.ParseCoordinatePair("0", "0").Should().BeNull(); + GeoMath.ParseLatLonString(null).Should().BeNull(); + GeoMath.ParseLatLonString("39.7392").Should().BeNull(); + GeoMath.ParseLatLonString("a,b").Should().BeNull(); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs b/Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs new file mode 100644 index 000000000..0338a95bd --- /dev/null +++ b/Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + namespace RunCardsServiceTests + { + [TestFixture] + public class when_evaluating_run_card_trigger_specificity + { + private static readonly DateTime Now = new DateTime(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc); + + private static RunCard CardWithTriggers(params RunCardTrigger[] triggers) + { + return new RunCard + { + RunCardId = 1, + DepartmentId = 1, + Name = "Test Card", + Triggers = new List(triggers) + }; + } + + [Test] + public void should_be_null_for_null_card() + { + RunCardsService.GetTriggerMatchSpecificity(null, 3, 10, Now).Should().BeNull(); + } + + [Test] + public void should_be_null_for_card_without_triggers() + { + var card = new RunCard { RunCardId = 1, Triggers = new List() }; + + RunCardsService.GetTriggerMatchSpecificity(card, 3, 10, Now).Should().BeNull(); + } + + [Test] + public void should_match_priority_only_trigger() + { + var card = CardWithTriggers(new RunCardTrigger { TriggerType = (int)RunCardTriggerTypes.CallPriority, Priority = 3 }); + + RunCardsService.GetTriggerMatchSpecificity(card, 3, null, Now).Should().Be(1); + } + + [Test] + public void should_not_match_different_priority() + { + var card = CardWithTriggers(new RunCardTrigger { TriggerType = (int)RunCardTriggerTypes.CallPriority, Priority = 3 }); + + RunCardsService.GetTriggerMatchSpecificity(card, 2, null, Now).Should().BeNull(); + } + + [Test] + public void should_match_department_priority_above_system_range() + { + var card = CardWithTriggers(new RunCardTrigger { TriggerType = (int)RunCardTriggerTypes.CallPriority, Priority = 17 }); + + RunCardsService.GetTriggerMatchSpecificity(card, 17, null, Now).Should().Be(1); + } + + [Test] + public void should_match_call_type_trigger() + { + var card = CardWithTriggers(new RunCardTrigger { TriggerType = (int)RunCardTriggerTypes.CallType, CallTypeId = 10 }); + + RunCardsService.GetTriggerMatchSpecificity(card, 0, 10, Now).Should().Be(2); + } + + [Test] + public void should_not_match_call_type_trigger_when_call_has_no_type() + { + var card = CardWithTriggers(new RunCardTrigger { TriggerType = (int)RunCardTriggerTypes.CallType, CallTypeId = 10 }); + + RunCardsService.GetTriggerMatchSpecificity(card, 0, null, Now).Should().BeNull(); + } + + [Test] + public void should_match_priority_and_type_trigger_only_when_both_match() + { + var card = CardWithTriggers(new RunCardTrigger + { + TriggerType = (int)RunCardTriggerTypes.CallPriorityAndType, + Priority = 3, + CallTypeId = 10 + }); + + RunCardsService.GetTriggerMatchSpecificity(card, 3, 10, Now).Should().Be(3); + RunCardsService.GetTriggerMatchSpecificity(card, 3, 11, Now).Should().BeNull(); + RunCardsService.GetTriggerMatchSpecificity(card, 2, 10, Now).Should().BeNull(); + } + + [Test] + public void should_return_strongest_specificity_when_multiple_triggers_match() + { + var card = CardWithTriggers( + new RunCardTrigger { TriggerType = (int)RunCardTriggerTypes.CallPriority, Priority = 3 }, + new RunCardTrigger { TriggerType = (int)RunCardTriggerTypes.CallPriorityAndType, Priority = 3, CallTypeId = 10 }); + + RunCardsService.GetTriggerMatchSpecificity(card, 3, 10, Now).Should().Be(3); + } + + [Test] + public void should_ignore_trigger_before_its_window_starts() + { + var card = CardWithTriggers(new RunCardTrigger + { + TriggerType = (int)RunCardTriggerTypes.CallPriority, + Priority = 3, + StartsOn = Now.AddHours(1) + }); + + RunCardsService.GetTriggerMatchSpecificity(card, 3, null, Now).Should().BeNull(); + } + + [Test] + public void should_ignore_trigger_after_its_window_ends() + { + var card = CardWithTriggers(new RunCardTrigger + { + TriggerType = (int)RunCardTriggerTypes.CallPriority, + Priority = 3, + EndsOn = Now.AddHours(-1) + }); + + RunCardsService.GetTriggerMatchSpecificity(card, 3, null, Now).Should().BeNull(); + } + + [Test] + public void should_match_trigger_inside_its_window() + { + var card = CardWithTriggers(new RunCardTrigger + { + TriggerType = (int)RunCardTriggerTypes.CallPriority, + Priority = 3, + StartsOn = Now.AddHours(-1), + EndsOn = Now.AddHours(1) + }); + + RunCardsService.GetTriggerMatchSpecificity(card, 3, null, Now).Should().Be(1); + } + + [Test] + public void should_treat_null_window_bounds_as_open_ended() + { + var card = CardWithTriggers(new RunCardTrigger + { + TriggerType = (int)RunCardTriggerTypes.CallPriority, + Priority = 3, + StartsOn = null, + EndsOn = null + }); + + RunCardsService.GetTriggerMatchSpecificity(card, 3, null, Now).Should().Be(1); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs index 78425f524..563c10b76 100644 --- a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs @@ -1,3 +1,9 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -5,26 +11,47 @@ using Microsoft.AspNetCore.Mvc; using Moq; using NUnit.Framework; +using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Web.Services.Controllers.v4; using Resgrid.Web.Services.Models.v4.Calls; +using Resgrid.Web.ServicesCore.Helpers; namespace Resgrid.Tests.Web.Services { [TestFixture] + [NonParallelizable] public class CallsControllerTests { + private const int DepartmentId = 10; + private const string UserId = "call-viewer"; + private Mock _callsService; private Mock _authorizationService; + private Mock _protocolsService; private CallsController _controller; + private Activity _activity; [SetUp] public void SetUp() { _callsService = new Mock(); _authorizationService = new Mock(); + _protocolsService = new Mock(); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + _activity = new Activity("CallsControllerTests").Start(); + _controller = new CallsController( _callsService.Object, Mock.Of(), @@ -37,7 +64,7 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of(), + _protocolsService.Object, Mock.Of(), Mock.Of(), Mock.Of(), @@ -46,12 +73,21 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()) + Mock.Of(), + Mock.Of(), + Mock.Of()) { - ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + ControllerContext = new ControllerContext { HttpContext = httpContext } }; } + [TearDown] + public void TearDown() + { + ClaimsAuthorizationHelper._httpContextAccessor = null; + _activity?.Stop(); + } + [TestCase(null)] [TestCase("")] [TestCase("u3246")] @@ -66,6 +102,56 @@ public async Task GetCall_ReturnsBadRequest_WhenCallIdIsInvalid(string callId) Times.Never); } + [Test] + public async Task GetCall_HydratesProtocols_UsingDispatchProtocolId() + { + var call = new Call + { + CallId = 42, + DepartmentId = DepartmentId, + Name = "Structure Fire", + Address = "123 Main St", + LoggedOn = DateTime.UtcNow, + Protocols = new Collection + { + new CallProtocol { CallProtocolId = 999, CallId = 42, DispatchProtocolId = 5 } + } + }; + + _callsService + .Setup(service => service.GetCallByIdAsync(42, It.IsAny())) + .ReturnsAsync(call); + _callsService + .Setup(service => service.PopulateCallData(call, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(call); + _authorizationService + .Setup(service => service.CanUserViewCallAsync(UserId, 42)) + .ReturnsAsync(true); + _protocolsService + .Setup(service => service.GetProtocolByIdAsync(5)) + .ReturnsAsync(new DispatchProtocol + { + DispatchProtocolId = 5, + DepartmentId = DepartmentId, + Name = "Fire Response", + Code = "FIRE", + Triggers = new Collection(), + Attachments = new Collection(), + Questions = new Collection() + }); + + var response = await _controller.GetCall("42"); + + var result = response.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + result.Data.Protocols.Should().ContainSingle(p => p.Id == "5"); + + _protocolsService.Verify(service => service.GetProtocolByIdAsync(5), Times.Once); + _protocolsService.Verify(service => service.GetProtocolByIdAsync(999), Times.Never); + } + [TestCase(null)] [TestCase("")] [TestCase("not-a-number")] diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 951af8ad8..481395363 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -529,7 +529,8 @@ public TestableTwilioController( encryptionService, twilioVoiceResponseService, featureToggleService, - textDepartmentSwitchService) + textDepartmentSwitchService, + Mock.Of()) { } diff --git a/Tests/Resgrid.Tests/Web/User/ProtocolsControllerTests.cs b/Tests/Resgrid.Tests/Web/User/ProtocolsControllerTests.cs new file mode 100644 index 000000000..8f120a750 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/ProtocolsControllerTests.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.WebCore.Areas.User.Models.Protocols; + +namespace Resgrid.Tests.Web.User +{ + [TestFixture] + [NonParallelizable] + public class ProtocolsControllerTests + { + private const int DepartmentId = 10; + private const string UserId = "protocol-admin"; + + private Mock _protocolsService; + private ProtocolsController _controller; + + [SetUp] + public void SetUp() + { + _protocolsService = new Mock(); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = + new HttpContextAccessor { HttpContext = httpContext }; + + _controller = new ProtocolsController( + _protocolsService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + [Test] + public async Task NewProtocol_SavesTriggerWithCallPriority_NotTriggerType() + { + DispatchProtocol savedProtocol = null; + _protocolsService + .Setup(service => service.SaveProtocolAsync(It.IsAny(), It.IsAny())) + .Callback((protocol, _) => savedProtocol = protocol) + .ReturnsAsync((DispatchProtocol protocol, CancellationToken _) => protocol); + + var model = new NewProtocolModel + { + Protocol = new DispatchProtocol + { + Name = "Test Protocol", + Code = "test" + } + }; + + var form = new FormCollection(new Dictionary + { + { "triggerType_0", "2" }, + { "triggerStartsOn_0", "" }, + { "triggerEndsOn_0", "" }, + { "triggerCallPriority_0", "3" }, + { "triggerCallType_0", "Fire" } + }); + + var result = await _controller.New(model, form, null); + + result.Should().BeOfType() + .Which.ActionName.Should().Be("Index"); + + savedProtocol.Should().NotBeNull(); + savedProtocol.Code.Should().Be("TEST"); + var trigger = savedProtocol.Triggers.Should().ContainSingle().Subject; + trigger.Type.Should().Be(2); + trigger.Priority.Should().Be(3); + trigger.CallType.Should().Be("Fire"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/EmailController.cs b/Web/Resgrid.Web.Services/Controllers/EmailController.cs index 319df77a3..f33901a35 100644 --- a/Web/Resgrid.Web.Services/Controllers/EmailController.cs +++ b/Web/Resgrid.Web.Services/Controllers/EmailController.cs @@ -46,13 +46,16 @@ public class EmailController : ControllerBase private readonly IUnitsService _unitsService; private readonly IGeoLocationProvider _geoLocationProvider; private readonly ICallDispatchStatusService _callDispatchStatusService; + private readonly IDispatchRecommendationService _dispatchRecommendationService; + private readonly IFeatureToggleService _featureToggleService; public EmailController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IDistributionListsService distributionListsService, IUsersService usersService, IEmailService emailService, IDepartmentGroupsService departmentGroupsService, IMessageService messageService, - IFileService fileService, IUnitsService unitsService, IGeoLocationProvider geoLocationProvider, ICallDispatchStatusService callDispatchStatusService) + IFileService fileService, IUnitsService unitsService, IGeoLocationProvider geoLocationProvider, ICallDispatchStatusService callDispatchStatusService, + IDispatchRecommendationService dispatchRecommendationService, IFeatureToggleService featureToggleService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -74,6 +77,8 @@ public EmailController(IDepartmentSettingsService departmentSettingsService, INu _unitsService = unitsService; _geoLocationProvider = geoLocationProvider; _callDispatchStatusService = callDispatchStatusService; + _dispatchRecommendationService = dispatchRecommendationService; + _featureToggleService = featureToggleService; } #endregion Private Readonly Properties and Constructors @@ -639,6 +644,28 @@ public async Task Receive(PostmarkInboundMessage message, Cancella private async Task QueueCallBroadcastAsync(Call savedCall, CancellationToken cancellationToken) { var call = await _callsService.PopulateCallData(savedCall, true, false, false, true, true, true, false, false, false); + + // Run card auto-dispatch for email-originated calls: additively merge + // recommended resources when the resolved auto-dispatch decision is on. + try + { + if (await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, call.DepartmentId)) + { + var recommendation = await _dispatchRecommendationService.EnrichCallForDispatchAsync(call, 1, true, cancellationToken); + + if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) + { + call = await _callsService.SaveCallAsync(call, cancellationToken); + await _dispatchRecommendationService.RecordActivationAsync(call, recommendation, null, cancellationToken); + } + } + } + catch (Exception ex) + { + // A recommendation failure must never block the email-originated dispatch itself. + Logging.LogException(ex); + } + var cqi = new CallQueueItem(); cqi.Call = call; diff --git a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs index 16f62fac3..dd9fb48be 100644 --- a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs +++ b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs @@ -49,6 +49,8 @@ public class SignalWireController : ControllerBase private readonly IChatbotIngressService _chatbotIngressService; private readonly IUsersService _usersService; private readonly ITextDepartmentSwitchService _textDepartmentSwitchService; + private readonly IDispatchRecommendationService _dispatchRecommendationService; + private readonly IFeatureToggleService _featureToggleService; public SignalWireController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, @@ -56,7 +58,8 @@ public SignalWireController(IDepartmentSettingsService departmentSettingsService IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, ICommunicationTestService communicationTestService, IChatbotIngressService chatbotIngressService, IUsersService usersService, - ITextDepartmentSwitchService textDepartmentSwitchService) + ITextDepartmentSwitchService textDepartmentSwitchService, IDispatchRecommendationService dispatchRecommendationService, + IFeatureToggleService featureToggleService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -77,6 +80,8 @@ public SignalWireController(IDepartmentSettingsService departmentSettingsService _chatbotIngressService = chatbotIngressService; _usersService = usersService; _textDepartmentSwitchService = textDepartmentSwitchService; + _dispatchRecommendationService = dispatchRecommendationService; + _featureToggleService = featureToggleService; } #endregion Private Readonly Properties and Constructors @@ -254,6 +259,27 @@ public async Task Receive(CancellationToken cancellationToken) var savedCall = await _callsService.SaveCallAsync(c, cancellationToken); + // Run card auto-dispatch for text-to-call: additively merge + // recommended units/personnel when auto-dispatch resolves on. + try + { + if (await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, savedCall.DepartmentId)) + { + var recommendation = await _dispatchRecommendationService.EnrichCallForDispatchAsync(savedCall, 1, true, cancellationToken); + + if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) + { + savedCall = await _callsService.SaveCallAsync(savedCall, cancellationToken); + await _dispatchRecommendationService.RecordActivationAsync(savedCall, recommendation, null, cancellationToken); + } + } + } + catch (Exception ex) + { + // A recommendation failure must never block the text-to-call dispatch itself. + Logging.LogException(ex); + } + var cqi = new CallQueueItem(); cqi.Call = savedCall; cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(users.Select(x => x.UserId).ToList()); diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 0107be771..888185325 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -57,6 +57,7 @@ public class TwilioController : ControllerBase private readonly ITwilioVoiceResponseService _twilioVoiceResponseService; private readonly IFeatureToggleService _featureToggleService; private readonly ITextDepartmentSwitchService _textDepartmentSwitchService; + private readonly IDispatchRecommendationService _dispatchRecommendationService; public TwilioController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, @@ -65,7 +66,8 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, IUsersService usersService, ICalendarService calendarService, ICommunicationTestService communicationTestService, IEncryptionService encryptionService, ITwilioVoiceResponseService twilioVoiceResponseService, - IFeatureToggleService featureToggleService, ITextDepartmentSwitchService textDepartmentSwitchService) + IFeatureToggleService featureToggleService, ITextDepartmentSwitchService textDepartmentSwitchService, + IDispatchRecommendationService dispatchRecommendationService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -89,6 +91,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN _twilioVoiceResponseService = twilioVoiceResponseService; _featureToggleService = featureToggleService; _textDepartmentSwitchService = textDepartmentSwitchService; + _dispatchRecommendationService = dispatchRecommendationService; } #endregion Private Readonly Properties and Constructors @@ -349,6 +352,27 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t var savedCall = await _callsService.SaveCallAsync(c); + // Run card auto-dispatch for text-to-call: additively merge + // recommended units/personnel when auto-dispatch resolves on. + try + { + if (await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, savedCall.DepartmentId)) + { + var recommendation = await _dispatchRecommendationService.EnrichCallForDispatchAsync(savedCall, 1, true); + + if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) + { + savedCall = await _callsService.SaveCallAsync(savedCall); + await _dispatchRecommendationService.RecordActivationAsync(savedCall, recommendation, null); + } + } + } + catch (Exception ex) + { + // A recommendation failure must never block the text-to-call dispatch itself. + Logging.LogException(ex); + } + var cqi = new CallQueueItem(); cqi.Call = savedCall; cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(users.Select(x => x.UserId).ToList()); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 5dbcc0e9b..5927fc4e3 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -57,6 +57,8 @@ public class CallsController : V4AuthenticatedApiControllerbaseSystemAuth private readonly ICommunicationService _communicationService; private readonly IWeatherAlertService _weatherAlertService; private readonly ICallDispatchStatusService _callDispatchStatusService; + private readonly IDispatchRecommendationService _dispatchRecommendationService; + private readonly IFeatureToggleService _featureToggleService; public CallsController( ICallsService callsService, @@ -79,7 +81,9 @@ public CallsController( IUserDefinedFieldsService userDefinedFieldsService, ICommunicationService communicationService, IWeatherAlertService weatherAlertService, - ICallDispatchStatusService callDispatchStatusService + ICallDispatchStatusService callDispatchStatusService, + IDispatchRecommendationService dispatchRecommendationService, + IFeatureToggleService featureToggleService ) { _callsService = callsService; @@ -103,6 +107,8 @@ ICallDispatchStatusService callDispatchStatusService _communicationService = communicationService; _weatherAlertService = weatherAlertService; _callDispatchStatusService = callDispatchStatusService; + _dispatchRecommendationService = dispatchRecommendationService; + _featureToggleService = featureToggleService; } #endregion Members and Constructors @@ -203,7 +209,7 @@ public async Task> GetCall(string callId, [FromQuery { foreach (var callProtocol in c.Protocols) { - var protocol = await _protocolsService.GetProtocolByIdAsync(callProtocol.CallProtocolId); + var protocol = await _protocolsService.GetProtocolByIdAsync(callProtocol.DispatchProtocolId); if (protocol != null) protocols.Add(protocol); } @@ -755,8 +761,18 @@ public async Task> SaveCall([FromBody] NewCallInput if (call.DispatchOn.HasValue && call.DispatchOn.Value <= DateTime.UtcNow) call.HasBeenDispatched = true; + // Run card auto-dispatch: additively merge recommended resources before save + // (only applies when the resolved auto-dispatch decision is on). Covers all + // API-originated calls, including chatbot/MCP sources. + DispatchRecommendationResult recommendationResult = null; + if (shouldDispatchNow && await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, effectiveDepartmentId)) + recommendationResult = await _dispatchRecommendationService.EnrichCallForDispatchAsync(call, 1, true, cancellationToken); + var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); + if (recommendationResult != null && recommendationResult.MatchedRunCardId.HasValue && recommendationResult.AutoDispatch) + await _dispatchRecommendationService.RecordActivationAsync(savedCall, recommendationResult, UserId, cancellationToken); + // Attach weather alerts as call notes if enabled await _weatherAlertService.AttachWeatherAlertsToCallAsync(savedCall, cancellationToken); @@ -1329,6 +1345,86 @@ public async Task> UpdateSchedul return Ok(result); } + /// + /// "Strike Next Alarm": escalates a call to its next alarm level, additively + /// dispatching the active run card's next-level requirements and notifying only + /// the newly added resources. + /// + /// The call to escalate + /// + [HttpPut("EscalateCall")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Call_Update)] + public async Task EscalateCall(string callId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(callId) || !int.TryParse(callId, out var parsedCallId)) + return BadRequest(); + + var canDoOperation = await _authorizationService.CanUserEditCallAsync(UserId, parsedCallId); + + if (!canDoOperation) + return Unauthorized(); + + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return BadRequest(); + + var call = await _callsService.GetCallByIdAsync(parsedCallId); + + if (call == null) + return NotFound(); + + if (call.DepartmentId != DepartmentId) + return Unauthorized(); + + if (call.State != (int)CallStates.Active) + return BadRequest(); + + call = await _callsService.PopulateCallData(call, true, false, false, true, true, true, false, false, false); + + var previousAlarmLevel = Math.Max(1, call.AlarmLevel); + var escalationResult = await _dispatchRecommendationService.EnrichCallForDispatchAsync(call, previousAlarmLevel + 1, false, cancellationToken); + + if (!escalationResult.MatchedRunCardId.HasValue || !escalationResult.HasRecommendations) + return Ok(new { success = false, newAlarmLevel = previousAlarmLevel, addedUnits = 0, addedPersonnel = 0 }); + + var newUnitIds = escalationResult.Units.Select(u => u.UnitId).ToList(); + var newUserIds = escalationResult.Personnel.Select(p => p.UserId).ToList(); + + var escalatedCall = await _callsService.SaveCallAsync(call, cancellationToken); + + await _dispatchRecommendationService.RecordActivationAsync(escalatedCall, escalationResult, UserId, cancellationToken); + + if (newUnitIds.Any()) + await _callDispatchStatusService.ApplyDispatchStatusesAsync(escalatedCall, null, newUnitIds, cancellationToken); + + var escalationCqi = new CallQueueItem(); + escalationCqi.Call = escalatedCall; + + if (newUserIds.Any()) + escalationCqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(newUserIds); + else + escalationCqi.Profiles = new List(); + + escalationCqi.SetBroadcastDispatches(newUserIds, new List(), newUnitIds, new List()); + + await _queueService.EnqueueCallBroadcastAsync(escalationCqi, cancellationToken); + + _eventAggregator.SendMessage(new CallAlarmEscalatedEvent + { + DepartmentId = DepartmentId, + CallId = escalatedCall.CallId, + PreviousAlarmLevel = previousAlarmLevel, + NewAlarmLevel = escalatedCall.AlarmLevel, + AddedUnitIds = newUnitIds, + AddedUserIds = newUserIds + }); + + _eventAggregator.SendMessage(new CallUpdatedEvent() { DepartmentId = DepartmentId, Call = escalatedCall }); + + return Ok(new { success = true, newAlarmLevel = escalatedCall.AlarmLevel, addedUnits = newUnitIds.Count, addedPersonnel = newUserIds.Count }); + } + /// /// Deletes a call /// diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index 9f9a6237c..13014b70b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -221,6 +221,9 @@ public async Task> CreateDirectMessage([F if (input == null || (String.IsNullOrWhiteSpace(input.TargetUserId) && !input.TargetUnitId.HasValue)) return BadRequest(); + if (!input.TargetUnitId.HasValue && String.Equals(input.TargetUserId, UserId, StringComparison.OrdinalIgnoreCase)) + return BadRequest(); + var result = new ChatChannelCreatedResult(); var channel = await _chatChannelService.GetOrCreateDirectMessageChannelAsync(DepartmentId, UserId, input.TargetUserId, input.TargetUnitId, cancellationToken); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs index 438c840ea..1a32d8209 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs @@ -23,11 +23,14 @@ public class ConfigController : ControllerBase #region Members and Constructors private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IUserProfileService _userProfileService; + private readonly IFeatureToggleService _featureToggleService; - public ConfigController(IDepartmentSettingsService departmentSettingsService, IUserProfileService userProfileService) + public ConfigController(IDepartmentSettingsService departmentSettingsService, IUserProfileService userProfileService, + IFeatureToggleService featureToggleService) { _departmentSettingsService = departmentSettingsService; _userProfileService = userProfileService; + _featureToggleService = featureToggleService; } #endregion Members and Constructors @@ -142,6 +145,26 @@ private async Task BuildConfigResultAsync(string key, int depar departmentModernApplicationSoundsEnabled, userModernApplicationSoundsEnabled); + if (departmentId > 0) + { + try + { + result.Data.DispatchRunCardsEnabled = await _featureToggleService.IsEnabledAsync(Resgrid.Model.FeatureFlagKeys.DispatchRunCards, departmentId); + + if (result.Data.DispatchRunCardsEnabled) + { + result.Data.DispatchRecommendationMode = (int)await _departmentSettingsService.GetDispatchRecommendationModeAsync(departmentId); + result.Data.DispatchRecommendationAutoDispatch = await _departmentSettingsService.GetDispatchRecommendationAutoDispatchAsync(departmentId); + } + } + catch (System.Exception ex) + { + // A settings/flag store failure must not break config bootstrap for the apps. + Resgrid.Framework.Logging.LogException(ex, + $"{nameof(BuildConfigResultAsync)}: run card dispatch settings lookup failed for departmentId {departmentId}."); + } + } + result.PageSize = 1; result.Status = ResponseHelper.Success; ResponseHelper.PopulateV4ResponseData(result); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs new file mode 100644 index 000000000..be4f51b7f --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.Version3; +using Resgrid.Web.Services.Models.v4.RunCards; +using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Run cards (CAD-style response plans): CRUD plus a recommendation preview. + /// Gated behind the Dispatch.RunCards feature flag. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class RunCardsController : V4AuthenticatedApiControllerbase + { + private readonly IRunCardsService _runCardsService; + private readonly IDispatchRecommendationService _dispatchRecommendationService; + private readonly IFeatureToggleService _featureToggleService; + private readonly IAuthorizationService _authorizationService; + + public RunCardsController(IRunCardsService runCardsService, IDispatchRecommendationService dispatchRecommendationService, + IFeatureToggleService featureToggleService, IAuthorizationService authorizationService) + { + _runCardsService = runCardsService; + _dispatchRecommendationService = dispatchRecommendationService; + _featureToggleService = featureToggleService; + _authorizationService = authorizationService; + } + + /// + /// All run cards for the department (fully hydrated). + /// + [HttpGet("GetAllRunCards")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task>> GetAllRunCards() + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return NotFound(); + + var cards = await _runCardsService.GetAllRunCardsForDepartmentAsync(DepartmentId); + + return Ok(cards.Select(ConvertRunCardData).ToList()); + } + + /// + /// One run card by id. + /// + [HttpGet("GetRunCard")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetRunCard(int runCardId) + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return NotFound(); + + var card = await _runCardsService.GetRunCardByIdAsync(runCardId); + + if (card == null || card.DepartmentId != DepartmentId) + return NotFound(); + + return Ok(ConvertRunCardData(card)); + } + + /// + /// Creates or updates a run card (child graph is replaced to match the input). + /// Department admin only. + /// + [HttpPost("SaveRunCard")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task SaveRunCard([FromBody] RunCardData input, CancellationToken cancellationToken) + { + if (input == null || string.IsNullOrWhiteSpace(input.Name) || input.Triggers == null || !input.Triggers.Any() + || input.AlarmLevels == null || !input.AlarmLevels.Any()) + return BadRequest(); + + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return NotFound(); + + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + RunCard card; + if (input.RunCardId > 0) + { + card = await _runCardsService.GetRunCardByIdAsync(input.RunCardId); + + if (card == null || card.DepartmentId != DepartmentId) + return NotFound(); + + card.UpdatedOn = DateTime.UtcNow; + card.UpdatedByUserId = UserId; + } + else + { + card = new RunCard + { + DepartmentId = DepartmentId, + AddedOn = DateTime.UtcNow, + AddedByUserId = UserId + }; + } + + card.Name = input.Name.Trim(); + card.Description = input.Description; + card.IsDisabled = input.IsDisabled; + card.DispatchModeOverride = input.DispatchModeOverride; + card.AutoDispatchOverride = input.AutoDispatchOverride; + card.MinimumStaffingLevelOverride = input.MinimumStaffingLevelOverride; + card.HomeStationGroupId = input.HomeStationGroupId; + + card.Triggers = input.Triggers.Select(t => new RunCardTrigger + { + RunCardTriggerId = t.RunCardTriggerId, + RunCardId = card.RunCardId, + TriggerType = t.TriggerType, + Priority = t.Priority, + CallTypeId = t.CallTypeId, + StartsOn = t.StartsOn, + EndsOn = t.EndsOn + }).ToList(); + + card.AlarmLevels = input.AlarmLevels.Select(l => new RunCardAlarmLevel + { + RunCardAlarmLevelId = l.RunCardAlarmLevelId, + RunCardId = card.RunCardId, + AlarmLevel = l.AlarmLevel, + Name = l.Name, + UnitRequirements = (l.UnitRequirements ?? new List()).Select(r => new RunCardUnitRequirement + { + RunCardUnitRequirementId = r.RunCardUnitRequirementId, + RunCardAlarmLevelId = l.RunCardAlarmLevelId, + UnitTypeId = r.UnitTypeId, + RequiredCount = Math.Max(1, r.RequiredCount), + SortOrder = r.SortOrder + }).ToList(), + RoleRequirements = (l.RoleRequirements ?? new List()).Select(r => new RunCardRoleRequirement + { + RunCardRoleRequirementId = r.RunCardRoleRequirementId, + RunCardAlarmLevelId = l.RunCardAlarmLevelId, + PersonnelRoleId = r.PersonnelRoleId, + RequiredCount = Math.Max(1, r.RequiredCount), + SortOrder = r.SortOrder + }).ToList() + }).ToList(); + + card.AvailabilitySelections = (input.Selections ?? new List()).Select(s => new RunCardAvailabilitySelection + { + RunCardAvailabilitySelectionId = s.RunCardAvailabilitySelectionId, + RunCardId = card.RunCardId, + SelectionType = s.SelectionType, + UnitTypeId = s.UnitTypeId, + IsCustomState = s.IsCustomState, + StateId = s.StateId + }).ToList(); + + var saved = await _runCardsService.SaveRunCardAsync(card, cancellationToken); + + return Ok(new { runCardId = saved.RunCardId }); + } + + /// + /// Deletes a run card. Department admin only. + /// + [HttpDelete("DeleteRunCard")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task DeleteRunCard(int runCardId, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + var card = await _runCardsService.GetRunCardByIdAsync(runCardId); + + if (card == null || card.DepartmentId != DepartmentId) + return NotFound(); + + await _runCardsService.DeleteRunCardAsync(runCardId, cancellationToken); + + return Ok(); + } + + /// + /// Recommendation preview: what would the department's run cards dispatch for + /// this priority/type/location right now? Nothing is dispatched. + /// + [HttpGet("GetRecommendation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task> GetRecommendation(int priority, string type, double? latitude, double? longitude, int alarmLevel = 1) + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return NotFound(); + + var result = await _dispatchRecommendationService.GetRecommendationAsync(new DispatchRecommendationRequest + { + DepartmentId = DepartmentId, + Priority = priority, + CallTypeName = type, + Latitude = latitude, + Longitude = longitude, + TargetAlarmLevel = alarmLevel + }); + + return Ok(result); + } + + private static RunCardData ConvertRunCardData(RunCard card) + { + return new RunCardData + { + RunCardId = card.RunCardId, + Name = card.Name, + Description = card.Description, + IsDisabled = card.IsDisabled, + DispatchModeOverride = card.DispatchModeOverride, + AutoDispatchOverride = card.AutoDispatchOverride, + MinimumStaffingLevelOverride = card.MinimumStaffingLevelOverride, + HomeStationGroupId = card.HomeStationGroupId, + Triggers = (card.Triggers ?? new List()).Select(t => new RunCardTriggerData + { + RunCardTriggerId = t.RunCardTriggerId, + TriggerType = t.TriggerType, + Priority = t.Priority, + CallTypeId = t.CallTypeId, + StartsOn = t.StartsOn, + EndsOn = t.EndsOn + }).ToList(), + AlarmLevels = (card.AlarmLevels ?? new List()).Select(l => new RunCardAlarmLevelData + { + RunCardAlarmLevelId = l.RunCardAlarmLevelId, + AlarmLevel = l.AlarmLevel, + Name = l.Name, + UnitRequirements = (l.UnitRequirements ?? new List()).Select(r => new RunCardUnitRequirementData + { + RunCardUnitRequirementId = r.RunCardUnitRequirementId, + UnitTypeId = r.UnitTypeId, + RequiredCount = r.RequiredCount, + SortOrder = r.SortOrder + }).ToList(), + RoleRequirements = (l.RoleRequirements ?? new List()).Select(r => new RunCardRoleRequirementData + { + RunCardRoleRequirementId = r.RunCardRoleRequirementId, + PersonnelRoleId = r.PersonnelRoleId, + RequiredCount = r.RequiredCount, + SortOrder = r.SortOrder + }).ToList() + }).ToList(), + Selections = (card.AvailabilitySelections ?? new List()).Select(s => new RunCardSelectionData + { + RunCardAvailabilitySelectionId = s.RunCardAvailabilitySelectionId, + SelectionType = s.SelectionType, + UnitTypeId = s.UnitTypeId, + IsCustomState = s.IsCustomState, + StateId = s.StateId + }).ToList() + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs b/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs index 4a83360ca..3c5cd23d3 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs @@ -111,6 +111,21 @@ public class GetConfigResultData /// public string OpenWeatherApiKey { get; set; } + /// + /// True when the run card dispatch system is enabled for this department + /// + public bool DispatchRunCardsEnabled { get; set; } + + /// + /// Department dispatch recommendation mode (0 = off, 1 = station based, 2 = closest unit) + /// + public int DispatchRecommendationMode { get; set; } + + /// + /// True when matched run cards auto-dispatch; false = recommendations pre-populate for review + /// + public bool DispatchRecommendationAutoDispatch { get; set; } + /// /// API url for Novu /// diff --git a/Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardApiModels.cs new file mode 100644 index 000000000..2cd16b085 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardApiModels.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.RunCards +{ + /// A run card (CAD-style response plan) with its full child graph. + public class RunCardData + { + /// Run card id + public int RunCardId { get; set; } + /// Name + public string Name { get; set; } + /// Description + public string Description { get; set; } + /// True when the card is disabled and never matches + public bool IsDisabled { get; set; } + /// Per-card dispatch mode override (null = department default, 0 = manual only, 1 = station based, 2 = closest unit) + public int? DispatchModeOverride { get; set; } + /// Per-card auto dispatch override (null = department default, 0 = pre-populate, 1 = auto) + public int? AutoDispatchOverride { get; set; } + /// Per-card minimum UnitStaffingLevel override (null = department default, 0 = off) + public int? MinimumStaffingLevelOverride { get; set; } + /// Station group anchoring the cascade when a call has no location + public int? HomeStationGroupId { get; set; } + /// Match conditions (OR'd) + public List Triggers { get; set; } = new List(); + /// Additive alarm levels + public List AlarmLevels { get; set; } = new List(); + /// Dispatchable status/staffing selections + public List Selections { get; set; } = new List(); + } + + /// A run card trigger + public class RunCardTriggerData + { + /// Trigger id (0 for new) + public int RunCardTriggerId { get; set; } + /// 0 = priority, 1 = call type, 2 = both + public int TriggerType { get; set; } + /// Call priority (system 0-3 or DepartmentCallPriorityId) + public int? Priority { get; set; } + /// Call type id + public int? CallTypeId { get; set; } + /// Optional window start (UTC) + public DateTime? StartsOn { get; set; } + /// Optional window end (UTC) + public DateTime? EndsOn { get; set; } + } + + /// An alarm level and its requirements + public class RunCardAlarmLevelData + { + /// Alarm level id (0 for new) + public int RunCardAlarmLevelId { get; set; } + /// 1-based level number + public int AlarmLevel { get; set; } + /// Optional display name + public string Name { get; set; } + /// Required unit types with counts + public List UnitRequirements { get; set; } = new List(); + /// Required personnel roles with counts + public List RoleRequirements { get; set; } = new List(); + } + + /// A unit type requirement + public class RunCardUnitRequirementData + { + /// Requirement id (0 for new) + public int RunCardUnitRequirementId { get; set; } + /// Unit type id + public int UnitTypeId { get; set; } + /// How many units of this type + public int RequiredCount { get; set; } + /// Sort order + public int SortOrder { get; set; } + } + + /// A personnel role requirement + public class RunCardRoleRequirementData + { + /// Requirement id (0 for new) + public int RunCardRoleRequirementId { get; set; } + /// Personnel role id + public int PersonnelRoleId { get; set; } + /// How many people holding this role + public int RequiredCount { get; set; } + /// Sort order + public int SortOrder { get; set; } + } + + /// A dispatchable status/staffing selection + public class RunCardSelectionData + { + /// Selection id (0 for new) + public int RunCardAvailabilitySelectionId { get; set; } + /// 1 = unit status, 2 = personnel status, 3 = staffing + public int SelectionType { get; set; } + /// Unit type scope for unit status selections (null = all) + public int? UnitTypeId { get; set; } + /// True when StateId is a CustomStateDetailId + public bool IsCustomState { get; set; } + /// Built-in state value or CustomStateDetailId + public int StateId { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 7da3188aa..c3fc8d6a0 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -308,6 +308,15 @@ Data to update + + + "Strike Next Alarm": escalates a call to its next alarm level, additively + dispatching the active run card's next-level requirements and notifying only + the newly added resources. + + The call to escalate + + Deletes a call @@ -2359,6 +2368,39 @@ Gets all active schedules for the department's route plans + + + Run cards (CAD-style response plans): CRUD plus a recommendation preview. + Gated behind the Dispatch.RunCards feature flag. + + + + + All run cards for the department (fully hydrated). + + + + + One run card by id. + + + + + Creates or updates a run card (child graph is replaced to match the input). + Department admin only. + + + + + Deletes a run card. Department admin only. + + + + + Recommendation preview: what would the department's run cards dispatch for + this priority/type/location right now? Nothing is dispatched. + + SCIM 2.0 provisioning endpoint for automated user lifecycle management @@ -4552,52 +4594,6 @@ Is the user a group admin - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - Input data to add a staffing schedule in the Resgrid system @@ -4703,6 +4699,52 @@ Note for this staffing schedule + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + A resrouce in the system this could be a user or unit @@ -8820,6 +8862,21 @@ API Key for the OpenWeatherAPI + + + True when the run card dispatch system is enabled for this department + + + + + Department dispatch recommendation mode (0 = off, 1 = station based, 2 = closest unit) + + + + + True when matched run cards auto-dispatch; false = recommendations pre-populate for review + + API url for Novu @@ -10207,339 +10264,169 @@ Identifier of the new npte - + - The result of getting all personnel filters for the system + A GPS location for a point in time of a specificed person - + - The Id value of the filter + PersonId of the person that the location is for - + - The type of the filter + The timestamp of the location in UTC - + - The filters name + GPS Latitude of the Person - + - Result containing all the data required to populate the New Call form + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + GPS Altitude of the Person - + - Response Data + GPS Altitude Accuracy of the Person - + - Result containing all the data required to populate the New Call form + GPS Speed of the Person - + - Response Data + GPS Heading of the Person - + - Information about a User + A unit location in the Resgrid system - + - The UserId GUID/UUID for the user + Response Data - + - DepartmentId of the deparment the user belongs to + The information about a specific unit's location - + - Department specificed ID number for this user + Id of the Person - + - The Users First Name + The Timestamp for the location in UTC - + - The Users Last Name + GPS Latitude of the Person - + - The Users Email Address + GPS Longitude of the Person - + - The Users Mobile Telephone Number + GPS Latitude\Longitude Accuracy of the Person - + - GroupId the user is assigned to (0 for no group) + GPS Altitude of the Person - + - Name of the group the user is assigned to + GPS Altitude Accuracy of the Person - + - Enumeration/List of roles the user currently holds + GPS Speed of the Person - + - The current action/status type for the user + GPS Heading of the Person - + - The current action/status string for the user + The result of getting the current staffing for a user - + - The current action/status color hex string for the user + Response Data - + - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + Information about a User staffing - + - The current action/status destination id for the user + The UserId GUID/UUID for the user status being return - + - The current action/status destination name for the user + DepartmentId of the deparment the user belongs to - + - The current staffing level (state) type for the user + The current staffing type for the user - + - The current staffing level (state) string for the user + The timestamp of the last staffing. This is converted UTC version of the timestamp. - + - The current staffing level (state) color hex string for the user + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - + - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + Note for this staffing - + - Users last known location + Saves (sets) and Personnel Staffing in the system, for a single user - + - Sorting weight for the user - - - - - User Defined Field values for this personnel record - - - - - A GPS location for a point in time of a specificed person - - - - - PersonId of the person that the location is for - - - - - The timestamp of the location in UTC - - - - - GPS Latitude of the Person - - - - - GPS Longitude of the Person - - - - - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - A unit location in the Resgrid system - - - - - Response Data - - - - - The information about a specific unit's location - - - - - Id of the Person - - - - - The Timestamp for the location in UTC - - - - - GPS Latitude of the Person - - - - - GPS Longitude of the Person - - - - - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - The result of getting the current staffing for a user - - - - - Response Data - - - - - Information about a User staffing - - - - - The UserId GUID/UUID for the user status being return - - - - - DepartmentId of the deparment the user belongs to - - - - - The current staffing type for the user - - - - - The timestamp of the last staffing. This is converted UTC version of the timestamp. - - - - - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - - - - - Note for this staffing - - - - - Saves (sets) and Personnel Staffing in the system, for a single user - - - - - UnitId of the apparatus that the state is being set for + UnitId of the apparatus that the state is being set for @@ -10880,114 +10767,284 @@ Response Data - + - Result containing all the data required to populate the New Call form + The result of getting all personnel filters for the system - + - Response Data + The Id value of the filter - + - Details of a protocol + The type of the filter - + - Protocol id + The filters name - + - Department id + Result containing all the data required to populate the New Call form - + - Name of the Protocol + Response Data - + - Protocol code + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - This this protocol disabled + Response Data - + - Protocol description + Result containing all the data required to populate the New Call form - + - Text of the protocol + Response Data - + - UTC date and time when the Protocol was created + Information about a User - + - UserId of the user who created the protocol + The UserId GUID/UUID for the user - + - UTC timestamp of when the Protocol was updated + DepartmentId of the deparment the user belongs to - + - Minimum triggering Weight of the Protocol + Department specificed ID number for this user - + - UserId that last updated the Protocol + The Users First Name - + - Triggers used to activate this Protocol + The Users Last Name - + - Attachments for this Protocol + The Users Email Address - + - Questions used to determine if this Protocol needs to be used or not + The Users Mobile Telephone Number - + - State type + GroupId the user is assigned to (0 for no group) - + - Result containing all the data required to populate the New Call form + Name of the group the user is assigned to - + - Response Data + Enumeration/List of roles the user currently holds - - Composite dashboard report (scalar totals, dense series, breakdowns). - + + + The current action/status type for the user + + + + + The current action/status string for the user + + + + + The current action/status color hex string for the user + + + + + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + + + + + The current action/status destination id for the user + + + + + The current action/status destination name for the user + + + + + The current staffing level (state) type for the user + + + + + The current staffing level (state) string for the user + + + + + The current staffing level (state) color hex string for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Users last known location + + + + + Sorting weight for the user + + + + + User Defined Field values for this personnel record + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Details of a protocol + + + + + Protocol id + + + + + Department id + + + + + Name of the Protocol + + + + + Protocol code + + + + + This this protocol disabled + + + + + Protocol description + + + + + Text of the protocol + + + + + UTC date and time when the Protocol was created + + + + + UserId of the user who created the protocol + + + + + UTC timestamp of when the Protocol was updated + + + + + Minimum triggering Weight of the Protocol + + + + + UserId that last updated the Protocol + + + + + Triggers used to activate this Protocol + + + + + Attachments for this Protocol + + + + + Questions used to determine if this Protocol needs to be used or not + + + + + State type + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + Composite dashboard report (scalar totals, dense series, breakdowns). + Response Data @@ -11059,6 +11116,129 @@ Origin longitude supplied by the caller (unit's current location). Null means start from the first waypoint. + + A run card (CAD-style response plan) with its full child graph. + + + Run card id + + + Name + + + Description + + + True when the card is disabled and never matches + + + Per-card dispatch mode override (null = department default, 0 = manual only, 1 = station based, 2 = closest unit) + + + Per-card auto dispatch override (null = department default, 0 = pre-populate, 1 = auto) + + + Per-card minimum UnitStaffingLevel override (null = department default, 0 = off) + + + Station group anchoring the cascade when a call has no location + + + Match conditions (OR'd) + + + Additive alarm levels + + + Dispatchable status/staffing selections + + + A run card trigger + + + Trigger id (0 for new) + + + 0 = priority, 1 = call type, 2 = both + + + Call priority (system 0-3 or DepartmentCallPriorityId) + + + Call type id + + + Optional window start (UTC) + + + Optional window end (UTC) + + + An alarm level and its requirements + + + Alarm level id (0 for new) + + + 1-based level number + + + Optional display name + + + Required unit types with counts + + + Required personnel roles with counts + + + A unit type requirement + + + Requirement id (0 for new) + + + Unit type id + + + How many units of this type + + + Sort order + + + A personnel role requirement + + + Requirement id (0 for new) + + + Personnel role id + + + How many people holding this role + + + Sort order + + + A dispatchable status/staffing selection + + + Selection id (0 for new) + + + 1 = unit status, 2 = personnel status, 3 = staffing + + + Unit type scope for unit status selections (null = all) + + + True when StateId is a CustomStateDetailId + + + Built-in state value or CustomStateDetailId + Response Data @@ -12263,545 +12443,545 @@ Default constructor - + - Result that contains all the options available to filter units against compatible Resgrid APIs + Depicts a result after saving a unit status - + Response Data - + - A unit in the Resgrid system + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Response Data + UnitId of the apparatus that the state is being set for - + - The information about a specific unit + The UnitStateType of the Unit - + - Id of the Unit + The Call/Station the unit is responding to - + - The Id of the department the unit is under + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Name of the Unit + The timestamp of the status event in UTC - + - Department assigned type for the unit + The timestamp of the status event in the local time of the device - + - Department assigned type id for the unit + User provided note for this event - + - Custom Statuses Set Id + GPS Latitude of the Unit - + - Station Id of the station housing the unit (0 means no station) + GPS Longitude of the Unit - + - Name of the station the unit is under + GPS Latitude\Longitude Accuracy of the Unit - + - Vehicle Identification Number for the unit + GPS Altitude of the Unit - + - Plate Number for the Unit + GPS Altitude Accuracy of the Unit - + - Is the unit 4-Wheel drive + GPS Speed of the Unit - + - Does the unit require a special permit to drive + GPS Heading of the Unit - + - Id number of the units current destionation (0 means no destination) + The event id used for queuing on mobile applications - + - The current status/state of the Unit + The accountability roles filed for this event - + - The Timestamp of the status + Role filled by a User on a Unit for an event - + - The units current Latitude + Id of the locally stored event - + - The units current Longitude + Local Event Id - + - Current user provide status note + UserId of the user filling the role - + - User Defined Field values for this unit + RoleId of the role being filled - + - Unit role information for roles on a unit + The name of the Role - + - Unit Role Id + Depicts a unit status in the Resgrid system. - + - User Id of the user in the role (could be null) + Response Data - + - Name of the Role + Depicts a unit's status - + - Name of the user in the role (could be null) + Unit Id - + - Multiple Unit infos Result + Units Name - + - Response Data + The Type of the Unit - + - Default constructor + Units current Status (State) - + - The information about a specific unit + CSS for status (for display) - + - Id of the Unit + CSS Style for status (for display) - + - The Id of the department the unit is under + Timestamp of this Unit State - + - Name of the Unit + Timestamp in Utc of this Unit State - + - Department assigned type for the unit + Destination Id (Station or Call) - + - Department assigned type id for the unit + Destination type (Station, Call, or POI). - + - Custom Statuses Set Id + Name of the Desination (Call or Station) - + - Station Id of the station housing the unit (0 means no station) + Destination address. - + - Name of the station the unit is under + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - Vehicle Identification Number for the unit + Note for the State - + - Plate Number for the Unit + Latitude - + - Is the unit 4-Wheel drive + Longitude - + - Does the unit require a special permit to drive + Name of the Group the Unit is in - + - Id number of the units current destination (0 means no destination) + Id of the Group the Unit is in - + - Name of the units current destination (0 means no destination) + Unit statuses (states) - + - The current status/state of the Unit + Response Data - + - The current status/state of the Unit as a name + Default constructor - + - The current status/state of the Unit color + Result that contains all the options available to filter units against compatible Resgrid APIs - + - The Timestamp of the status + Response Data - + - The Timestamp of the status in UTC/GMT + A unit in the Resgrid system - + - The units current Latitude + Response Data - + - The units current Longitude + The information about a specific unit - + - Current user provide status note + Id of the Unit - + - Units Roles + The Id of the department the unit is under - + - Multiple Units Result + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Default constructor + Department assigned type id for the unit - + - Depicts a result after saving a unit status + Custom Statuses Set Id - + - Response Data + Station Id of the station housing the unit (0 means no station) - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + Name of the station the unit is under - + - UnitId of the apparatus that the state is being set for + Vehicle Identification Number for the unit - + - The UnitStateType of the Unit + Plate Number for the Unit - + - The Call/Station the unit is responding to + Is the unit 4-Wheel drive - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + Does the unit require a special permit to drive - + - The timestamp of the status event in UTC + Id number of the units current destionation (0 means no destination) - + - The timestamp of the status event in the local time of the device + The current status/state of the Unit - + - User provided note for this event + The Timestamp of the status - + - GPS Latitude of the Unit + The units current Latitude - + - GPS Longitude of the Unit + The units current Longitude - + - GPS Latitude\Longitude Accuracy of the Unit + Current user provide status note - + - GPS Altitude of the Unit + User Defined Field values for this unit - + - GPS Altitude Accuracy of the Unit + Unit role information for roles on a unit - + - GPS Speed of the Unit + Unit Role Id - + - GPS Heading of the Unit + User Id of the user in the role (could be null) - + - The event id used for queuing on mobile applications + Name of the Role - + - The accountability roles filed for this event + Name of the user in the role (could be null) - + - Role filled by a User on a Unit for an event + Multiple Unit infos Result - + - Id of the locally stored event + Response Data - + - Local Event Id + Default constructor - + - UserId of the user filling the role + The information about a specific unit - + - RoleId of the role being filled + Id of the Unit - + - The name of the Role + The Id of the department the unit is under - + - Depicts a unit status in the Resgrid system. + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Depicts a unit's status + Department assigned type id for the unit - + - Unit Id + Custom Statuses Set Id - + - Units Name + Station Id of the station housing the unit (0 means no station) - + - The Type of the Unit + Name of the station the unit is under - + - Units current Status (State) + Vehicle Identification Number for the unit - + - CSS for status (for display) + Plate Number for the Unit - + - CSS Style for status (for display) + Is the unit 4-Wheel drive - + - Timestamp of this Unit State + Does the unit require a special permit to drive - + - Timestamp in Utc of this Unit State + Id number of the units current destination (0 means no destination) - + - Destination Id (Station or Call) + Name of the units current destination (0 means no destination) - + - Destination type (Station, Call, or POI). + The current status/state of the Unit - + - Name of the Desination (Call or Station) + The current status/state of the Unit as a name - + - Destination address. + The current status/state of the Unit color - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + The Timestamp of the status - + - Note for the State + The Timestamp of the status in UTC/GMT - + - Latitude + The units current Latitude - + - Longitude + The units current Longitude - + - Name of the Group the Unit is in + Current user provide status note - + - Id of the Group the Unit is in + Units Roles - + - Unit statuses (states) + Multiple Units Result - + Response Data - + Default constructor diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/customElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/customElement.tsx index ef4be30de..770f3249c 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/customElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/customElement.tsx @@ -17,6 +17,33 @@ export interface HostedReactElementProps { type ComponentLoader = () => Promise<{ default: ComponentType }>; +// A deploy rotates the hashed chunk filenames, so a page loaded before the deploy 404s +// when it lazily imports an element chunk. One forced reload picks up the fresh HTML and +// hashes; the session flag stops a reload loop when the failure is anything else. +const CHUNK_RELOAD_FLAG = 'rg-elements-chunk-reload'; + +function recoverFromChunkLoadFailure(tagName: string, error: unknown): void { + try { + if (!sessionStorage.getItem(CHUNK_RELOAD_FLAG)) { + sessionStorage.setItem(CHUNK_RELOAD_FLAG, '1'); + window.location.reload(); + return; + } + } catch { + // sessionStorage unavailable (private browsing) — fall through to the console + } + + console.error(`Failed to load the component for <${tagName}>`, error); +} + +function clearChunkReloadFlag(): void { + try { + sessionStorage.removeItem(CHUNK_RELOAD_FLAG); + } catch { + // ignore + } +} + function parseAttributeValue(rawValue: string | null, definition: PropDefinition): unknown { if (rawValue === null) { return definition.defaultValue; @@ -90,7 +117,16 @@ export function defineReactElement( this.componentPromise = loader(); } - const module = await this.componentPromise; + let module: { default: ComponentType }; + try { + module = await this.componentPromise; + } catch (error) { + // Drop the failed promise so a later render can retry instead of re-awaiting the failure. + this.componentPromise = null; + throw error; + } + + clearChunkReloadFlag(); this.component = module.default; return this.component; } @@ -117,7 +153,9 @@ export function defineReactElement( } if (!this.component) { - void this.loadComponentAsync().then(() => this.renderComponent()); + void this.loadComponentAsync() + .then(() => this.renderComponent()) + .catch((error: unknown) => recoverFromChunkLoadFailure(tagName, error)); return; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index fb0d7bcb5..a9d44e08e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -64,6 +64,8 @@ public class DepartmentController : SecureBaseController private readonly IContactsService _contactsService; private readonly ICheckInTimerService _checkInTimerService; private readonly ISecurityPinService _securityPinService; + private readonly IRunCardsService _runCardsService; + private readonly IFeatureToggleService _featureToggleService; public DepartmentController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IDepartmentGroupsService departmentGroupsService, IUserProfileService userProfileService, IDeleteService deleteService, @@ -72,7 +74,7 @@ public DepartmentController(IDepartmentsService departmentsService, IUsersServic ICertificationService certificationService, INumbersService numbersService, IScheduledTasksService scheduledTasksService, IPersonnelRolesService personnelRolesService, IEventAggregator eventAggregator, ICustomStateService customStateService, ICqrsProvider cqrsProvider, IPrinterProvider printerProvider, IQueueService queueService, IDocumentsService documentsService, INotesService notesService, IContactsService contactsService, ICheckInTimerService checkInTimerService, - ISecurityPinService securityPinService) + ISecurityPinService securityPinService, IRunCardsService runCardsService, IFeatureToggleService featureToggleService) { _departmentsService = departmentsService; _usersService = usersService; @@ -103,6 +105,8 @@ public DepartmentController(IDepartmentsService departmentsService, IUsersServic _contactsService = contactsService; _checkInTimerService = checkInTimerService; _securityPinService = securityPinService; + _runCardsService = runCardsService; + _featureToggleService = featureToggleService; } #endregion Private Members and Constructors @@ -1773,6 +1777,8 @@ public async Task DispatchSettings() await PopulateDispatchSettingsUnitTypeOverridesAsync(model, await _departmentSettingsService.GetUnitCallStatusOverridesByUnitTypeAsync(DepartmentId)); + await PopulateRunCardRecommendationSettingsAsync(model); + return View(model); } @@ -1819,18 +1825,146 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Pe await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.AutoEnableCheckInTimers.ToString(), DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls, cancellationToken); + if (await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + { + var mode = Enum.IsDefined(typeof(DispatchRecommendationModes), model.DispatchRecommendationMode) + ? (DispatchRecommendationModes)model.DispatchRecommendationMode + : Model.DispatchRecommendationModes.Off; + + await _departmentSettingsService.SetDispatchRecommendationModeAsync(DepartmentId, mode, cancellationToken); + await _departmentSettingsService.SetDispatchRecommendationAutoDispatchAsync(DepartmentId, model.DispatchRecommendationAutoDispatch, cancellationToken); + await _departmentSettingsService.SetDispatchRecommendationConfigAsync(DepartmentId, new DispatchRecommendationConfig + { + MaxLocationAgeSeconds = Math.Max(0, model.RecommendationMaxLocationAgeSeconds), + MaxRadiusMeters = Math.Max(0, model.RecommendationMaxRadiusMeters), + IncludeStaleLocations = model.RecommendationIncludeStaleLocations, + PersonnelMaxLocationAgeSeconds = Math.Max(0, model.RecommendationPersonnelMaxLocationAgeSeconds), + UseRoutedEta = model.RecommendationUseRoutedEta, + EtaShortlistSize = model.RecommendationEtaShortlistSize > 0 ? model.RecommendationEtaShortlistSize : DispatchRecommendationConfig.DefaultEtaShortlistSize, + RestPeriodMinutes = Math.Max(0, model.RecommendationRestPeriodMinutes), + UnitMinimumStaffingLevel = Math.Max(0, model.RecommendationUnitMinimumStaffingLevel), + MoveUpRecommendationsEnabled = model.RecommendationMoveUpEnabled + }, cancellationToken); + } + ModelState.Clear(); model.UnitTypeStatusOverrides = new List(); await PopulateDispatchSettingsUnitTypeOverridesAsync(model, unitTypeStatusOverrides); + await PopulateRunCardRecommendationSettingsAsync(model, false); model.SaveSuccess = true; return View(model); } await PopulateDispatchSettingsUnitTypeOverridesAsync(model); + await PopulateRunCardRecommendationSettingsAsync(model, false); model.SaveSuccess = false; return View(model); } + private async Task PopulateRunCardRecommendationSettingsAsync(DispatchSettingsView model, bool loadValues = true) + { + model.RunCardsFeatureEnabled = await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId); + + if (!model.RunCardsFeatureEnabled) + return; + + if (loadValues) + { + model.DispatchRecommendationMode = (int)await _departmentSettingsService.GetDispatchRecommendationModeAsync(DepartmentId, true); + model.DispatchRecommendationAutoDispatch = await _departmentSettingsService.GetDispatchRecommendationAutoDispatchAsync(DepartmentId, true); + + var config = await _departmentSettingsService.GetDispatchRecommendationConfigAsync(DepartmentId, true); + model.RecommendationMaxLocationAgeSeconds = config.MaxLocationAgeSeconds; + model.RecommendationMaxRadiusMeters = config.MaxRadiusMeters; + model.RecommendationIncludeStaleLocations = config.IncludeStaleLocations; + model.RecommendationPersonnelMaxLocationAgeSeconds = config.PersonnelMaxLocationAgeSeconds; + model.RecommendationUseRoutedEta = config.UseRoutedEta; + model.RecommendationEtaShortlistSize = config.EtaShortlistSize; + model.RecommendationRestPeriodMinutes = config.RestPeriodMinutes; + model.RecommendationUnitMinimumStaffingLevel = config.UnitMinimumStaffingLevel; + model.RecommendationMoveUpEnabled = config.MoveUpRecommendationsEnabled; + } + + model.DispatchRecommendationModes = new SelectList(new[] + { + new SelectListItem { Value = ((int)Model.DispatchRecommendationModes.Off).ToString(), Text = "Off (Manual Dispatch)" }, + new SelectListItem { Value = ((int)Model.DispatchRecommendationModes.StationBased).ToString(), Text = "Station Based Dispatching" }, + new SelectListItem { Value = ((int)Model.DispatchRecommendationModes.ClosestUnit).ToString(), Text = "Closest Unit Response" } + }, "Value", "Text", model.DispatchRecommendationMode); + + model.StaffingLevelOptions = new SelectList(new[] + { + new SelectListItem { Value = "0", Text = "Off (No Staffing Gate)" }, + new SelectListItem { Value = ((int)UnitStaffingLevel.PartiallyStaffed).ToString(), Text = "At Least Partially Staffed" }, + new SelectListItem { Value = ((int)UnitStaffingLevel.Degraded).ToString(), Text = "At Least Degraded" }, + new SelectListItem { Value = ((int)UnitStaffingLevel.FullyStaffed).ToString(), Text = "Fully Staffed Only" } + }, "Value", "Text", model.RecommendationUnitMinimumStaffingLevel); + + model.StationCoverageRequirements = await _runCardsService.GetStationCoverageRequirementsForDepartmentAsync(DepartmentId); + model.StationGroups = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId) ?? new List(); + model.PersonnelRoles = await _personnelRolesService.GetRolesForDepartmentAsync(DepartmentId) ?? new List(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task SaveStationCoverageRequirement([FromForm] int stationCoverageRequirementId, [FromForm] int departmentGroupId, + [FromForm] int? unitTypeId, [FromForm] int? personnelRoleId, [FromForm] int minimumAvailableCount, [FromForm] int? radiusMeters, + [FromForm] bool isEnabled, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return Json(new { success = false, message = "Run cards are not enabled for this department." }); + + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); + if (stations == null || stations.All(s => s.DepartmentGroupId != departmentGroupId)) + return Json(new { success = false, message = "Invalid station group." }); + + if ((!unitTypeId.HasValue && !personnelRoleId.HasValue) || (unitTypeId.HasValue && personnelRoleId.HasValue)) + return Json(new { success = false, message = "Select a unit type or a personnel role (not both)." }); + + if (minimumAvailableCount < 1) + return Json(new { success = false, message = "Minimum available count must be at least 1." }); + + var requirement = new StationCoverageRequirement + { + StationCoverageRequirementId = stationCoverageRequirementId, + DepartmentId = DepartmentId, + DepartmentGroupId = departmentGroupId, + UnitTypeId = unitTypeId, + PersonnelRoleId = personnelRoleId, + MinimumAvailableCount = minimumAvailableCount, + RadiusMeters = radiusMeters.HasValue && radiusMeters.Value > 0 ? radiusMeters : null, + IsEnabled = isEnabled + }; + + if (stationCoverageRequirementId > 0) + { + var existing = await _runCardsService.GetStationCoverageRequirementsForDepartmentAsync(DepartmentId); + if (existing.All(r => r.StationCoverageRequirementId != stationCoverageRequirementId)) + return Json(new { success = false, message = "Coverage requirement not found." }); + } + + var saved = await _runCardsService.SaveStationCoverageRequirementAsync(requirement, cancellationToken); + + return Json(new { success = true, id = saved.StationCoverageRequirementId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task DeleteStationCoverageRequirement([FromForm] int stationCoverageRequirementId, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + var deleted = await _runCardsService.DeleteStationCoverageRequirementAsync(stationCoverageRequirementId, DepartmentId, cancellationToken); + + return Json(new { success = deleted }); + } + private async Task PopulateDispatchSettingsSelectionsAsync(DispatchSettingsView model) { model.StatusLevels = await GetPersonnelDispatchStatusSelectListAsync(model); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 9c4551b13..47a227627 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -74,6 +74,8 @@ public class DispatchController : SecureBaseController private readonly IModerationService _moderationService; private readonly IStringLocalizer _dispatchLocalizer; private readonly IStringLocalizer _commonLocalizer; + private readonly IDispatchRecommendationService _dispatchRecommendationService; + private readonly IFeatureToggleService _featureToggleService; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -85,7 +87,8 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, ICheckInTimerService checkInTimerService, IWeatherAlertService weatherAlertService, ICallDispatchStatusService callDispatchStatusService, IModerationService moderationService, - IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer) + IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer, + IDispatchRecommendationService dispatchRecommendationService, IFeatureToggleService featureToggleService) { _departmentsService = departmentsService; _usersService = usersService; @@ -118,6 +121,8 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _moderationService = moderationService; _dispatchLocalizer = dispatchLocalizer; _commonLocalizer = commonLocalizer; + _dispatchRecommendationService = dispatchRecommendationService; + _featureToggleService = featureToggleService; } #endregion Private Members and Constructors @@ -478,8 +483,30 @@ public async Task NewCall(NewCallView model, IFormCollection coll } catch { /* If no addy, no addy */ } } + + // Run card auto-dispatch: additively merge recommended resources into the + // call before save (only applies when the resolved auto-dispatch decision + // is on; pre-populate mode rides the form selections instead). + DispatchRecommendationResult recommendationResult = null; + if (shouldDispatchNow && await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + { + recommendationResult = await _dispatchRecommendationService.EnrichCallForDispatchAsync(model.Call, 1, true, cancellationToken); + + if (recommendationResult.AutoDispatch) + { + foreach (var recommendedUnit in recommendationResult.Units.Where(u => !dispatchingUnitIds.Contains(u.UnitId))) + dispatchingUnitIds.Add(recommendedUnit.UnitId); + + foreach (var recommendedUser in recommendationResult.Personnel.Where(p => !dispatchingUserIds.Contains(p.UserId))) + dispatchingUserIds.Add(recommendedUser.UserId); + } + } + var call = await _callsService.SaveCallAsync(model.Call, cancellationToken); + if (recommendationResult != null && recommendationResult.MatchedRunCardId.HasValue && recommendationResult.AutoDispatch) + await _dispatchRecommendationService.RecordActivationAsync(call, recommendationResult, UserId, cancellationToken); + // Attach weather alerts as call notes if enabled await _weatherAlertService.AttachWeatherAlertsToCallAsync(call, cancellationToken); @@ -545,6 +572,101 @@ public async Task NewCall(NewCallView model, IFormCollection coll return View("NewCall", model); } + /// + /// Run card recommendation preview for the New Call page (pre-populate mode). + /// Called by JS whenever priority/type/location change; returns the full + /// explainability result so the page can pre-check grids and render the panel. + /// + [HttpGet] + [Authorize(Policy = ResgridResources.Call_Create)] + public async Task GetDispatchRecommendation(int priority, string type, double? latitude, double? longitude, int alarmLevel = 1) + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return Json(new { success = false }); + + var result = await _dispatchRecommendationService.GetRecommendationAsync(new DispatchRecommendationRequest + { + DepartmentId = DepartmentId, + Priority = priority, + CallTypeName = type, + Latitude = latitude, + Longitude = longitude, + TargetAlarmLevel = alarmLevel + }); + + return Json(new { success = true, result }); + } + + /// + /// "Strike Next Alarm": escalates the call to its next alarm level, additively + /// dispatching that level's run card requirements and notifying only the newly + /// added resources via selective broadcast. + /// + [HttpPost] + [Authorize(Policy = ResgridResources.Call_Update)] + public async Task EscalateCall([FromForm] int callId, CancellationToken cancellationToken) + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return Json(new { success = false, message = "Run cards are not enabled for this department." }); + + var call = await _callsService.GetCallByIdAsync(callId); + + if (call == null || call.DepartmentId != DepartmentId) + return Json(new { success = false, message = "Call not found." }); + + if (call.State != (int)CallStates.Active) + return Json(new { success = false, message = "Only active calls can be escalated." }); + + call = await _callsService.PopulateCallData(call, true, false, false, true, true, true, false, false, false); + + var previousAlarmLevel = Math.Max(1, call.AlarmLevel); + var targetAlarmLevel = previousAlarmLevel + 1; + + var result = await _dispatchRecommendationService.EnrichCallForDispatchAsync(call, targetAlarmLevel, false, cancellationToken); + + if (!result.MatchedRunCardId.HasValue) + return Json(new { success = false, message = "No run card matches this call; nothing to escalate." }); + + if (!result.HasRecommendations) + return Json(new { success = false, message = "The run card has no additional resources for the next alarm level.", result }); + + var newUnitIds = result.Units.Select(u => u.UnitId).ToList(); + var newUserIds = result.Personnel.Select(p => p.UserId).ToList(); + + var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); + + await _dispatchRecommendationService.RecordActivationAsync(savedCall, result, UserId, cancellationToken); + + if (newUnitIds.Any()) + await _callDispatchStatusService.ApplyDispatchStatusesAsync(savedCall, null, newUnitIds, cancellationToken); + + var cqi = new CallQueueItem(); + cqi.Call = savedCall; + + if (newUserIds.Any()) + cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(newUserIds); + else + cqi.Profiles = new List(); + + cqi.SetBroadcastDispatches(newUserIds, new List(), newUnitIds, new List()); + + await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + + _eventAggregator.SendMessage(new CallAlarmEscalatedEvent + { + DepartmentId = DepartmentId, + CallId = savedCall.CallId, + PreviousAlarmLevel = previousAlarmLevel, + NewAlarmLevel = savedCall.AlarmLevel, + AddedUnitIds = newUnitIds, + AddedUserIds = newUserIds + }); + + _eventAggregator.SendMessage(new CallUpdatedEvent() { DepartmentId = DepartmentId, Call = savedCall }); + + return Json(new { success = true, newAlarmLevel = savedCall.AlarmLevel, addedUnits = newUnitIds.Count, addedPersonnel = newUserIds.Count, result }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Call_Update)] public async Task UpdateCall(int callId) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs index 1a36cf9d4..c71c45883 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs @@ -616,6 +616,10 @@ public async Task Geofence(int departmentGroupId) [Authorize(Policy = ResgridResources.GenericGroup_Update)] public async Task SaveGeofence([FromBody]SaveGeofenceModel model, CancellationToken cancellationToken) { + // Not an [ApiController]; a body that fails to bind leaves model null instead of auto-400ing + if (model == null) + return new StatusCodeResult((int)HttpStatusCode.BadRequest); + var group = await _departmentGroupsService.GetGroupByIdAsync(model.DepartmentGroupId); if (group == null) @@ -627,6 +631,16 @@ public async Task SaveGeofence([FromBody]SaveGeofenceModel model, if (!await _authorizationService.CanUserEditDepartmentGroupAsync(UserId, model.DepartmentGroupId)) return Unauthorized(); + // An empty fence clears the response area; anything else must parse as a + // polygon (>= 3 vertices) or downstream dispatch containment silently skips it. + if (!string.IsNullOrWhiteSpace(model.GeoFence) && GeoMath.ParseGeofence(model.GeoFence) == null) + { + model.Success = false; + model.Message = "The geofence is not a valid polygon. Draw an area with at least three points and try again."; + + return Json(model); + } + group.GeofenceColor = model.Color; group.Geofence = model.GeoFence; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs index d817c1e28..7e383f080 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs @@ -139,7 +139,7 @@ where key.ToString().StartsWith("triggerType_") if (!String.IsNullOrWhiteSpace(triggerEndsOn)) trigger.EndsOn = DateTime.Parse(triggerEndsOn); - trigger.Priority = triggerType; + trigger.Priority = triggerCallPriotity; trigger.CallType = triggerCallType; model.Protocol.Triggers.Add(trigger); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs new file mode 100644 index 000000000..b6e5dc1ad --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.RunCards; +using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Management UI for run cards (CAD-style response plans). Department-admin only + /// and gated behind the Dispatch.RunCards feature flag. + /// + [Area("User")] + public class RunCardsController : SecureBaseController + { + private readonly IRunCardsService _runCardsService; + private readonly ICallsService _callsService; + private readonly IUnitsService _unitsService; + private readonly IPersonnelRolesService _personnelRolesService; + private readonly ICustomStateService _customStateService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IDispatchRecommendationService _dispatchRecommendationService; + private readonly IAuthorizationService _authorizationService; + private readonly IFeatureToggleService _featureToggleService; + + public RunCardsController(IRunCardsService runCardsService, ICallsService callsService, IUnitsService unitsService, + IPersonnelRolesService personnelRolesService, ICustomStateService customStateService, + IDepartmentGroupsService departmentGroupsService, IDispatchRecommendationService dispatchRecommendationService, + IAuthorizationService authorizationService, IFeatureToggleService featureToggleService) + { + _runCardsService = runCardsService; + _callsService = callsService; + _unitsService = unitsService; + _personnelRolesService = personnelRolesService; + _customStateService = customStateService; + _departmentGroupsService = departmentGroupsService; + _dispatchRecommendationService = dispatchRecommendationService; + _authorizationService = authorizationService; + _featureToggleService = featureToggleService; + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task Index() + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return RedirectToAction("Dashboard", "Home", new { area = "User" }); + + var model = new RunCardsIndexModel + { + RunCards = await _runCardsService.GetAllRunCardsForDepartmentAsync(DepartmentId, true) + }; + + return View(model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task New() + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return RedirectToAction("Dashboard", "Home", new { area = "User" }); + + var model = new EditRunCardModel { IsNew = true, RunCard = new RunCard { DepartmentId = DepartmentId } }; + await PopulateEditModelAsync(model); + + return View("Edit", model); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task Edit(int runCardId) + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return RedirectToAction("Dashboard", "Home", new { area = "User" }); + + var card = await _runCardsService.GetRunCardByIdAsync(runCardId); + + if (card == null || card.DepartmentId != DepartmentId) + return Unauthorized(); + + var model = new EditRunCardModel { RunCard = card }; + await PopulateEditModelAsync(model); + + return View(model); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task Save([FromBody] RunCardEditInput input, CancellationToken cancellationToken) + { + if (input == null) + return new StatusCodeResult((int)HttpStatusCode.BadRequest); + + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return Json(new { success = false, message = "Run cards are not enabled for this department." }); + + if (string.IsNullOrWhiteSpace(input.Name)) + return Json(new { success = false, message = "A run card needs a name." }); + + if (input.Triggers == null || !input.Triggers.Any()) + return Json(new { success = false, message = "A run card needs at least one trigger." }); + + if (input.AlarmLevels == null || !input.AlarmLevels.Any()) + return Json(new { success = false, message = "A run card needs at least one alarm level." }); + + RunCard card; + if (input.RunCardId > 0) + { + card = await _runCardsService.GetRunCardByIdAsync(input.RunCardId); + + if (card == null || card.DepartmentId != DepartmentId) + return Unauthorized(); + + card.UpdatedOn = DateTime.UtcNow; + card.UpdatedByUserId = UserId; + } + else + { + card = new RunCard + { + DepartmentId = DepartmentId, + AddedOn = DateTime.UtcNow, + AddedByUserId = UserId + }; + } + + card.Name = input.Name.Trim(); + card.Description = input.Description; + card.IsDisabled = input.IsDisabled; + card.DispatchModeOverride = input.DispatchModeOverride; + card.AutoDispatchOverride = input.AutoDispatchOverride; + card.MinimumStaffingLevelOverride = input.MinimumStaffingLevelOverride; + card.HomeStationGroupId = input.HomeStationGroupId; + + card.Triggers = input.Triggers.Select(t => new RunCardTrigger + { + RunCardTriggerId = t.RunCardTriggerId, + RunCardId = card.RunCardId, + TriggerType = t.TriggerType, + Priority = t.Priority, + CallTypeId = t.CallTypeId, + StartsOn = t.StartsOn, + EndsOn = t.EndsOn + }).ToList(); + + card.AlarmLevels = input.AlarmLevels.Select(l => new RunCardAlarmLevel + { + RunCardAlarmLevelId = l.RunCardAlarmLevelId, + RunCardId = card.RunCardId, + AlarmLevel = l.AlarmLevel, + Name = l.Name, + UnitRequirements = (l.UnitRequirements ?? new List()).Select(r => new RunCardUnitRequirement + { + RunCardUnitRequirementId = r.RunCardUnitRequirementId, + RunCardAlarmLevelId = l.RunCardAlarmLevelId, + UnitTypeId = r.UnitTypeId, + RequiredCount = Math.Max(1, r.RequiredCount), + SortOrder = r.SortOrder + }).ToList(), + RoleRequirements = (l.RoleRequirements ?? new List()).Select(r => new RunCardRoleRequirement + { + RunCardRoleRequirementId = r.RunCardRoleRequirementId, + RunCardAlarmLevelId = l.RunCardAlarmLevelId, + PersonnelRoleId = r.PersonnelRoleId, + RequiredCount = Math.Max(1, r.RequiredCount), + SortOrder = r.SortOrder + }).ToList() + }).ToList(); + + card.AvailabilitySelections = (input.Selections ?? new List()).Select(s => new RunCardAvailabilitySelection + { + RunCardAvailabilitySelectionId = s.RunCardAvailabilitySelectionId, + RunCardId = card.RunCardId, + SelectionType = s.SelectionType, + UnitTypeId = s.UnitTypeId, + IsCustomState = s.IsCustomState, + StateId = s.StateId + }).ToList(); + + var saved = await _runCardsService.SaveRunCardAsync(card, cancellationToken); + + return Json(new { success = true, runCardId = saved.RunCardId }); + } + + [HttpPost] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task Delete([FromForm] int runCardId, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + var card = await _runCardsService.GetRunCardByIdAsync(runCardId); + + if (card == null || card.DepartmentId != DepartmentId) + return Json(new { success = false }); + + await _runCardsService.DeleteRunCardAsync(runCardId, cancellationToken); + + return Json(new { success = true }); + } + + /// + /// Test/simulate endpoint for the editor's preview tab: what would this + /// priority/type/location dispatch right now? + /// + [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task Preview(int priority, string type, double? latitude, double? longitude, int alarmLevel = 1) + { + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) + return Json(new { success = false }); + + var result = await _dispatchRecommendationService.GetRecommendationAsync(new DispatchRecommendationRequest + { + DepartmentId = DepartmentId, + Priority = priority, + CallTypeName = type, + Latitude = latitude, + Longitude = longitude, + TargetAlarmLevel = alarmLevel + }); + + return Json(new { success = true, result }); + } + + private async Task PopulateEditModelAsync(EditRunCardModel model) + { + var priorities = await _callsService.GetActiveCallPrioritiesForDepartmentAsync(DepartmentId); + model.CallPriorities = new SelectList(priorities, "DepartmentCallPriorityId", "Name"); + + model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); + model.StationGroups = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId) ?? new List(); + model.UnitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId) ?? new List(); + model.PersonnelRoles = await _personnelRolesService.GetRolesForDepartmentAsync(DepartmentId) ?? new List(); + + var unitCustomStates = await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(DepartmentId) ?? new List(); + + foreach (var unitType in model.UnitTypes) + { + var options = new List(); + var customState = unitType.CustomStatesId.HasValue + ? unitCustomStates.FirstOrDefault(s => s.CustomStateId == unitType.CustomStatesId.Value) + : null; + + if (customState != null) + { + options.AddRange(customState.GetActiveDetails().Select(d => new StatusOptionModel + { + StateId = d.CustomStateDetailId, + IsCustomState = true, + Text = d.ButtonText + })); + } + else + { + options.AddRange(Enum.GetValues(typeof(UnitStateTypes)).Cast().Select(s => new StatusOptionModel + { + StateId = (int)s, + IsCustomState = false, + Text = s.ToString() + })); + } + + model.UnitStatusOptions[unitType.UnitTypeId] = options; + } + + var personnelState = await _customStateService.GetActivePersonnelStateForDepartmentAsync(DepartmentId); + if (personnelState != null) + { + model.PersonnelStatusOptions = personnelState.GetActiveDetails().Select(d => new StatusOptionModel + { + StateId = d.CustomStateDetailId, + IsCustomState = true, + Text = d.ButtonText + }).ToList(); + } + else + { + model.PersonnelStatusOptions = Enum.GetValues(typeof(ActionTypes)).Cast().Select(s => new StatusOptionModel + { + StateId = (int)s, + IsCustomState = false, + Text = s.ToString() + }).ToList(); + } + + var staffingState = await _customStateService.GetActiveStaffingLevelsForDepartmentAsync(DepartmentId); + if (staffingState != null) + { + model.StaffingOptions = staffingState.GetActiveDetails().Select(d => new StatusOptionModel + { + StateId = d.CustomStateDetailId, + IsCustomState = true, + Text = d.ButtonText + }).ToList(); + } + else + { + model.StaffingOptions = Enum.GetValues(typeof(UserStateTypes)).Cast().Select(s => new StatusOptionModel + { + StateId = (int)s, + IsCustomState = false, + Text = s.ToString() + }).ToList(); + } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs b/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs index b66b1781e..6e9cd9606 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs @@ -34,6 +34,25 @@ public class DispatchSettingsView public bool? SaveSuccess { get; set; } public string Message { get; set; } + // Run Card / Recommendation Settings (feature-flag gated) + public bool RunCardsFeatureEnabled { get; set; } + public int DispatchRecommendationMode { get; set; } + public SelectList DispatchRecommendationModes { get; set; } + public bool DispatchRecommendationAutoDispatch { get; set; } + public int RecommendationMaxLocationAgeSeconds { get; set; } + public int RecommendationMaxRadiusMeters { get; set; } + public bool RecommendationIncludeStaleLocations { get; set; } + public int RecommendationPersonnelMaxLocationAgeSeconds { get; set; } + public bool RecommendationUseRoutedEta { get; set; } + public int RecommendationEtaShortlistSize { get; set; } + public int RecommendationRestPeriodMinutes { get; set; } + public int RecommendationUnitMinimumStaffingLevel { get; set; } + public SelectList StaffingLevelOptions { get; set; } + public bool RecommendationMoveUpEnabled { get; set; } + public List StationCoverageRequirements { get; set; } + public List StationGroups { get; set; } + public List PersonnelRoles { get; set; } + public DispatchSettingsView() { ShiftDispatchStatus = -1; @@ -46,6 +65,9 @@ public DispatchSettingsView() UnitTypes = new List(); CallTypes = new List(); StateNames = new Dictionary(); + StationCoverageRequirements = new List(); + StationGroups = new List(); + PersonnelRoles = new List(); } } diff --git a/Web/Resgrid.Web/Areas/User/Models/RunCards/RunCardModels.cs b/Web/Resgrid.Web/Areas/User/Models/RunCards/RunCardModels.cs new file mode 100644 index 000000000..0018f12ba --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/RunCards/RunCardModels.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.RunCards +{ + public class RunCardsIndexModel + { + public List RunCards { get; set; } = new List(); + } + + public class EditRunCardModel + { + public RunCard RunCard { get; set; } = new RunCard(); + + public bool IsNew { get; set; } + + public SelectList CallPriorities { get; set; } + + public List CallTypes { get; set; } = new List(); + + public List StationGroups { get; set; } = new List(); + + public List UnitTypes { get; set; } = new List(); + + public List PersonnelRoles { get; set; } = new List(); + + /// Selectable unit statuses per unit type (built-in or the type's custom status set). + public Dictionary> UnitStatusOptions { get; set; } = new Dictionary>(); + + public List PersonnelStatusOptions { get; set; } = new List(); + + public List StaffingOptions { get; set; } = new List(); + } + + public class StatusOptionModel + { + public int StateId { get; set; } + + public bool IsCustomState { get; set; } + + public string Text { get; set; } + } + + /// JSON payload the run card editor posts back. + public class RunCardEditInput + { + public int RunCardId { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public bool IsDisabled { get; set; } + public int? DispatchModeOverride { get; set; } + public int? AutoDispatchOverride { get; set; } + public int? MinimumStaffingLevelOverride { get; set; } + public int? HomeStationGroupId { get; set; } + public List Triggers { get; set; } = new List(); + public List AlarmLevels { get; set; } = new List(); + public List Selections { get; set; } = new List(); + } + + public class RunCardTriggerInput + { + public int RunCardTriggerId { get; set; } + 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; } + } + + public class RunCardAlarmLevelInput + { + public int RunCardAlarmLevelId { get; set; } + public int AlarmLevel { get; set; } + public string Name { get; set; } + public List UnitRequirements { get; set; } = new List(); + public List RoleRequirements { get; set; } = new List(); + } + + public class RunCardUnitRequirementInput + { + public int RunCardUnitRequirementId { get; set; } + public int UnitTypeId { get; set; } + public int RequiredCount { get; set; } + public int SortOrder { get; set; } + } + + public class RunCardRoleRequirementInput + { + public int RunCardRoleRequirementId { get; set; } + public int PersonnelRoleId { get; set; } + public int RequiredCount { get; set; } + public int SortOrder { get; set; } + } + + public class RunCardSelectionInput + { + public int RunCardAvailabilitySelectionId { get; set; } + public int SelectionType { get; set; } + public int? UnitTypeId { get; set; } + public bool IsCustomState { get; set; } + public int StateId { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml index 092878112..c608bc031 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml @@ -157,6 +157,87 @@ + @if (Model.RunCardsFeatureEnabled) + { +
+

@localizer["RunCardSettingsHeader"]

+ +
+ +
+ @Html.DropDownListFor(m => m.DispatchRecommendationMode, Model.DispatchRecommendationModes, new { style = "width: 70%" }) + @localizer["DispatchRecommendationModeHelp"] +
+
+
+ +
+ + @localizer["DispatchAutoDispatchHelp"] +
+
+
+ +
+ + @localizer["RestPeriodHelp"] +
+
+
+ +
+ @Html.DropDownListFor(m => m.RecommendationUnitMinimumStaffingLevel, Model.StaffingLevelOptions, new { style = "width: 70%" }) + @localizer["StaffingGateHelp"] +
+
+
+ +
+ + @localizer["MoveUpEnabledHelp"] +
+
+ +

@localizer["ClosestUnitTuningHeader"]

+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + @localizer["UseRoutedEtaHelp"] +
+
+
+ +
+ +
+
+ } +
@commonLocalizer["Cancel"] @@ -167,6 +248,77 @@
+ @if (Model.RunCardsFeatureEnabled) + { + @* ── Station Coverage Requirements (AJAX CRUD) ── *@ +
+
@localizer["StationCoverageHeader"]
+
+

@localizer["StationCoverageHelp"]

+ + + + + + + + + + + + + + @foreach (var req in Model.StationCoverageRequirements) + { + + + + + + + + + } + +
@localizer["StationCoverageStationLabel"]@localizer["StationCoverageTargetLabel"]@localizer["StationCoverageMinCountLabel"]@localizer["StationCoverageRadiusLabel"]@localizer["StationCoverageEnabledLabel"]
@(Model.StationGroups.FirstOrDefault(s => s.DepartmentGroupId == req.DepartmentGroupId)?.Name) + @if (req.UnitTypeId.HasValue) + { + @(Model.UnitTypes.FirstOrDefault(t => t.UnitTypeId == req.UnitTypeId.Value)?.Type) + } + else if (req.PersonnelRoleId.HasValue) + { + @(Model.PersonnelRoles.FirstOrDefault(r => r.PersonnelRoleId == req.PersonnelRoleId.Value)?.Name) + } + @req.MinimumAvailableCount@(req.RadiusMeters.HasValue ? req.RadiusMeters.Value.ToString() : "-")@(req.IsEnabled ? commonLocalizer["Yes"] : commonLocalizer["No"]) + +
+ +
+ @Html.AntiForgeryToken() + + + + + +
+
+
+ } + @* ── Default Timer Configurations (independent form) ── *@
@localizer["CheckInTimerDefaultConfigsHeader"]
@@ -534,6 +686,46 @@ // Override form setupTimerForm('form[action*="SaveCheckInTimerOverride"]', 'overrideActiveStates', 'overrideActiveForStates'); + + // Station coverage requirement CRUD (run cards feature) + var $coverageForm = $('#newCoverageForm'); + if ($coverageForm.length) { + var coverageToken = $coverageForm.find('input[name="__RequestVerificationToken"]').val(); + + $('#addCoverageButton').on('click', function () { + var target = $('#coverageTarget').val() || ''; + var isUnit = target.indexOf('U:') === 0; + var targetId = target.substring(2); + + $.post('@Url.Action("SaveStationCoverageRequirement", "Department", new { area = "User" })', { + __RequestVerificationToken: coverageToken, + stationCoverageRequirementId: 0, + departmentGroupId: $('#coverageStation').val(), + unitTypeId: isUnit ? targetId : null, + personnelRoleId: isUnit ? null : targetId, + minimumAvailableCount: $('#coverageMinCount').val(), + radiusMeters: $('#coverageRadius').val(), + isEnabled: true + }, function (response) { + if (response.success) { + window.location.reload(); + } else { + alert(response.message); + } + }); + }); + + $('#stationCoverageTable').on('click', '.delete-coverage', function () { + $.post('@Url.Action("DeleteStationCoverageRequirement", "Department", new { area = "User" })', { + __RequestVerificationToken: coverageToken, + stationCoverageRequirementId: $(this).data('id') + }, function (response) { + if (response.success) { + window.location.reload(); + } + }); + }); + } }); } diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml index 4ce613aa8..16ec74fea 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml @@ -1,6 +1,7 @@ @using Resgrid.Model @model Resgrid.Web.Areas.User.Models.DepartmentSettingsModel @inject IStringLocalizer localizer +@inject Resgrid.Model.Services.IFeatureToggleService featureToggleService @{ ViewBag.Title = "Resgrid | " + @localizer["DepartmentSettingsHeader"]; } @@ -20,6 +21,10 @@
@localizer["CallDispatchSettingsNav"] + @if (await featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, ClaimsAuthorizationHelper.GetDepartmentId())) + { + @localizer["RunCardsNav"] + } @localizer["ShiftSettings"] @localizer["MappingSettingsHeader"] @localizer["ApiRssSettings"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index 394838f0d..88d28d3db 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -258,6 +258,12 @@
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index e5638eced..ab2c814e6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -43,6 +43,10 @@ } @if (ClaimsAuthorizationHelper.CanCreateCall()) { + if (Model.Call.State == 0 && Model.Call.ActiveRunCardId.HasValue) + { + + } @localizer["UpdateCallHeader"] @localizer["CloseCallHeader"] } @@ -51,6 +55,29 @@
+@if (Model.Call.ActiveRunCardId.HasValue) +{ + +} +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RunCards/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RunCards/Edit.cshtml new file mode 100644 index 000000000..fa057ccdd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RunCards/Edit.cshtml @@ -0,0 +1,484 @@ +@model Resgrid.Web.Areas.User.Models.RunCards.EditRunCardModel +@using Newtonsoft.Json +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["RunCardsHeader"]; + + var cardJson = JsonConvert.SerializeObject(new + { + runCardId = Model.RunCard.RunCardId, + name = Model.RunCard.Name, + description = Model.RunCard.Description, + isDisabled = Model.RunCard.IsDisabled, + dispatchModeOverride = Model.RunCard.DispatchModeOverride, + autoDispatchOverride = Model.RunCard.AutoDispatchOverride, + minimumStaffingLevelOverride = Model.RunCard.MinimumStaffingLevelOverride, + homeStationGroupId = Model.RunCard.HomeStationGroupId, + triggers = (Model.RunCard.Triggers ?? new List()).Select(t => new + { + runCardTriggerId = t.RunCardTriggerId, + triggerType = t.TriggerType, + priority = t.Priority, + callTypeId = t.CallTypeId + }), + alarmLevels = (Model.RunCard.AlarmLevels ?? new List()).Select(l => new + { + runCardAlarmLevelId = l.RunCardAlarmLevelId, + alarmLevel = l.AlarmLevel, + name = l.Name, + unitRequirements = (l.UnitRequirements ?? new List()).Select(r => new + { + runCardUnitRequirementId = r.RunCardUnitRequirementId, + unitTypeId = r.UnitTypeId, + requiredCount = r.RequiredCount + }), + roleRequirements = (l.RoleRequirements ?? new List()).Select(r => new + { + runCardRoleRequirementId = r.RunCardRoleRequirementId, + personnelRoleId = r.PersonnelRoleId, + requiredCount = r.RequiredCount + }) + }), + selections = (Model.RunCard.AvailabilitySelections ?? new List()).Select(s => new + { + runCardAvailabilitySelectionId = s.RunCardAvailabilitySelectionId, + selectionType = s.SelectionType, + unitTypeId = s.UnitTypeId, + isCustomState = s.IsCustomState, + stateId = s.StateId + }) + }); + + var lookupsJson = JsonConvert.SerializeObject(new + { + priorities = Model.CallPriorities.Items.Cast().Select(p => new { id = p.DepartmentCallPriorityId, name = p.Name }), + callTypes = Model.CallTypes.Select(t => new { id = t.CallTypeId, name = t.Type }), + stations = Model.StationGroups.Select(s => new { id = s.DepartmentGroupId, name = s.Name }), + unitTypes = Model.UnitTypes.Select(t => new { id = t.UnitTypeId, name = t.Type }), + roles = Model.PersonnelRoles.Select(r => new { id = r.PersonnelRoleId, name = r.Name }), + unitStatusOptions = Model.UnitStatusOptions.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value.Select(o => new { stateId = o.StateId, isCustomState = o.IsCustomState, text = o.Text })), + personnelStatusOptions = Model.PersonnelStatusOptions.Select(o => new { stateId = o.StateId, isCustomState = o.IsCustomState, text = o.Text }), + staffingOptions = Model.StaffingOptions.Select(o => new { stateId = o.StateId, isCustomState = o.IsCustomState, text = o.Text }) + }); +} + +
+
+

@(Model.IsNew ? localizer["NewRunCardButton"] : Model.RunCard.Name)

+ +
+
+ +
+
+
+
+
+ + + +
+ +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ +
+
+

@localizer["RunCardTriggersHelp"]

+ + + + + + + + + + +
@localizer["RunCardTriggerTypeLabel"]@localizer["CallPriority"]@localizer["CallType"]
+ +
+
+ +
+
+

@localizer["RunCardAlarmLevelsHelp"]

+
+ +
+
+ +
+
+

@localizer["RunCardSelectionsHelp"]

+

@localizer["UnitCallDispatchStatusLabel"]

+
+

@localizer["PersonCallDispatchStatusLabel"]

+
+

@localizer["RunCardStaffingSelectionsLabel"]

+
+
+
+ +
+
+

@localizer["RunCardTestHelp"]

+
+ +
+
+
+ +
+
+
+ +
+ + + + +
+
+ +
+
+
+
+ +
+
+ @commonLocalizer["Cancel"] + +
+
+
+
+
+
+ +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/RunCards/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RunCards/Index.cshtml new file mode 100644 index 000000000..eb6dd49dc --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RunCards/Index.cshtml @@ -0,0 +1,91 @@ +@model Resgrid.Web.Areas.User.Models.RunCards.RunCardsIndexModel +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["RunCardsHeader"]; +} + +
+
+

@localizer["RunCardsHeader"]

+ +
+ +
+ +
+
+
+
+
+

@localizer["RunCardsHelp"]

+ + + + + + + + + + + + + + @foreach (var card in Model.RunCards) + { + + + + + + + + + } + +
@localizer["RunCardNameLabel"]@localizer["RunCardDescriptionLabel"]@localizer["RunCardAlarmLevelsHeader"]@localizer["RunCardTriggersHeader"]@localizer["StationCoverageEnabledLabel"]
@card.Name@card.Description@(card.AlarmLevels?.Count ?? 0)@(card.Triggers?.Count ?? 0)@(card.IsDisabled ? commonLocalizer["No"] : commonLocalizer["Yes"]) + @localizer["RunCardEditButton"] + +
+
@Html.AntiForgeryToken()
+
+
+
+
+
+ +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js index 47b8536ef..9a72b1e90 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js @@ -51,10 +51,16 @@ var resgrid; $("#CallPriority").change(function () { checkForProtocols(); + newcall.checkForRecommendations(); }); $("#Call_Type").change(function () { checkForProtocols(); + newcall.checkForRecommendations(); + }); + + $("#Latitude, #Longitude").change(function () { + newcall.checkForRecommendations(); }); let noteQuillDescription = new Quill('#note-container', { @@ -493,7 +499,83 @@ var resgrid; } newcall.getStatusField = getStatusField; + // ── Run card recommendations (pre-populate mode) ── + function prop(obj, name) { + if (!obj) return undefined; + if (obj[name] !== undefined) return obj[name]; + var pascal = name.charAt(0).toUpperCase() + name.slice(1); + return obj[pascal]; + } + function checkForRecommendations() { + var callPriorityVal = $('#CallPriority').val(); + var callTypeVal = $('#Call_Type').val(); + var lat = $('#Latitude').val(); + var lon = $('#Longitude').val(); + + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetDispatchRecommendation', + data: { priority: callPriorityVal, type: callTypeVal, latitude: lat || null, longitude: lon || null }, + type: 'GET' + }).done(function (response) { + var panel = $('#runCardPanel'); + var row = $('#runCardPanelRow'); + var result = prop(response, 'result'); + + if (!response || !prop(response, 'success') || !result || !prop(result, 'matchedRunCardId')) { + row.hide(); + return; + } + + var autoDispatch = prop(result, 'autoDispatch') === true; + var units = prop(result, 'units') || []; + var personnel = prop(result, 'personnel') || []; + var shortfalls = prop(result, 'shortfalls') || []; + var notes = prop(result, 'notes') || []; + + var html = '' + $('').text(prop(result, 'matchedRunCardName') || '').html() + ''; + if (autoDispatch) { + html += ' Auto-dispatch is ON — recommended resources will be dispatched automatically on save.'; + } + + if (units.length) { + html += '
Units: ' + units.map(function (u) { + var text = prop(u, 'unitName') || ('#' + prop(u, 'unitId')); + var distance = prop(u, 'distanceMeters'); + if (distance) text += ' (' + (distance / 1000).toFixed(1) + ' km)'; + return $('').text(text).html(); + }).join(', ') + '
'; + } + if (personnel.length) { + html += '
Personnel: ' + personnel.length + ' recommended
'; + } + if (shortfalls.length) { + html += '
Shortfalls: ' + shortfalls.map(function (s) { + return $('').text((prop(s, 'typeOrRoleName') || ('#' + prop(s, 'typeOrRoleId'))) + ': ' + prop(s, 'filledCount') + '/' + prop(s, 'requiredCount')).html(); + }).join(', ') + '
'; + } + if (notes.length) { + html += '
' + notes.map(function (n) { return $('').text(n).html(); }).join('
') + '
'; + } + + panel.html(html); + row.show(); + + // Pre-check recommended resources when NOT auto-dispatching (dispatcher + // reviews and can uncheck; the normal form post picks these up). + if (!autoDispatch) { + units.forEach(function (u) { + $('input[name="dispatchUnit_' + prop(u, 'unitId') + '"]').prop('checked', true); + }); + personnel.forEach(function (p) { + $('input[name="dispatchUser_' + prop(p, 'userId') + '"]').prop('checked', true); + }); + } + }); + } + newcall.checkForRecommendations = checkForRecommendations; + checkForProtocols(); + checkForRecommendations(); })(newcall = dispatch.newcall || (dispatch.newcall = {})); })(dispatch = resgrid.dispatch || (resgrid.dispatch = {})); })(resgrid || (resgrid = {})); diff --git a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs index 3a7034178..cd9da8075 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs @@ -41,8 +41,33 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP { foreach (var call in pendingCalls) { + var populatedCall = await callsService.PopulateCallData(call, true, false, false, true, true, true, true, false, false); + + // Run card auto-dispatch: scheduled calls are enriched at dispatch + // time, when the call's location and resource picture are final. + try + { + var featureToggleService = Bootstrapper.GetKernel().Resolve(); + if (await featureToggleService.IsEnabledAsync(Resgrid.Model.FeatureFlagKeys.DispatchRunCards, populatedCall.DepartmentId)) + { + var dispatchRecommendationService = Bootstrapper.GetKernel().Resolve(); + var recommendation = await dispatchRecommendationService.EnrichCallForDispatchAsync(populatedCall, 1, true, cancellationToken); + + if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) + { + populatedCall = await callsService.SaveCallAsync(populatedCall, cancellationToken); + await dispatchRecommendationService.RecordActivationAsync(populatedCall, recommendation, null, cancellationToken); + } + } + } + catch (Exception recEx) + { + // A recommendation failure must never block the scheduled dispatch. + Resgrid.Framework.Logging.LogException(recEx); + } + var cqi = new CallQueueItem(); - cqi.Call = await callsService.PopulateCallData(call, true, false, false, true, true, true, true, false, false); + cqi.Call = populatedCall; if (cqi.Call.Dispatches != null && cqi.Call.Dispatches.Any()) cqi.Profiles = await userProfileService.GetSelectedUserProfilesAsync(cqi.Call.Dispatches.Select(x => x.UserId).ToList()); diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs index 986bcca77..e4108fd43 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs @@ -102,6 +102,29 @@ public async Task> Process(CallEmailQueueItem item) // The relationship between the two objects cannot be defined because they are attached to different ObjectContext objects. // So I'm wrapping this in a try catch to prevent all calls form being dropped. var savedCall = await _callsService.SaveCallAsync(newCall); + + // Run card auto-dispatch for imported email calls. + try + { + var featureToggleService = Bootstrapper.GetKernel().Resolve(); + if (await featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, savedCall.DepartmentId)) + { + var dispatchRecommendationService = Bootstrapper.GetKernel().Resolve(); + var recommendation = await dispatchRecommendationService.EnrichCallForDispatchAsync(savedCall, 1, true); + + if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) + { + savedCall = await _callsService.SaveCallAsync(savedCall); + await dispatchRecommendationService.RecordActivationAsync(savedCall, recommendation, null); + } + } + } + catch (Exception recEx) + { + // A recommendation failure must never block the imported call's dispatch. + Logging.LogException(recEx); + } + var cqi = new CallQueueItem(); cqi.Call = savedCall; From 53e77a7fcd00239e4a63bd4834aa52b3dbdb3f95 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 13 Aug 2026 22:11:25 -0700 Subject: [PATCH 2/3] RG-T54 PR#463 fixes --- Core/Resgrid.Model/Call.cs | 23 ++ .../DispatchRecommendationConfig.cs | 16 + Core/Resgrid.Model/GeoMath.cs | 22 +- Core/Resgrid.Services/ChatChannelService.cs | 17 +- .../ChatProvisioningEventService.cs | 22 +- .../DepartmentSettingsService.cs | 48 ++- .../DispatchRecommendationService.cs | 65 ++- .../DispatchVoicePromptBuilder.cs | 177 ++++++++ Core/Resgrid.Services/RunCardsService.cs | 387 ++++++++++++++---- Core/Resgrid.Services/ServicesModule.cs | 8 +- Tests/Resgrid.Tests/Models/CallTests.cs | 38 ++ .../Services/ChatIncidentBackfillTests.cs | 44 ++ ...artmentSettingsServiceUnitTrackingTests.cs | 116 ++++++ .../DispatchRecommendationServiceTests.cs | 103 +++++ Tests/Resgrid.Tests/Services/GeoMathTests.cs | 30 ++ .../Services/RunCardsServiceTests.cs | 152 +++++++ .../Controllers/EmailController.cs | 14 +- .../Controllers/TwilioController.cs | 85 ++-- .../Controllers/v4/CallsController.cs | 32 +- .../Controllers/v4/RunCardsController.cs | 81 +++- .../Models/v4/Calls/EscalateCallResult.cs | 23 ++ .../Models/v4/RunCards/RunCardResults.cs | 49 +++ .../Resgrid.Web.Services.xml | 68 +++ .../Twilio/TwilioVoiceResponseService.cs | 109 +---- Web/Resgrid.Web.Tts/Services/TtsService.cs | 18 +- .../User/Controllers/DepartmentController.cs | 14 + .../User/Controllers/DispatchController.cs | 8 + .../User/Controllers/RunCardsController.cs | 8 + .../Areas/User/Views/Dispatch/ViewCall.cshtml | 50 +-- .../Areas/User/Views/RunCards/Edit.cshtml | 14 +- .../dispatch/resgrid.dispatch.newcall.js | 56 ++- .../Tasks/DispatchScheduledCallsTask.cs | 46 ++- .../Logic/CallBroadcast.cs | 57 +++ 33 files changed, 1695 insertions(+), 305 deletions(-) create mode 100644 Core/Resgrid.Services/DispatchVoicePromptBuilder.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Calls/EscalateCallResult.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardResults.cs diff --git a/Core/Resgrid.Model/Call.cs b/Core/Resgrid.Model/Call.cs index 55b46df14..b3a0b721b 100644 --- a/Core/Resgrid.Model/Call.cs +++ b/Core/Resgrid.Model/Call.cs @@ -229,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()) diff --git a/Core/Resgrid.Model/DispatchRecommendationConfig.cs b/Core/Resgrid.Model/DispatchRecommendationConfig.cs index f756c6b92..4004bcff4 100644 --- a/Core/Resgrid.Model/DispatchRecommendationConfig.cs +++ b/Core/Resgrid.Model/DispatchRecommendationConfig.cs @@ -14,6 +14,22 @@ 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; diff --git a/Core/Resgrid.Model/GeoMath.cs b/Core/Resgrid.Model/GeoMath.cs index 0f4eab6ea..fe2efc82d 100644 --- a/Core/Resgrid.Model/GeoMath.cs +++ b/Core/Resgrid.Model/GeoMath.cs @@ -59,7 +59,7 @@ public static List ParseGeofence(string geofenceJson) 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) + if (!lat.HasValue || !lon.HasValue || !IsValidCoordinate(lat.Value, lon.Value)) return null; points.Add(new GeoPoint(lat.Value, lon.Value)); @@ -160,9 +160,29 @@ public static double HaversineMeters(double lat1, double lon1, double lat2, doub 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. /// diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 13037b45f..3320bfb7f 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -686,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(), @@ -1223,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) { @@ -1233,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/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index f5f971295..5d1fbcf88 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -51,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) { @@ -60,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)) @@ -908,7 +915,7 @@ async Task getSetting() var config = ObjectSerialization.Deserialize(value); if (config != null) - return config; + return ClampDispatchRecommendationConfig(config); } catch (Exception) { @@ -919,6 +926,37 @@ async Task getSetting() 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) diff --git a/Core/Resgrid.Services/DispatchRecommendationService.cs b/Core/Resgrid.Services/DispatchRecommendationService.cs index c5b1f2d3b..79c89044e 100644 --- a/Core/Resgrid.Services/DispatchRecommendationService.cs +++ b/Core/Resgrid.Services/DispatchRecommendationService.cs @@ -105,7 +105,8 @@ public DispatchRecommendationService(IRunCardsService runCardsService, IUnitsSer Config = config, StaffingGate = staffingGate, Now = now, - Result = result + Result = result, + CancellationToken = cancellationToken }; await BuildUnitCandidatesAsync(context); @@ -299,6 +300,13 @@ private sealed class RecommendationContext 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(); @@ -941,6 +949,8 @@ private async Task FillUnitRequirementByProximityAsync(RecommendationContext con 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)); @@ -1048,6 +1058,8 @@ private async Task FillRoleRequirementByProximityAsync(RecommendationContext con 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)); @@ -1133,6 +1145,10 @@ private async Task RunMoveUpPassAsync(RecommendationContext context) 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) @@ -1143,7 +1159,7 @@ private async Task RunMoveUpPassAsync(RecommendationContext context) if (requirement.UnitTypeId.HasValue) EvaluateUnitCoverage(context, requirement, station, stationPoint, committedUnitIds); else if (requirement.PersonnelRoleId.HasValue) - EvaluateRoleCoverage(context, requirement, station, committedUserIds); + EvaluateRoleCoverage(context, requirement, station, stationPoint, committedUserIds); } } @@ -1153,8 +1169,7 @@ private void EvaluateUnitCoverage(RecommendationContext context, StationCoverage var typeUnits = context.UnitCandidates.Where(c => c.UnitTypeId == requirement.UnitTypeId.Value).ToList(); List remaining; - if (requirement.RadiusMeters.HasValue && requirement.RadiusMeters.Value > 0 && stationPoint.HasValue - && typeUnits.Any(c => c.Latitude.HasValue)) + if (UseRadiusCoverage(context, requirement, stationPoint, typeUnits.Any(c => c.Latitude.HasValue))) { remaining = typeUnits .Where(c => !committedUnitIds.Contains(c.Unit.UnitId)) @@ -1201,16 +1216,28 @@ private void EvaluateUnitCoverage(RecommendationContext context, StationCoverage } private void EvaluateRoleCoverage(RecommendationContext context, StationCoverageRequirement requirement, - DepartmentGroup station, HashSet committedUserIds) + DepartmentGroup station, GeoMath.GeoPoint? stationPoint, HashSet committedUserIds) { var roleHolders = context.PersonnelCandidates .Where(c => c.RoleIds.Contains(requirement.PersonnelRoleId.Value)) .ToList(); - var remaining = roleHolders - .Where(c => !committedUserIds.Contains(c.UserId)) - .Where(c => c.StationGroupId == requirement.DepartmentGroupId) - .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; @@ -1235,6 +1262,26 @@ private void EvaluateRoleCoverage(RecommendationContext context, StationCoverage 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; 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/RunCardsService.cs b/Core/Resgrid.Services/RunCardsService.cs index 23823978b..ceaf9efc5 100644 --- a/Core/Resgrid.Services/RunCardsService.cs +++ b/Core/Resgrid.Services/RunCardsService.cs @@ -6,6 +6,7 @@ using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; namespace Resgrid.Services @@ -24,12 +25,19 @@ public class RunCardsService : IRunCardsService 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) + ICacheProvider cacheProvider, IUnitOfWork unitOfWork, IUnitsService unitsService, + IPersonnelRolesService personnelRolesService, IDepartmentGroupsService departmentGroupsService, + ICustomStateService customStateService) { _runCardsRepository = runCardsRepository; _runCardTriggersRepository = runCardTriggersRepository; @@ -40,6 +48,11 @@ public RunCardsService(IRunCardsRepository runCardsRepository, IRunCardTriggersR _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) @@ -81,6 +94,23 @@ public async Task GetRunCardByIdAsync(int runCardId) 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 @@ -90,90 +120,268 @@ public async Task GetRunCardByIdAsync(int runCardId) var alarmLevels = runCard.AlarmLevels?.ToList() ?? new List(); var selections = runCard.AvailabilitySelections?.ToList() ?? new List(); - await _runCardsRepository.SaveOrUpdateAsync(runCard, cancellationToken, true); + // 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); - // Triggers - var existingTriggers = isNew - ? new List() - : (await _runCardTriggersRepository.GetTriggersByRunCardIdAsync(runCard.RunCardId)).ToList(); + await _runCardAlarmLevelsRepository.DeleteAsync(removedLevel, cancellationToken); + } - foreach (var removed in existingTriggers.Where(e => triggers.All(t => t.RunCardTriggerId != e.RunCardTriggerId))) - await _runCardTriggersRepository.DeleteAsync(removed, 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); + } - foreach (var trigger in triggers) + _unitOfWork.CommitChanges(); + } + catch { - trigger.RunCardId = runCard.RunCardId; - await _runCardTriggersRepository.SaveOrUpdateAsync(trigger, cancellationToken, true); + _unitOfWork.DiscardChanges(); + throw; } - // 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))) + // 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 req in existingUnitReqs.Where(r => r.RunCardAlarmLevelId == removedLevel.RunCardAlarmLevelId)) - await _runCardUnitRequirementsRepository.DeleteAsync(req, cancellationToken); + foreach (var trigger in triggers) + trigger.RunCardTriggerId = 0; + + foreach (var level in levels) + level.RunCardAlarmLevelId = 0; - foreach (var req in existingRoleReqs.Where(r => r.RunCardAlarmLevelId == removedLevel.RunCardAlarmLevelId)) - await _runCardRoleRequirementsRepository.DeleteAsync(req, cancellationToken); + foreach (var requirement in unitRequirements) + requirement.RunCardUnitRequirementId = 0; - await _runCardAlarmLevelsRepository.DeleteAsync(removedLevel, cancellationToken); + foreach (var requirement in roleRequirements) + requirement.RunCardRoleRequirementId = 0; + + foreach (var selection in selections) + selection.RunCardAvailabilitySelectionId = 0; + + return; } - foreach (var level in alarmLevels) + 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 unitReqs = level.UnitRequirements?.ToList() ?? new List(); - var roleReqs = level.RoleRequirements?.ToList() ?? new List(); + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(runCard.DepartmentId); - level.RunCardId = runCard.RunCardId; - await _runCardAlarmLevelsRepository.SaveOrUpdateAsync(level, cancellationToken, true); + 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)); + } - foreach (var removed in existingUnitReqs.Where(e => e.RunCardAlarmLevelId == level.RunCardAlarmLevelId - && unitReqs.All(r => r.RunCardUnitRequirementId != e.RunCardUnitRequirementId))) - await _runCardUnitRequirementsRepository.DeleteAsync(removed, cancellationToken); + var triggerCallTypeIds = (runCard.Triggers ?? new List()) + .Where(t => t.CallTypeId.HasValue) + .Select(t => t.CallTypeId.Value) + .Distinct() + .ToList(); - foreach (var req in unitReqs) - { - req.RunCardAlarmLevelId = level.RunCardAlarmLevelId; - await _runCardUnitRequirementsRepository.SaveOrUpdateAsync(req, cancellationToken, true); - } + if (triggerCallTypeIds.Any()) + { + var callTypes = await _callTypesRepository.GetAllByDepartmentIdAsync(runCard.DepartmentId); + var callTypeIds = new HashSet((callTypes ?? Enumerable.Empty()).Select(t => t.CallTypeId)); - foreach (var removed in existingRoleReqs.Where(e => e.RunCardAlarmLevelId == level.RunCardAlarmLevelId - && roleReqs.All(r => r.RunCardRoleRequirementId != e.RunCardRoleRequirementId))) - await _runCardRoleRequirementsRepository.DeleteAsync(removed, cancellationToken); + if (triggerCallTypeIds.Any(id => !callTypeIds.Contains(id))) + throw new ArgumentException("A trigger references a call type from another department.", nameof(runCard)); + } - foreach (var req in roleReqs) - { - req.RunCardAlarmLevelId = level.RunCardAlarmLevelId; - await _runCardRoleRequirementsRepository.SaveOrUpdateAsync(req, cancellationToken, true); - } + var levels = runCard.AlarmLevels ?? new List(); + var selections = runCard.AvailabilitySelections ?? new List(); + + var referencedUnitTypeIds = levels + .SelectMany(l => l.UnitRequirements ?? new List()) + .Select(r => r.UnitTypeId) + .Concat(selections.Where(s => s.UnitTypeId.HasValue).Select(s => s.UnitTypeId.Value)) + .Distinct() + .ToList(); + + if (referencedUnitTypeIds.Any()) + { + var unitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(runCard.DepartmentId); + var unitTypeIds = new HashSet((unitTypes ?? new List()).Select(t => t.UnitTypeId)); + + if (referencedUnitTypeIds.Any(id => !unitTypeIds.Contains(id))) + throw new ArgumentException("A unit type does not belong to this department.", nameof(runCard)); } - // Availability selections - var existingSelections = isNew - ? new List() - : (await _runCardAvailabilitySelectionsRepository.GetSelectionsByRunCardIdAsync(runCard.RunCardId)).ToList(); + var referencedRoleIds = levels + .SelectMany(l => l.RoleRequirements ?? new List()) + .Select(r => r.PersonnelRoleId) + .Distinct() + .ToList(); - foreach (var removed in existingSelections.Where(e => selections.All(s => s.RunCardAvailabilitySelectionId != e.RunCardAvailabilitySelectionId))) - await _runCardAvailabilitySelectionsRepository.DeleteAsync(removed, cancellationToken); + if (referencedRoleIds.Any()) + { + var roles = await _personnelRolesService.GetRolesForDepartmentAsync(runCard.DepartmentId); + var roleIds = new HashSet((roles ?? new List()).Select(r => r.PersonnelRoleId)); - foreach (var selection in selections) + if (referencedRoleIds.Any(id => !roleIds.Contains(id))) + throw new ArgumentException("A personnel role does not belong to this department.", nameof(runCard)); + } + + // Only custom selections carry a department-scoped id; built-in ones are enum + // values shared by every department and have no ownership to check. + var customStateIds = selections.Where(s => s.IsCustomState).Select(s => s.StateId).Distinct().ToList(); + + if (customStateIds.Any()) { - selection.RunCardId = runCard.RunCardId; - await _runCardAvailabilitySelectionsRepository.SaveOrUpdateAsync(selection, cancellationToken, true); + var owned = new HashSet(); + + foreach (var state in await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(runCard.DepartmentId) ?? new List()) + AddActiveDetailIds(owned, state); + + AddActiveDetailIds(owned, await _customStateService.GetActivePersonnelStateForDepartmentAsync(runCard.DepartmentId)); + AddActiveDetailIds(owned, await _customStateService.GetActiveStaffingLevelsForDepartmentAsync(runCard.DepartmentId)); + + if (customStateIds.Any(id => !owned.Contains(id))) + throw new ArgumentException("A status selection does not belong to this department.", nameof(runCard)); } + } - await InvalidateRunCardsInCacheAsync(runCard.DepartmentId); + private static void AddActiveDetailIds(HashSet ids, CustomState state) + { + if (state == null) + return; - return runCard; + foreach (var detail in state.GetActiveDetails() ?? new List()) + ids.Add(detail.CustomStateDetailId); } public async Task DeleteRunCardAsync(int runCardId, CancellationToken cancellationToken = default(CancellationToken)) @@ -183,24 +391,37 @@ public async Task GetRunCardByIdAsync(int runCardId) if (card == null) return false; - foreach (var level in card.AlarmLevels ?? Enumerable.Empty()) + // Children are removed before the header, so a partial failure would strand rows + // pointing at a card that no longer exists (or leave a card with no alarm levels). + _unitOfWork.CreateOrGetConnection(); + try { - foreach (var req in level.UnitRequirements ?? Enumerable.Empty()) - await _runCardUnitRequirementsRepository.DeleteAsync(req, cancellationToken); + foreach (var level in card.AlarmLevels ?? Enumerable.Empty()) + { + foreach (var req in level.UnitRequirements ?? Enumerable.Empty()) + await _runCardUnitRequirementsRepository.DeleteAsync(req, cancellationToken); - foreach (var req in level.RoleRequirements ?? Enumerable.Empty()) - await _runCardRoleRequirementsRepository.DeleteAsync(req, cancellationToken); + foreach (var req in level.RoleRequirements ?? Enumerable.Empty()) + await _runCardRoleRequirementsRepository.DeleteAsync(req, cancellationToken); - await _runCardAlarmLevelsRepository.DeleteAsync(level, cancellationToken); - } + await _runCardAlarmLevelsRepository.DeleteAsync(level, cancellationToken); + } + + foreach (var trigger in card.Triggers ?? Enumerable.Empty()) + await _runCardTriggersRepository.DeleteAsync(trigger, cancellationToken); - foreach (var trigger in card.Triggers ?? Enumerable.Empty()) - await _runCardTriggersRepository.DeleteAsync(trigger, cancellationToken); + foreach (var selection in card.AvailabilitySelections ?? Enumerable.Empty()) + await _runCardAvailabilitySelectionsRepository.DeleteAsync(selection, cancellationToken); - foreach (var selection in card.AvailabilitySelections ?? Enumerable.Empty()) - await _runCardAvailabilitySelectionsRepository.DeleteAsync(selection, cancellationToken); + await _runCardsRepository.DeleteAsync(card, cancellationToken); - await _runCardsRepository.DeleteAsync(card, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } await InvalidateRunCardsInCacheAsync(card.DepartmentId); @@ -307,6 +528,28 @@ public async Task> GetStationCoverageRequiremen if (requirement.UnitTypeId.HasValue && requirement.PersonnelRoleId.HasValue) throw new ArgumentException("A station coverage requirement cannot target both a unit type and a personnel role.", nameof(requirement)); + // Same client-supplied ids as a run card, same ownership bar. + var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(requirement.DepartmentId); + + if (stations == null || stations.All(s => s.DepartmentGroupId != requirement.DepartmentGroupId)) + throw new ArgumentException("The station does not belong to this department.", nameof(requirement)); + + if (requirement.UnitTypeId.HasValue) + { + var unitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(requirement.DepartmentId); + + if (unitTypes == null || unitTypes.All(t => t.UnitTypeId != requirement.UnitTypeId.Value)) + throw new ArgumentException("The unit type does not belong to this department.", nameof(requirement)); + } + + if (requirement.PersonnelRoleId.HasValue) + { + var roles = await _personnelRolesService.GetRolesForDepartmentAsync(requirement.DepartmentId); + + if (roles == null || roles.All(r => r.PersonnelRoleId != requirement.PersonnelRoleId.Value)) + throw new ArgumentException("The personnel role does not belong to this department.", nameof(requirement)); + } + return await _stationCoverageRequirementsRepository.SaveOrUpdateAsync(requirement, cancellationToken, true); } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 75d186fd4..3c36cdc4b 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -122,9 +122,15 @@ protected override void Load(ContainerBuilder builder) if (string.IsNullOrWhiteSpace(TtsConfig.ServiceBaseUrl)) throw new InvalidOperationException("TtsConfig.ServiceBaseUrl must be configured before using the TTS service."); + // 30s: long enough to ride out a cold TTS generation (Piper model load + + // synthesis + normalization + upload can take 10-20s on constrained pods). + // Twilio webhooks never block on this — they bound their own waits with + // short CancellationTokens and fall back to "please wait"/ — so the + // long timeout only affects background pre-warms and worker tasks, where + // waiting out the generation is exactly what we want. var options = new RestClientOptions(TtsConfig.ServiceBaseUrl.TrimEnd('/')) { - Timeout = TimeSpan.FromSeconds(5) + Timeout = TimeSpan.FromSeconds(30) }; return new RestClient(options, configureSerialization: serializer => serializer.UseNewtonsoftJson()); diff --git a/Tests/Resgrid.Tests/Models/CallTests.cs b/Tests/Resgrid.Tests/Models/CallTests.cs index 90418a036..fdecfd0dd 100644 --- a/Tests/Resgrid.Tests/Models/CallTests.cs +++ b/Tests/Resgrid.Tests/Models/CallTests.cs @@ -46,5 +46,43 @@ public void DidDispatchCountChange_ReturnsFalse_WhenCountUnchanged() call.DidDispatchCountChange().Should().BeFalse(); } + + [Test] + public void GetDisplayName_PrefixesTheNumberOntoTheName() + { + var call = new Call { Number = "26-45", Name = "Structure Fire" }; + + call.GetDisplayName().Should().Be("26-45 Structure Fire"); + } + + [Test] + public void GetDisplayName_ReturnsTheNameAlone_WhenThereIsNoNumber() + { + var call = new Call { Name = "Structure Fire" }; + + call.GetDisplayName().Should().Be("Structure Fire"); + } + + [Test] + public void GetDisplayName_ReturnsTheNumberAlone_WhenThereIsNoName() + { + var call = new Call { Number = "26-45" }; + + call.GetDisplayName().Should().Be("26-45"); + } + + [Test] + public void GetDisplayName_DoesNotDoubleUpWhenTheNameAlreadyLeadsWithTheNumber() + { + var call = new Call { Number = "26-45", Name = "26-45 Structure Fire" }; + + call.GetDisplayName().Should().Be("26-45 Structure Fire"); + } + + [Test] + public void GetDisplayName_IsEmpty_WhenTheCallHasNeither() + { + new Call().GetDisplayName().Should().BeEmpty(); + } } } diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs index a4724f9e6..99dc33f8b 100644 --- a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs @@ -266,6 +266,50 @@ public async Task channels_fall_back_to_the_call_name_when_the_command_is_unname _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.Name == "Structure Fire Staging"); } + [Test] + public async Task the_call_number_prefixes_the_call_name_on_every_incident_channel() + { + // What a responder scanning the channel list actually recognizes: "26-45 Structure Fire", + // not the call id. + GivenExistingChannels(); + _callsService.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())) + .ReturnsAsync(new Call { CallId = CallId, DepartmentId = 1, Number = "26-45", Name = "Structure Fire" }); + + await BuildService().EnsureIncidentChannelsAsync(BuildCommand(), new[] { BuildNode("Staging") }); + + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.Incident && c.Name == "26-45 Structure Fire"); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentCommand && c.Name == "26-45 Structure Fire Command (private)"); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLeads && c.Name == "26-45 Structure Fire All Leads"); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentDispatch && c.Name == "26-45 Structure Fire Dispatch"); + _inserted.Should().Contain(c => c.ChannelType == (int)ChatChannelType.IncidentLane && c.Name == "26-45 Structure Fire Staging"); + } + + [Test] + public async Task an_incident_channel_stuck_on_the_call_id_fallback_is_renamed() + { + // Channels created when the call lookup failed carry the bare "Call {id}" fallback forever — + // the next ensure (call added, call edited) has to heal them in place. + var existing = new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident, Name = $"Call {CallId}" }; + _channelRepository.Setup(x => x.GetByCallIdAndTypeAsync(CallId, (int)ChatChannelType.Incident)).ReturnsAsync(existing); + + await BuildService().EnsureIncidentChannelAsync(1, CallId, "26-45 Structure Fire"); + + _inserted.Should().BeEmpty(); + _channelRepository.Verify(x => x.UpdateChannelInfoAsync("a", "26-45 Structure Fire", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _permissionService.Verify(x => x.InvalidateChannelCacheAsync("a"), Times.Once); + } + + [Test] + public async Task an_incident_channel_already_carrying_the_call_name_is_not_rewritten() + { + var existing = new ChatChannel { ChatChannelId = "a", CallId = CallId, ChannelType = (int)ChatChannelType.Incident, Name = "26-45 Structure Fire" }; + _channelRepository.Setup(x => x.GetByCallIdAndTypeAsync(CallId, (int)ChatChannelType.Incident)).ReturnsAsync(existing); + + await BuildService().EnsureIncidentChannelAsync(1, CallId, "26-45 Structure Fire"); + + _channelRepository.Verify(x => x.UpdateChannelInfoAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task stale_channel_names_are_refreshed_when_the_incident_is_named() { diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs index 246da55c5..ac8e0fb5a 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceUnitTrackingTests.cs @@ -1,9 +1,12 @@ +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Moq; using NUnit.Framework; using Resgrid.Config; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; @@ -91,5 +94,118 @@ await _service.SaveOrUpdateSettingAsync( provider => provider.RemoveAsync("DSetHardwareTrackingStale_7"), Times.Once); } + + [Test] + public async Task GetDispatchRecommendationConfigAsync_ClampsOutOfRangeTuningValues() + { + var stored = new DispatchRecommendationConfig + { + MaxLocationAgeSeconds = 999999, + PersonnelMaxLocationAgeSeconds = 999999, + MaxRadiusMeters = 99999999, + RestPeriodMinutes = 100000, + // Each shortlisted candidate is one routed-ETA call to the mapping provider. + EtaShortlistSize = 10000 + }; + + _repository + .Setup(repository => repository.GetDepartmentSettingByIdTypeAsync( + 7, + DepartmentSettingTypes.DispatchRecommendationConfig)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.DispatchRecommendationConfig, + Setting = ObjectSerialization.Serialize(stored) + }); + + var config = await _service.GetDispatchRecommendationConfigAsync(7); + + config.MaxLocationAgeSeconds.Should().Be(DispatchRecommendationConfig.MaximumLocationAgeSeconds); + config.PersonnelMaxLocationAgeSeconds.Should().Be(DispatchRecommendationConfig.MaximumLocationAgeSeconds); + config.MaxRadiusMeters.Should().Be(DispatchRecommendationConfig.MaximumRadiusMeters); + config.RestPeriodMinutes.Should().Be(DispatchRecommendationConfig.MaximumRestPeriodMinutes); + config.EtaShortlistSize.Should().Be(DispatchRecommendationConfig.MaximumEtaShortlistSize); + } + + [Test] + public async Task GetDispatchRecommendationConfigAsync_PreservesInRangeValuesAndZeroMeansNoLimit() + { + var stored = new DispatchRecommendationConfig + { + MaxLocationAgeSeconds = 600, + MaxRadiusMeters = 0, + RestPeriodMinutes = 0, + EtaShortlistSize = 3 + }; + + _repository + .Setup(repository => repository.GetDepartmentSettingByIdTypeAsync( + 7, + DepartmentSettingTypes.DispatchRecommendationConfig)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.DispatchRecommendationConfig, + Setting = ObjectSerialization.Serialize(stored) + }); + + var config = await _service.GetDispatchRecommendationConfigAsync(7); + + config.MaxLocationAgeSeconds.Should().Be(600); + config.MaxRadiusMeters.Should().Be(0); + config.RestPeriodMinutes.Should().Be(0); + config.EtaShortlistSize.Should().Be(3); + } + + [Test] + public async Task SaveOrUpdateSettingAsync_InvalidatesCacheAfterTheWriteCommits() + { + // Invalidating before the write lets a concurrent reader repopulate the key with + // the pre-write value, which then survives for the full cache TTL. + var sequence = new List(); + + _repository + .Setup(repository => repository.SaveOrUpdateAsync( + It.IsAny(), + It.IsAny(), + false)) + .Callback(() => sequence.Add("write")) + .ReturnsAsync((DepartmentSetting setting, CancellationToken cancellationToken, bool firstLevelOnly) => setting); + + _cacheProvider + .Setup(provider => provider.RemoveAsync(It.IsAny())) + .Callback(() => sequence.Add("invalidate")) + .ReturnsAsync(true); + + await _service.SaveOrUpdateSettingAsync( + 7, + "240", + DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds); + + sequence.Should().Equal("write", "invalidate"); + } + + [Test] + public void SaveOrUpdateSettingAsync_FailedWrite_DoesNotInvalidateCache() + { + _repository + .Setup(repository => repository.SaveOrUpdateAsync( + It.IsAny(), + It.IsAny(), + false)) + .ThrowsAsync(new InvalidOperationException("write failed")); + + Assert.ThrowsAsync(async () => + await _service.SaveOrUpdateSettingAsync( + 7, + "240", + DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds)); + + // The cached value still matches the database, so dropping it would be a pointless miss. + _cacheProvider.Verify( + provider => provider.RemoveAsync(It.IsAny()), + Times.Never); + } } } diff --git a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs index 38ab1e8c3..8706461e5 100644 --- a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs @@ -461,6 +461,109 @@ public async Task move_up_pass_flags_station_coverage_gap_with_donor() m.StationGroupId == StationAId && m.SuggestedUnitId == 2 && m.FromStationGroupId == StationBId); } + [Test] + public void cancelling_the_request_stops_routed_eta_lookups() + { + BuildCard(engineCount: 1); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + _config.UseRoutedEta = true; + + var now = DateTime.UtcNow; + _unitsService.Setup(x => x.GetLatestUnitLocationsAsync(DepartmentId)) + .ReturnsAsync(new List + { + new UnitsLocation { UnitId = 1, Latitude = 39.7501m, Longitude = -104.9501m, Timestamp = now }, + new UnitsLocation { UnitId = 2, Latitude = 39.76m, Longitude = -104.96m, Timestamp = now } + }); + + // The caller gives up while the first routed ETA lookup is in flight; the loop + // must not keep calling the mapping provider for the rest of the shortlist. + var cancellation = new CancellationTokenSource(); + _geoService.Setup(x => x.GetEtaInSecondsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + cancellation.Cancel(); + return 120d; + }); + + Assert.ThrowsAsync(async () => + await _service.GetRecommendationAsync(BuildRequest(), cancellation.Token)); + + _geoService.Verify(x => x.GetEtaInSecondsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task move_up_pass_measures_role_coverage_by_radius_in_closest_unit_mode() + { + BuildCard(roleCount: 1); + _config.MoveUpRecommendationsEnabled = true; + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + + // user-1 sits at Station A, user-2 is 100km away. Station A requires one + // firefighter within 5km; user-1 gets dispatched to the call, so the radius + // leaves the station uncovered even though user-2 is still available. + var now = DateTime.UtcNow; + _personnelLocationResolver.Setup(x => x.GetLatestLocationsAsync(DepartmentId, It.IsAny(), It.IsAny())) + .ReturnsAsync(new Dictionary + { + { "user-1", new ResolvedPersonnelLocation { UserId = "user-1", Latitude = 39.7501, Longitude = -104.9501, Timestamp = now } }, + { "user-2", new ResolvedPersonnelLocation { UserId = "user-2", Latitude = 40.75, Longitude = -104.95, Timestamp = now } } + }); + + _runCardsService.Setup(x => x.GetStationCoverageRequirementsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new StationCoverageRequirement + { + StationCoverageRequirementId = 1, + DepartmentId = DepartmentId, + DepartmentGroupId = StationAId, + PersonnelRoleId = FirefighterRoleId, + MinimumAvailableCount = 1, + RadiusMeters = 5000, + IsEnabled = true + } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Personnel.Should().ContainSingle(p => p.UserId == "user-1"); + result.MoveUps.Should().ContainSingle(m => m.StationGroupId == StationAId + && m.PersonnelRoleId == FirefighterRoleId && m.AvailableAfterDispatch == 0); + } + + [Test] + public async Task move_up_pass_ignores_radius_for_role_coverage_in_station_based_mode() + { + BuildCard(roleCount: 1); + _config.MoveUpRecommendationsEnabled = true; + + // Same requirement, but the department dispatches station-based, where "at this + // station" means group assignment. user-2 is assigned to Station B, so Station A + // is genuinely uncovered once user-1 goes on the call; the radius is not applied. + _runCardsService.Setup(x => x.GetStationCoverageRequirementsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List + { + new StationCoverageRequirement + { + StationCoverageRequirementId = 1, + DepartmentId = DepartmentId, + DepartmentGroupId = StationAId, + PersonnelRoleId = FirefighterRoleId, + MinimumAvailableCount = 1, + RadiusMeters = 5000, + IsEnabled = true + } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.MoveUps.Should().ContainSingle(m => m.StationGroupId == StationAId + && m.SuggestedUserId == "user-2" && m.FromStationGroupId == StationBId); + } + [Test] public async Task enrich_call_adds_dispatch_rows_without_duplicates_and_stamps_run_card() { diff --git a/Tests/Resgrid.Tests/Services/GeoMathTests.cs b/Tests/Resgrid.Tests/Services/GeoMathTests.cs index c813c7780..579c10bc2 100644 --- a/Tests/Resgrid.Tests/Services/GeoMathTests.cs +++ b/Tests/Resgrid.Tests/Services/GeoMathTests.cs @@ -54,6 +54,15 @@ public void should_return_null_for_null_empty_or_garbage() GeoMath.ParseGeofence("[{\"foo\":1,\"bar\":2},{\"foo\":1,\"bar\":2},{\"foo\":1,\"bar\":2}]").Should().BeNull(); } + [Test] + public void should_return_null_for_non_finite_or_out_of_range_vertices() + { + GeoMath.ParseGeofence("[{\"lat\":NaN,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.8}]").Should().BeNull(); + GeoMath.ParseGeofence("[{\"lat\":\"NaN\",\"lng\":\"-104.9\"},{\"lat\":\"39.8\",\"lng\":\"-104.9\"},{\"lat\":\"39.8\",\"lng\":\"-104.8\"}]").Should().BeNull(); + GeoMath.ParseGeofence("[{\"lat\":500,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.8}]").Should().BeNull(); + GeoMath.ParseGeofence("[{\"lat\":39.7,\"lng\":-500},{\"lat\":39.8,\"lng\":-104.9},{\"lat\":39.8,\"lng\":-104.8}]").Should().BeNull(); + } + [Test] public void should_return_null_for_degenerate_polygons() { @@ -169,6 +178,27 @@ public void should_parse_valid_pairs_and_lat_lon_blobs() blob.Value.Longitude.Should().BeApproximately(-104.9903, 0.0001); } + [Test] + public void should_reject_non_finite_and_out_of_range_coordinates() + { + GeoMath.ParseCoordinatePair("NaN", "-104.9").Should().BeNull(); + GeoMath.ParseCoordinatePair("39.7", "NaN").Should().BeNull(); + GeoMath.ParseCoordinatePair("Infinity", "-104.9").Should().BeNull(); + GeoMath.ParseCoordinatePair("39.7", "-Infinity").Should().BeNull(); + GeoMath.ParseCoordinatePair("90.1", "-104.9").Should().BeNull(); + GeoMath.ParseCoordinatePair("-90.1", "-104.9").Should().BeNull(); + GeoMath.ParseCoordinatePair("39.7", "180.1").Should().BeNull(); + GeoMath.ParseCoordinatePair("39.7", "-180.1").Should().BeNull(); + GeoMath.ParseLatLonString("NaN,-104.9").Should().BeNull(); + } + + [Test] + public void should_accept_coordinates_on_the_range_boundaries() + { + GeoMath.ParseCoordinatePair("90", "180").Should().NotBeNull(); + GeoMath.ParseCoordinatePair("-90", "-180").Should().NotBeNull(); + } + [Test] public void should_reject_missing_zero_or_garbage_input() { diff --git a/Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs b/Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs index 0338a95bd..a682f4099 100644 --- a/Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/RunCardsServiceTests.cs @@ -1,14 +1,166 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using FluentAssertions; +using Moq; using NUnit.Framework; using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; using Resgrid.Services; namespace Resgrid.Tests.Services { namespace RunCardsServiceTests { + [TestFixture] + public class when_saving_a_run_card + { + private const int DepartmentId = 1; + private const int OwnedUnitTypeId = 100; + private const int OwnedStationId = 10; + + private Mock _unitsService; + private Mock _departmentGroupsService; + private Mock _runCardsRepository; + private Mock _runCardTriggersRepository; + + private RunCardsService BuildService() + { + _unitsService = new Mock(); + _departmentGroupsService = new Mock(); + _runCardsRepository = new Mock(); + _runCardTriggersRepository = new Mock(); + + _unitsService.Setup(x => x.GetUnitTypesForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List { new UnitType { UnitTypeId = OwnedUnitTypeId, DepartmentId = DepartmentId, Type = "Engine" } }); + _departmentGroupsService.Setup(x => x.GetAllStationGroupsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List { new DepartmentGroup { DepartmentGroupId = OwnedStationId, DepartmentId = DepartmentId } }); + + return new RunCardsService( + _runCardsRepository.Object, + _runCardTriggersRepository.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + _unitsService.Object, + Mock.Of(), + _departmentGroupsService.Object, + Mock.Of()); + } + + [Test] + public async Task should_clear_child_identifiers_when_creating_a_new_card() + { + // SaveOrUpdateAsync treats a non-zero child id as an update keyed on that id + // alone, so a new card carrying one would rewrite another card's row. + var card = CardWithLevels(1); + card.Triggers = new List { new RunCardTrigger { RunCardTriggerId = 777, TriggerType = 0, Priority = 3 } }; + card.AlarmLevels.First().RunCardAlarmLevelId = 888; + card.AlarmLevels.First().UnitRequirements = new List + { + new RunCardUnitRequirement { RunCardUnitRequirementId = 999, UnitTypeId = OwnedUnitTypeId, RequiredCount = 1 } + }; + + await BuildService().SaveRunCardAsync(card); + + card.Triggers.First().RunCardTriggerId.Should().Be(0); + card.AlarmLevels.First().RunCardAlarmLevelId.Should().Be(0); + card.AlarmLevels.First().UnitRequirements.First().RunCardUnitRequirementId.Should().Be(0); + } + + [Test] + public void should_reject_a_child_identifier_from_another_run_card() + { + var service = BuildService(); + + // The stored card owns trigger 1; the submission claims trigger 777. + _runCardsRepository + .Setup(x => x.GetByIdAsync(5)) + .ReturnsAsync(new RunCard { RunCardId = 5, DepartmentId = DepartmentId, Name = "Stored" }); + _runCardTriggersRepository + .Setup(x => x.GetTriggersByRunCardIdAsync(5)) + .ReturnsAsync(new List { new RunCardTrigger { RunCardTriggerId = 1, RunCardId = 5 } }); + + var card = CardWithLevels(1); + card.RunCardId = 5; + card.Triggers = new List { new RunCardTrigger { RunCardTriggerId = 777, TriggerType = 0, Priority = 3 } }; + + Assert.ThrowsAsync(async () => await service.SaveRunCardAsync(card)); + } + + [Test] + public void should_reject_a_home_station_from_another_department() + { + // The engine anchors its station cascade on this id without a department + // check of its own, so a foreign station would steer selection. + var card = CardWithLevels(1); + card.HomeStationGroupId = 999; + + Assert.ThrowsAsync(async () => await BuildService().SaveRunCardAsync(card)); + } + + [Test] + public void should_reject_a_unit_type_from_another_department() + { + var card = CardWithLevels(1); + card.AlarmLevels.First().UnitRequirements = new List + { + new RunCardUnitRequirement { UnitTypeId = 999, RequiredCount = 1 } + }; + + Assert.ThrowsAsync(async () => await BuildService().SaveRunCardAsync(card)); + } + + [Test] + public void should_accept_references_owned_by_the_department() + { + var card = CardWithLevels(1); + card.HomeStationGroupId = OwnedStationId; + card.AlarmLevels.First().UnitRequirements = new List + { + new RunCardUnitRequirement { UnitTypeId = OwnedUnitTypeId, RequiredCount = 1 } + }; + + Assert.DoesNotThrowAsync(async () => await BuildService().SaveRunCardAsync(card)); + } + + private static RunCard CardWithLevels(params int[] levels) + { + return new RunCard + { + RunCardId = 0, + DepartmentId = DepartmentId, + Name = "Structure Fire", + AlarmLevels = levels.Select(l => new RunCardAlarmLevel { AlarmLevel = l }).ToList() + }; + } + + [Test] + public void should_reject_alarm_levels_below_one() + { + // Escalation starts at 1, so a level below it could never be matched. + Assert.ThrowsAsync(async () => + await BuildService().SaveRunCardAsync(CardWithLevels(0, 1))); + } + + [Test] + public void should_reject_duplicate_alarm_levels() + { + // Otherwise this surfaces as a UX_RunCardAlarmLevels_Card_Level violation. + Assert.ThrowsAsync(async () => + await BuildService().SaveRunCardAsync(CardWithLevels(1, 1))); + } + } + [TestFixture] public class when_evaluating_run_card_trigger_specificity { diff --git a/Web/Resgrid.Web.Services/Controllers/EmailController.cs b/Web/Resgrid.Web.Services/Controllers/EmailController.cs index f33901a35..4299bc359 100644 --- a/Web/Resgrid.Web.Services/Controllers/EmailController.cs +++ b/Web/Resgrid.Web.Services/Controllers/EmailController.cs @@ -568,7 +568,9 @@ public async Task Receive(PostmarkInboundMessage message, Cancella var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); - await QueueCallBroadcastAsync(savedCall, cancellationToken); + // Group-scoped: the units and members above were narrowed to this + // group, so run card enrichment stays out of it. + await QueueCallBroadcastAsync(savedCall, cancellationToken, false); return CreatedAtAction(nameof(Receive), new { id = savedCall.CallId }, savedCall); } @@ -641,7 +643,13 @@ public async Task Receive(PostmarkInboundMessage message, Cancella } } - private async Task QueueCallBroadcastAsync(Call savedCall, CancellationToken cancellationToken) + /// + /// False for calls raised through a group's dispatch address. That path deliberately + /// builds its dispatch list from one group's units and members, and the recommendation + /// engine scores against the whole department, so enriching there would pull in other + /// stations and break the scoping the caller asked for by emailing that address. + /// + private async Task QueueCallBroadcastAsync(Call savedCall, CancellationToken cancellationToken, bool allowRunCardEnrichment = true) { var call = await _callsService.PopulateCallData(savedCall, true, false, false, true, true, true, false, false, false); @@ -649,7 +657,7 @@ private async Task QueueCallBroadcastAsync(Call savedCall, CancellationToken can // recommended resources when the resolved auto-dispatch decision is on. try { - if (await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, call.DepartmentId)) + if (allowRunCardEnrichment && await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, call.DepartmentId)) { var recommendation = await _dispatchRecommendationService.EnrichCallForDispatchAsync(call, 1, true, cancellationToken); diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 888185325..211371657 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -18,6 +18,7 @@ using Resgrid.Model.Providers; using Resgrid.Model.Queue; using Resgrid.Model.Services; +using Resgrid.Services; using Resgrid.Web.Services.Models; using Resgrid.Web.Services.Twilio; using Twilio.AspNet.Common; @@ -107,6 +108,13 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN private static readonly TimeSpan TtsPromptBudget = TimeSpan.FromSeconds(6); private CancellationTokenSource _ttsBudgetCts; + // Run card enrichment on the text-to-call path runs bulk status/location queries and, + // when routed ETA is enabled, external routing calls per requirement — unbounded, that + // can outlast the same 15-second webhook limit and take the dispatch down with it, + // since the broadcast is enqueued after it. Recommendations are an enhancement to the + // dispatch, so they get a slice of the budget and are dropped if they overrun. + private static readonly TimeSpan RunCardEnrichmentBudget = TimeSpan.FromSeconds(5); + private CancellationToken GetTtsPromptBudgetToken() { if (_ttsBudgetCts == null) @@ -358,18 +366,29 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t { if (await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, savedCall.DepartmentId)) { - var recommendation = await _dispatchRecommendationService.EnrichCallForDispatchAsync(savedCall, 1, true); - - if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) + // Linked to RequestAborted so a caller that goes away also stops the + // work, and cancelled after the budget so a slow mapping provider + // cannot eat the webhook. The engine honours the token at its + // external-I/O boundaries, so this stops the calls rather than just + // abandoning the await. + using (var enrichmentCts = CancellationTokenSource.CreateLinkedTokenSource(HttpContext?.RequestAborted ?? CancellationToken.None)) { - savedCall = await _callsService.SaveCallAsync(savedCall); - await _dispatchRecommendationService.RecordActivationAsync(savedCall, recommendation, null); + enrichmentCts.CancelAfter(RunCardEnrichmentBudget); + + var recommendation = await _dispatchRecommendationService.EnrichCallForDispatchAsync(savedCall, 1, true, enrichmentCts.Token); + + if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) + { + savedCall = await _callsService.SaveCallAsync(savedCall, enrichmentCts.Token); + await _dispatchRecommendationService.RecordActivationAsync(savedCall, recommendation, null, enrichmentCts.Token); + } } } } catch (Exception ex) { - // A recommendation failure must never block the text-to-call dispatch itself. + // A recommendation failure — including overrunning the budget above — + // must never block the text-to-call dispatch itself. Logging.LogException(ex); } @@ -752,6 +771,16 @@ public async Task VoiceCall(string userId, int callId, [FromQuery] return CreateVoiceContentResult(response); } + // Load the department's custom priority so GetPriorityText() speaks its real + // name. The broadcast worker loads it the same way before pre-warming the + // dispatch TTS — the prompt text (and therefore the TTS cache key) must match. + try + { + if (call.CallPriority == null) + call.CallPriority = await _callsService.GetCallPrioritiesByIdAsync(call.DepartmentId, call.Priority, false); + } + catch { /* enum fallback text still works */ } + // For outbound calls, allow a brief pause for the audio bridge to // stabilize after the callee answers before attempting playback. response.Pause(length: 1); @@ -1488,49 +1517,19 @@ private async Task GetDepartmentTtsLanguageAsync(int? departmentId) } } + // Address resolution and prompt building are shared with the call broadcast + // worker (which pre-warms the dispatch TTS audio during ring time) via + // DispatchVoicePromptBuilder — both sides must produce identical text so the + // pre-warmed audio's cache key matches what this webhook requests. private async Task ResolveCallAddressAsync(Call call) { - 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 it runs inside a Twilio webhook 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), HttpContext?.RequestAborted ?? CancellationToken.None); - } - catch - { - } - } - - return String.IsNullOrWhiteSpace(address) ? call.Address : address; + return await DispatchVoicePromptBuilder.ResolveDispatchAddressAsync(call, _geoLocationProvider, + HttpContext?.RequestAborted ?? CancellationToken.None); } private 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}."; + return DispatchVoicePromptBuilder.BuildDispatchPrompt(call, address); } private static ContentResult CreateVoiceContentResult(VoiceResponse response) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 5927fc4e3..4ebcb9aef 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -1356,7 +1356,7 @@ public async Task> UpdateSchedul [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [Authorize(Policy = ResgridResources.Call_Update)] - public async Task EscalateCall(string callId, CancellationToken cancellationToken) + public async Task> EscalateCall(string callId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(callId) || !int.TryParse(callId, out var parsedCallId)) return BadRequest(); @@ -1386,7 +1386,21 @@ public async Task EscalateCall(string callId, CancellationToken c var escalationResult = await _dispatchRecommendationService.EnrichCallForDispatchAsync(call, previousAlarmLevel + 1, false, cancellationToken); if (!escalationResult.MatchedRunCardId.HasValue || !escalationResult.HasRecommendations) - return Ok(new { success = false, newAlarmLevel = previousAlarmLevel, addedUnits = 0, addedPersonnel = 0 }); + { + var noopResult = new EscalateCallResult + { + Id = parsedCallId.ToString(), + Success = false, + NewAlarmLevel = previousAlarmLevel, + AddedUnits = 0, + AddedPersonnel = 0, + PageSize = 0, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(noopResult); + + return Ok(noopResult); + } var newUnitIds = escalationResult.Units.Select(u => u.UnitId).ToList(); var newUserIds = escalationResult.Personnel.Select(p => p.UserId).ToList(); @@ -1422,7 +1436,19 @@ public async Task EscalateCall(string callId, CancellationToken c _eventAggregator.SendMessage(new CallUpdatedEvent() { DepartmentId = DepartmentId, Call = escalatedCall }); - return Ok(new { success = true, newAlarmLevel = escalatedCall.AlarmLevel, addedUnits = newUnitIds.Count, addedPersonnel = newUserIds.Count }); + var result = new EscalateCallResult + { + Id = escalatedCall.CallId.ToString(), + Success = true, + NewAlarmLevel = escalatedCall.AlarmLevel, + AddedUnits = newUnitIds.Count, + AddedPersonnel = newUserIds.Count, + PageSize = 0, + Status = ResponseHelper.Updated + }; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); } /// diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs index be4f51b7f..1ac25adf7 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RunCardsController.cs @@ -10,6 +10,7 @@ using Resgrid.Model.Services; using Resgrid.Providers.Claims; using Resgrid.Web.Services.Controllers.Version3; +using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.RunCards; using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; @@ -44,14 +45,30 @@ public RunCardsController(IRunCardsService runCardsService, IDispatchRecommendat [HttpGet("GetAllRunCards")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Call_View)] - public async Task>> GetAllRunCards() + public async Task> GetAllRunCards() { if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) return NotFound(); + var result = new RunCardsResult(); + var cards = await _runCardsService.GetAllRunCardsForDepartmentAsync(DepartmentId); - return Ok(cards.Select(ConvertRunCardData).ToList()); + if (cards != null && cards.Any()) + { + result.Data = cards.Select(ConvertRunCardData).ToList(); + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + } + else + { + result.PageSize = 0; + result.Status = ResponseHelper.NotFound; + } + + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); } /// @@ -60,7 +77,7 @@ public async Task>> GetAllRunCards() [HttpGet("GetRunCard")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Call_View)] - public async Task> GetRunCard(int runCardId) + public async Task> GetRunCard(int runCardId) { if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) return NotFound(); @@ -70,7 +87,16 @@ public async Task> GetRunCard(int runCardId) if (card == null || card.DepartmentId != DepartmentId) return NotFound(); - return Ok(ConvertRunCardData(card)); + var result = new RunCardResult + { + Data = ConvertRunCardData(card), + PageSize = 1, + Status = ResponseHelper.Success + }; + + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); } /// @@ -81,12 +107,18 @@ public async Task> GetRunCard(int runCardId) [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [Authorize(Policy = ResgridResources.Department_Update)] - public async Task SaveRunCard([FromBody] RunCardData input, CancellationToken cancellationToken) + public async Task> SaveRunCard([FromBody] RunCardData input, CancellationToken cancellationToken) { if (input == null || string.IsNullOrWhiteSpace(input.Name) || input.Triggers == null || !input.Triggers.Any() || input.AlarmLevels == null || !input.AlarmLevels.Any()) return BadRequest(); + // Alarm levels are 1-based and unique per card; reject here so the caller gets a + // 400 rather than a unique-index violation surfacing as a 500. + if (input.AlarmLevels.Any(l => l.AlarmLevel < 1) + || input.AlarmLevels.GroupBy(l => l.AlarmLevel).Any(g => g.Count() > 1)) + return BadRequest(); + if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) return NotFound(); @@ -169,7 +201,16 @@ public async Task SaveRunCard([FromBody] RunCardData input, Cancel var saved = await _runCardsService.SaveRunCardAsync(card, cancellationToken); - return Ok(new { runCardId = saved.RunCardId }); + var result = new SaveRunCardResult + { + Id = saved.RunCardId.ToString(), + PageSize = 0, + Status = input.RunCardId > 0 ? ResponseHelper.Updated : ResponseHelper.Created + }; + + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); } /// @@ -178,7 +219,7 @@ public async Task SaveRunCard([FromBody] RunCardData input, Cancel [HttpDelete("DeleteRunCard")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Department_Update)] - public async Task DeleteRunCard(int runCardId, CancellationToken cancellationToken) + public async Task> DeleteRunCard(int runCardId, CancellationToken cancellationToken) { if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); @@ -190,7 +231,16 @@ public async Task DeleteRunCard(int runCardId, CancellationToken c await _runCardsService.DeleteRunCardAsync(runCardId, cancellationToken); - return Ok(); + var result = new SaveRunCardResult + { + Id = runCardId.ToString(), + PageSize = 0, + Status = ResponseHelper.Deleted + }; + + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); } /// @@ -200,12 +250,12 @@ public async Task DeleteRunCard(int runCardId, CancellationToken c [HttpGet("GetRecommendation")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = ResgridResources.Call_View)] - public async Task> GetRecommendation(int priority, string type, double? latitude, double? longitude, int alarmLevel = 1) + public async Task> GetRecommendation(int priority, string type, double? latitude, double? longitude, int alarmLevel = 1) { if (!await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, DepartmentId)) return NotFound(); - var result = await _dispatchRecommendationService.GetRecommendationAsync(new DispatchRecommendationRequest + var recommendation = await _dispatchRecommendationService.GetRecommendationAsync(new DispatchRecommendationRequest { DepartmentId = DepartmentId, Priority = priority, @@ -215,6 +265,17 @@ public async Task> GetRecommendation( TargetAlarmLevel = alarmLevel }); + var result = new RunCardRecommendationResult + { + Data = recommendation, + PageSize = 1, + // No matching card is a valid answer, not a failure: the caller falls back to + // the manual dispatch flow. + Status = recommendation.MatchedRunCardId.HasValue ? ResponseHelper.Success : ResponseHelper.NotFound + }; + + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); } diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/EscalateCallResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/EscalateCallResult.cs new file mode 100644 index 000000000..b1661b5b3 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/EscalateCallResult.cs @@ -0,0 +1,23 @@ +namespace Resgrid.Web.Services.Models.v4.Calls +{ + /// + /// Outcome of a "Strike Next Alarm" escalation. + /// + public class EscalateCallResult : StandardApiResponseV4Base + { + /// Call identifier + public string Id { get; set; } + + /// False when no run card matched or the next alarm level adds nothing + public bool Success { get; set; } + + /// Alarm level after the escalation; unchanged when Success is false + public int NewAlarmLevel { get; set; } + + /// Units added by this escalation + public int AddedUnits { get; set; } + + /// Personnel added by this escalation + public int AddedPersonnel { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardResults.cs b/Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardResults.cs new file mode 100644 index 000000000..7cc6fa0c8 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/RunCards/RunCardResults.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Services.Models.v4.RunCards +{ + /// + /// All run cards for the department. + /// + public class RunCardsResult : StandardApiResponseV4Base + { + /// + /// Response Data + /// + public List Data { get; set; } = new List(); + } + + /// + /// A single run card. + /// + public class RunCardResult : StandardApiResponseV4Base + { + /// + /// Response Data + /// + public RunCardData Data { get; set; } + } + + /// + /// Identifier of the saved run card. + /// + public class SaveRunCardResult : StandardApiResponseV4Base + { + /// + /// Run card identifier + /// + public string Id { get; set; } + } + + /// + /// Preview of what the department's run cards would dispatch for a prospective call. + /// + public class RunCardRecommendationResult : StandardApiResponseV4Base + { + /// + /// Response Data + /// + public DispatchRecommendationResult Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index c3fc8d6a0..2f9c3b253 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -27,6 +27,14 @@ The cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ + + False for calls raised through a group's dispatch address. That path deliberately + builds its dispatch list from one group's units and members, and the recommendation + engine scores against the whole department, so enriching there would pull in other + stations and break the scoping the caller asked for by emailing that address. + + Validates the inbound SignalWire webhook signature. SignalWire's Compatibility API uses @@ -6618,6 +6626,26 @@ User Defined Field values for this call + + + Outcome of a "Strike Next Alarm" escalation. + + + + Call identifier + + + False when no run card matched or the next alarm level adds nothing + + + Alarm level after the escalation; unchanged when Success is false + + + Units added by this escalation + + + Personnel added by this escalation + Gets the calls current active, been dispatched and not closed or deleted @@ -11239,6 +11267,46 @@ Built-in state value or CustomStateDetailId + + + All run cards for the department. + + + + + Response Data + + + + + A single run card. + + + + + Response Data + + + + + Identifier of the saved run card. + + + + + Run card identifier + + + + + Preview of what the department's run cards would dispatch for a prospective call. + + + + + Response Data + + Response Data diff --git a/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs b/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs index 09f5d6c96..8f991613f 100644 --- a/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs +++ b/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs @@ -116,111 +116,12 @@ private async Task> CreatePlayVerbsAsync(string text, } } - private IEnumerable ChunkText(string text) + // Chunking lives in DispatchVoicePromptBuilder so the call broadcast worker + // pre-warms exactly the chunks this service will request — the TTS cache key + // is a hash of the exact chunk text. + private 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(); - } + return global::Resgrid.Services.DispatchVoicePromptBuilder.ChunkText(text); } private static Play CreatePlay(Uri url) diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs index b8d679360..4ce0aec50 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsService.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -12,6 +12,7 @@ public sealed class TtsService : ITtsService private readonly IAudioProcessingService _audioProcessingService; private readonly TtsOptions _options; private readonly ILogger _logger; + private readonly IHostApplicationLifetime? _applicationLifetime; private readonly SemaphoreSlim _generationSemaphore; private readonly ConcurrentDictionary _generationLocks = new(StringComparer.Ordinal); @@ -19,12 +20,14 @@ public TtsService( ICacheService cacheService, IAudioProcessingService audioProcessingService, IOptions options, - ILogger logger) + ILogger logger, + IHostApplicationLifetime? applicationLifetime = null) { _cacheService = cacheService; _audioProcessingService = audioProcessingService; _options = options.Value; _logger = logger; + _applicationLifetime = applicationLifetime; _generationSemaphore = new SemaphoreSlim(_options.MaxConcurrentGenerations, _options.MaxConcurrentGenerations); } @@ -155,9 +158,18 @@ private async Task GenerateInternalAsync(NormalizedTtsRequest reque return CreateResponse(cacheKey, request, cachedUrl, cached: true); } + // Generation and storage run on the application-lifetime token, NOT the + // caller's. A caller that gives up (HTTP client timeout, aborted Twilio + // webhook) must not kill an in-flight Piper run — cold generation can + // exceed short client timeouts, and cancelling here meant every retry + // restarted synthesis from scratch and the cache never filled. Letting + // it finish means the caller's retry (or the next caller of the same + // text) gets an instant cache hit. + var generationToken = _applicationLifetime?.ApplicationStopping ?? CancellationToken.None; + var generationTimer = Stopwatch.StartNew(); - var audioBytes = await _audioProcessingService.GenerateNormalizedWavAsync(request.Text, request.Voice, request.Speed, cancellationToken); - var objectUrl = await _cacheService.StoreAsync(cacheKey, audioBytes, cancellationToken); + var audioBytes = await _audioProcessingService.GenerateNormalizedWavAsync(request.Text, request.Voice, request.Speed, generationToken); + var objectUrl = await _cacheService.StoreAsync(cacheKey, audioBytes, generationToken); generationTimer.Stop(); _logger.LogInformation("Generated audio for {Hash} in {ElapsedMilliseconds} ms", cacheKey.Hash, generationTimer.ElapsedMilliseconds); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index a9d44e08e..90389dda2 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -1925,6 +1925,20 @@ public async Task SaveStationCoverageRequirement([FromForm] int s if ((!unitTypeId.HasValue && !personnelRoleId.HasValue) || (unitTypeId.HasValue && personnelRoleId.HasValue)) return Json(new { success = false, message = "Select a unit type or a personnel role (not both)." }); + if (unitTypeId.HasValue) + { + var unitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId); + if (unitTypes == null || unitTypes.All(t => t.UnitTypeId != unitTypeId.Value)) + return Json(new { success = false, message = "Invalid unit type." }); + } + + if (personnelRoleId.HasValue) + { + var roles = await _personnelRolesService.GetRolesForDepartmentAsync(DepartmentId); + if (roles == null || roles.All(r => r.PersonnelRoleId != personnelRoleId.Value)) + return Json(new { success = false, message = "Invalid personnel role." }); + } + if (minimumAvailableCount < 1) return Json(new { success = false, message = "Minimum available count must be at least 1." }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 47a227627..1fc784c79 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -603,6 +603,7 @@ public async Task GetDispatchRecommendation(int priority, string /// added resources via selective broadcast. /// [HttpPost] + [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Call_Update)] public async Task EscalateCall([FromForm] int callId, CancellationToken cancellationToken) { @@ -614,6 +615,13 @@ public async Task EscalateCall([FromForm] int callId, Cancellatio if (call == null || call.DepartmentId != DepartmentId) return Json(new { success = false, message = "Call not found." }); + // The Call_Update claim and the department check above are not enough on their + // own: escalating dispatches units and notifies personnel, so it takes the same + // per-call authority as editing the call (department admin, or the call's + // reporting user), matching UpdateCall and the v4 EscalateCall endpoint. + if (!await _authorizationService.CanUserEditCallAsync(UserId, callId)) + return Unauthorized(); + if (call.State != (int)CallStates.Active) return Json(new { success = false, message = "Only active calls can be escalated." }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs index b6e5dc1ad..207af4dfc 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RunCardsController.cs @@ -95,6 +95,7 @@ public async Task Edit(int runCardId) } [HttpPost] + [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Department_Update)] public async Task Save([FromBody] RunCardEditInput input, CancellationToken cancellationToken) { @@ -116,6 +117,12 @@ public async Task Save([FromBody] RunCardEditInput input, Cancell if (input.AlarmLevels == null || !input.AlarmLevels.Any()) return Json(new { success = false, message = "A run card needs at least one alarm level." }); + if (input.AlarmLevels.Any(l => l.AlarmLevel < 1)) + return Json(new { success = false, message = "Run card alarm levels start at 1." }); + + if (input.AlarmLevels.GroupBy(l => l.AlarmLevel).Any(g => g.Count() > 1)) + return Json(new { success = false, message = "A run card cannot define the same alarm level twice." }); + RunCard card; if (input.RunCardId > 0) { @@ -196,6 +203,7 @@ public async Task Save([FromBody] RunCardEditInput input, Cancell } [HttpPost] + [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Department_Update)] public async Task Delete([FromForm] int runCardId, CancellationToken cancellationToken) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index ab2c814e6..709286d89 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -46,6 +46,7 @@ if (Model.Call.State == 0 && Model.Call.ActiveRunCardId.HasValue) { +
@Html.AntiForgeryToken()
} @localizer["UpdateCallHeader"] @localizer["CloseCallHeader"] @@ -55,29 +56,6 @@
-@if (Model.Call.ActiveRunCardId.HasValue) -{ - -} -
@@ -978,6 +956,32 @@ @section Scripts { + @if (Model.Call.ActiveRunCardId.HasValue) + { + + } + " + // from breaking out of the block. + var jsonSettings = new JsonSerializerSettings { StringEscapeHandling = StringEscapeHandling.EscapeHtml }; + var cardJson = JsonConvert.SerializeObject(new { runCardId = Model.RunCard.RunCardId, @@ -47,7 +53,7 @@ isCustomState = s.IsCustomState, stateId = s.StateId }) - }); + }, jsonSettings); var lookupsJson = JsonConvert.SerializeObject(new { @@ -59,7 +65,7 @@ unitStatusOptions = Model.UnitStatusOptions.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value.Select(o => new { stateId = o.StateId, isCustomState = o.IsCustomState, text = o.Text })), personnelStatusOptions = Model.PersonnelStatusOptions.Select(o => new { stateId = o.StateId, isCustomState = o.IsCustomState, text = o.Text }), staffingOptions = Model.StaffingOptions.Select(o => new { stateId = o.StateId, isCustomState = o.IsCustomState, text = o.Text }) - }); + }, jsonSettings); }
@@ -210,6 +216,7 @@
+
@Html.AntiForgeryToken()
@commonLocalizer["Cancel"]
@@ -462,6 +469,9 @@ url: '@Url.Action("Save", "RunCards", new { area = "User" })', type: 'POST', contentType: 'application/json', + // JSON bodies cannot carry the token as a field, so it travels in the + // header the antiforgery options look for (see PerformCheckIn). + headers: { 'RequestVerificationToken': $('#saveCardForm input[name="__RequestVerificationToken"]').val() }, data: JSON.stringify(payload), success: function (response) { if (response.success) { diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js index 9a72b1e90..7005f93bf 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js @@ -202,6 +202,10 @@ var resgrid; }); personnelTable.on('draw', function() { $('#personnelGrid thead th:first').html(''); + + // The rows are new DOM on every draw, so any run card recommendation has + // to be re-applied here rather than only when the response arrives. + applyRecommendationSelections(); }); var groupsTable = $("#groupsGrid").DataTable({ @@ -506,17 +510,59 @@ var resgrid; var pascal = name.charAt(0).toUpperCase() + name.slice(1); return obj[pascal]; } + + // Ids the current recommendation ticked, so a later one can untick exactly those + // and leave the dispatcher's own selections alone. The sequence number lets a + // slow earlier response be discarded instead of overwriting a newer one. + var recommendationSequence = 0; + var recommendedUnitIds = []; + var recommendedUserIds = []; + + function clearRecommendationSelections() { + recommendedUnitIds.forEach(function (id) { + $('input[name="dispatchUnit_' + id + '"]').prop('checked', false); + }); + recommendedUserIds.forEach(function (id) { + $('input[name="dispatchUser_' + id + '"]').prop('checked', false); + }); + + recommendedUnitIds = []; + recommendedUserIds = []; + } + + // Personnel checkboxes are rendered by the DataTable, so they may not exist when + // the recommendation lands and are rebuilt unchecked on every redraw. This is + // called both on response and from the grid's draw handler. + function applyRecommendationSelections() { + recommendedUnitIds.forEach(function (id) { + $('input[name="dispatchUnit_' + id + '"]').prop('checked', true); + }); + recommendedUserIds.forEach(function (id) { + $('input[name="dispatchUser_' + id + '"]').prop('checked', true); + }); + } + newcall.applyRecommendationSelections = applyRecommendationSelections; + function checkForRecommendations() { var callPriorityVal = $('#CallPriority').val(); var callTypeVal = $('#Call_Type').val(); var lat = $('#Latitude').val(); var lon = $('#Longitude').val(); + var requestSequence = ++recommendationSequence; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Dispatch/GetDispatchRecommendation', data: { priority: callPriorityVal, type: callTypeVal, latitude: lat || null, longitude: lon || null }, type: 'GET' }).done(function (response) { + if (requestSequence !== recommendationSequence) { + return; + } + + // Drop the previous recommendation's ticks before deciding what this one + // shows, so no-match and auto-dispatch responses clear them too. + clearRecommendationSelections(); + var panel = $('#runCardPanel'); var row = $('#runCardPanelRow'); var result = prop(response, 'result'); @@ -563,12 +609,10 @@ var resgrid; // Pre-check recommended resources when NOT auto-dispatching (dispatcher // reviews and can uncheck; the normal form post picks these up). if (!autoDispatch) { - units.forEach(function (u) { - $('input[name="dispatchUnit_' + prop(u, 'unitId') + '"]').prop('checked', true); - }); - personnel.forEach(function (p) { - $('input[name="dispatchUser_' + prop(p, 'userId') + '"]').prop('checked', true); - }); + recommendedUnitIds = units.map(function (u) { return prop(u, 'unitId'); }); + recommendedUserIds = personnel.map(function (p) { return prop(p, 'userId'); }); + + applyRecommendationSelections(); } }); } diff --git a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs index cd9da8075..57ff984c6 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs @@ -34,6 +34,8 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP var callsService = Bootstrapper.GetKernel().Resolve(); var queueService = Bootstrapper.GetKernel().Resolve(); var callDispatchStatusService = Bootstrapper.GetKernel().Resolve(); + var featureToggleService = Bootstrapper.GetKernel().Resolve(); + var dispatchRecommendationService = Bootstrapper.GetKernel().Resolve(); var pendingCalls = await callsService.GetAllNonDispatchedScheduledCallsWithinDateRange(DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(5)); @@ -41,23 +43,24 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP { foreach (var call in pendingCalls) { + // PopulateCallData hydrates and returns the same instance, so this is the + // one object carried through enrichment, broadcast and the single save. var populatedCall = await callsService.PopulateCallData(call, true, false, false, true, true, true, true, false, false); - // Run card auto-dispatch: scheduled calls are enriched at dispatch - // time, when the call's location and resource picture are final. + // Run card auto-dispatch: scheduled calls are enriched at dispatch time, + // when the call's location and resource picture are final. Enrichment only + // mutates the in-memory graph here — persisting it is left to the single + // save below, because every save of a call fans out events and workflow + // triggers and the call must not be written twice per dispatch. + Resgrid.Model.DispatchRecommendationResult recommendation = null; try { - var featureToggleService = Bootstrapper.GetKernel().Resolve(); if (await featureToggleService.IsEnabledAsync(Resgrid.Model.FeatureFlagKeys.DispatchRunCards, populatedCall.DepartmentId)) { - var dispatchRecommendationService = Bootstrapper.GetKernel().Resolve(); - var recommendation = await dispatchRecommendationService.EnrichCallForDispatchAsync(populatedCall, 1, true, cancellationToken); + var enriched = await dispatchRecommendationService.EnrichCallForDispatchAsync(populatedCall, 1, true, cancellationToken); - if (recommendation.MatchedRunCardId.HasValue && recommendation.AutoDispatch && recommendation.HasRecommendations) - { - populatedCall = await callsService.SaveCallAsync(populatedCall, cancellationToken); - await dispatchRecommendationService.RecordActivationAsync(populatedCall, recommendation, null, cancellationToken); - } + if (enriched.MatchedRunCardId.HasValue && enriched.AutoDispatch && enriched.HasRecommendations) + recommendation = enriched; } } catch (Exception recEx) @@ -76,8 +79,27 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP if (result) { - call.HasBeenDispatched = true; - await callsService.SaveCallAsync(call); + // One write, covering both the dispatched flag and anything the run + // card added. If the broadcast failed we leave the call untouched so + // the next poll retries it cleanly. + populatedCall.HasBeenDispatched = true; + await callsService.SaveCallAsync(populatedCall, cancellationToken); + + if (recommendation != null) + { + try + { + // Recorded only once the dispatch actually went out, so the audit + // trail and its workflow event cannot describe a call that never + // broadcast. + await dispatchRecommendationService.RecordActivationAsync(populatedCall, recommendation, null, cancellationToken); + } + catch (Exception recEx) + { + Resgrid.Framework.Logging.LogException(recEx); + } + } + await callDispatchStatusService.ApplyDispatchStatusesAsync(cqi.Call, cancellationToken: cancellationToken); } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs index 4bf35541b..637199154 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs @@ -73,6 +73,8 @@ public static async Task ProcessCallQueueItem(CallQueueItem cqi) var department = await _departmentsService.GetDepartmentByIdAsync(cqi.Call.DepartmentId); cqi.Call.Department = department; + StartDispatchVoicePreWarm(cqi); + // Dispatch Personnel if (cqi.Call.Dispatches != null && cqi.Call.Dispatches.Any()) { @@ -329,5 +331,60 @@ await _communicationService.SendCallAsync(cqi.Call, return true; } + + /// + /// Kicks off TTS generation for the dispatch voice prompt in the background, + /// in parallel with placing the outbound calls. Cold generation (Piper model + /// load + synthesis + normalization) takes longer than the voice webhook's + /// per-request budget, so without this every recipient answering during the + /// cold window hears "please wait" loops. Ring time (typically 10-30s) absorbs + /// the generation, so by the time anyone answers the audio URL is a cache hit. + /// Fire-and-forget: dialing must never wait on audio generation. + /// + private static void StartDispatchVoicePreWarm(CallQueueItem cqi) + { + try + { + // A recorded dispatch-audio attachment is played instead of TTS. + if (cqi.CallDispatchAttachmentId > 0) + return; + + // No recipient takes dispatches by phone; don't generate audio nobody will hear. + if (cqi.Profiles == null || !cqi.Profiles.Any(x => x.VoiceForCall)) + return; + + var call = cqi.Call; + + _ = Task.Run(async () => + { + try + { + var ttsAudioService = Bootstrapper.GetKernel().Resolve(); + var geoLocationProvider = Bootstrapper.GetKernel().Resolve(); + var departmentSettingsService = Bootstrapper.GetKernel().Resolve(); + + // Text and chunking must match the Twilio voice webhook exactly — + // the TTS cache key is a hash of the chunk text. CallPriority was + // already populated above, mirroring the webhook's own load. + var address = await Resgrid.Services.DispatchVoicePromptBuilder.ResolveDispatchAddressAsync(call, geoLocationProvider); + var ttsLanguage = await departmentSettingsService.GetTtsLanguageForDepartmentAsync(call.DepartmentId); + var dispatchText = Resgrid.Services.DispatchVoicePromptBuilder.BuildDispatchPrompt(call, address); + + foreach (var chunk in Resgrid.Services.DispatchVoicePromptBuilder.ChunkText(dispatchText)) + { + await ttsAudioService.GenerateSpeechUrlAsync(chunk, ttsLanguage); + } + } + catch (Exception ex) + { + Logging.LogException(ex); + } + }); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } } } From 3433e7f3db6b1cf78f675c397a613da49a927805 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 14 Aug 2026 08:04:56 -0700 Subject: [PATCH 3/3] RG-T54 PR#463 bug fixes --- Core/Resgrid.Config/TtsConfig.cs | 7 +++++++ .../Configuration/ServiceCollectionExtensions.cs | 1 + Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs | 10 ++++++++++ Web/Resgrid.Web.Tts/Services/TtsService.cs | 8 +++++++- .../app/internal/dispatch/resgrid.dispatch.newcall.js | 11 +++++++++++ 5 files changed, 36 insertions(+), 1 deletion(-) 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/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs index 4dfb9841b..18fd58c84 100644 --- a/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ private static void ApplyTtsOptions(TtsOptions options) options.DefaultSpeed = TtsConfig.DefaultSpeed; options.MaxConcurrentGenerations = TtsConfig.MaxConcurrentGenerations; options.MaxTextLength = TtsConfig.MaxTextLength; + options.GenerationTimeoutSeconds = TtsConfig.GenerationTimeoutSeconds; options.PiperExecutable = string.IsNullOrWhiteSpace(TtsConfig.PiperExecutable) ? options.PiperExecutable : TtsConfig.PiperExecutable; options.PiperModelDirectory = string.IsNullOrWhiteSpace(TtsConfig.PiperModelDirectory) ? options.PiperModelDirectory : TtsConfig.PiperModelDirectory; options.FfmpegExecutable = string.IsNullOrWhiteSpace(TtsConfig.FfmpegExecutable) ? options.FfmpegExecutable : TtsConfig.FfmpegExecutable; diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs index c830286ab..4360d8525 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -16,6 +16,16 @@ public sealed class TtsOptions [Range(1, 10000)] public int MaxTextLength { get; set; } = 1000; + /// + /// Upper bound, in seconds, on a single synthesis + storage run. Generation is + /// deliberately decoupled from the caller's token, so this is the only thing that + /// stops a wedged Piper or ffmpeg process from holding a generation slot until the + /// application shuts down. Must stay well above a cold model load (first request for + /// a voice pays the model-file read) plus the S3 upload. + /// + [Range(1, 3600)] + public int GenerationTimeoutSeconds { get; set; } = 300; + [Required] public string PiperExecutable { get; set; } = "piper"; diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs index 4ce0aec50..7ddd5dc06 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsService.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -165,7 +165,13 @@ private async Task GenerateInternalAsync(NormalizedTtsRequest reque // restarted synthesis from scratch and the cache never filled. Letting // it finish means the caller's retry (or the next caller of the same // text) gets an instant cache hit. - var generationToken = _applicationLifetime?.ApplicationStopping ?? CancellationToken.None; + // A server-side timeout still bounds the run so a wedged Piper or ffmpeg + // cannot hold a generation slot until shutdown. It is sized above a cold + // model load, and application shutdown still cancels immediately. + using var generationCts = CancellationTokenSource.CreateLinkedTokenSource( + _applicationLifetime?.ApplicationStopping ?? CancellationToken.None); + generationCts.CancelAfter(TimeSpan.FromSeconds(_options.GenerationTimeoutSeconds)); + var generationToken = generationCts.Token; var generationTimer = Stopwatch.StartNew(); var audioBytes = await _audioProcessingService.GenerateNormalizedWavAsync(request.Text, request.Voice, request.Speed, generationToken); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js index 7005f93bf..8fc746b6b 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js @@ -614,6 +614,17 @@ var resgrid; applyRecommendationSelections(); } + }).fail(function () { + if (requestSequence !== recommendationSequence) { + return; + } + + // A failed lookup leaves no recommendation to show, so drop the previous + // one's ticks and panel instead of letting stale resources ride along on + // the save. + clearRecommendationSelections(); + $('#runCardPanel').empty(); + $('#runCardPanelRow').hide(); }); } newcall.checkForRecommendations = checkForRecommendations;