From 4ea27eb7dc325930727cd592693b281a38d7c1e8 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 13 Nov 2026 11:55:36 -0800 Subject: [PATCH 1/4] RG-T152 Mapping Updates, POI Updates, Run Card Fixes, Admin Assist Fixes --- Core/Resgrid.AdminAssist/Catalog/calls.yaml | 102 ++- .../Resgrid.AdminAssist/Catalog/security.yaml | 2 +- .../User/AdminAssist/AdminAssist.ar.resx | 25 +- .../User/AdminAssist/AdminAssist.de.resx | 25 +- .../User/AdminAssist/AdminAssist.el.resx | 25 +- .../User/AdminAssist/AdminAssist.en.resx | 27 +- .../User/AdminAssist/AdminAssist.es.resx | 25 +- .../User/AdminAssist/AdminAssist.fr.resx | 25 +- .../User/AdminAssist/AdminAssist.it.resx | 25 +- .../User/AdminAssist/AdminAssist.pl.resx | 25 +- .../Areas/User/AdminAssist/AdminAssist.resx | 27 +- .../User/AdminAssist/AdminAssist.sv.resx | 25 +- .../User/AdminAssist/AdminAssist.uk.resx | 25 +- .../Areas/User/Department/Department.ar.resx | 47 +- .../Areas/User/Department/Department.de.resx | 47 +- .../Areas/User/Department/Department.el.resx | 47 +- .../Areas/User/Department/Department.en.resx | 47 +- .../Areas/User/Department/Department.es.resx | 47 +- .../Areas/User/Department/Department.fr.resx | 47 +- .../Areas/User/Department/Department.it.resx | 47 +- .../Areas/User/Department/Department.pl.resx | 47 +- .../Areas/User/Department/Department.resx | 47 +- .../Areas/User/Department/Department.sv.resx | 47 +- .../Areas/User/Department/Department.uk.resx | 47 +- .../Areas/User/Dispatch/Call.ar.resx | 24 + .../Areas/User/Dispatch/Call.de.resx | 24 + .../Areas/User/Dispatch/Call.el.resx | 24 + .../Areas/User/Dispatch/Call.en.resx | 24 + .../Areas/User/Dispatch/Call.es.resx | 24 + .../Areas/User/Dispatch/Call.fr.resx | 24 + .../Areas/User/Dispatch/Call.it.resx | 24 + .../Areas/User/Dispatch/Call.pl.resx | 24 + .../Areas/User/Dispatch/Call.sv.resx | 24 + .../Areas/User/Dispatch/Call.uk.resx | 24 + .../Areas/User/Mapping/EventLayers.ar.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.cs | 10 + .../Areas/User/Mapping/EventLayers.de.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.el.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.en.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.es.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.fr.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.it.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.pl.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.sv.resx | 218 +++++ .../Areas/User/Mapping/EventLayers.uk.resx | 218 +++++ .../Areas/User/Mapping/Mapping.ar.resx | 132 +++ .../Areas/User/Mapping/Mapping.de.resx | 132 +++ .../Areas/User/Mapping/Mapping.el.resx | 132 +++ .../Areas/User/Mapping/Mapping.en.resx | 132 +++ .../Areas/User/Mapping/Mapping.es.resx | 132 +++ .../Areas/User/Mapping/Mapping.fr.resx | 132 +++ .../Areas/User/Mapping/Mapping.it.resx | 132 +++ .../Areas/User/Mapping/Mapping.pl.resx | 132 +++ .../Areas/User/Mapping/Mapping.sv.resx | 132 +++ .../Areas/User/Mapping/Mapping.uk.resx | 132 +++ .../Areas/User/Records/Records.ar.resx | 8 + .../Areas/User/Records/Records.de.resx | 8 + .../Areas/User/Records/Records.el.resx | 8 + .../Areas/User/Records/Records.en.resx | 8 + .../Areas/User/Records/Records.es.resx | 8 + .../Areas/User/Records/Records.fr.resx | 8 + .../Areas/User/Records/Records.it.resx | 8 + .../Areas/User/Records/Records.pl.resx | 8 + .../Areas/User/Records/Records.sv.resx | 8 + .../Areas/User/Records/Records.uk.resx | 8 + .../AdminAssist/AdminAssistWorkflowPayload.cs | 2 +- Core/Resgrid.Model/DepartmentSettingTypes.cs | 8 + .../Helpers/NewCallFieldPolicyValidator.cs | 6 + Core/Resgrid.Model/KmlImportResult.cs | 10 + Core/Resgrid.Model/MapLayer.cs | 68 ++ Core/Resgrid.Model/MapLayerEvents.cs | 53 ++ Core/Resgrid.Model/MapLayerFileReadResult.cs | 57 ++ Core/Resgrid.Model/NewCallFieldPolicy.cs | 132 ++- Core/Resgrid.Model/Poi.cs | 17 +- Core/Resgrid.Model/PoiAvailability.cs | 150 ++++ Core/Resgrid.Model/PoiCapability.cs | 95 +++ Core/Resgrid.Model/PoiImportFile.cs | 8 + .../Records/RecordsPreventionContracts.cs | 20 + .../IPoiCapabilitiesRepository.cs | 38 + .../Services/ICallStatusAttributionService.cs | 9 + .../Services/IDepartmentSettingsService.cs | 7 + .../Resgrid.Model/Services/IMappingService.cs | 48 +- .../Services/INearestDestinationService.cs | 53 ++ .../ITransportDestinationCheckService.cs | 22 + .../TransportDestinationRules.cs | 56 ++ Core/Resgrid.Model/UnitResponseOrigin.cs | 18 + .../AdminAssist/SettingsEvidenceSource.cs | 24 +- .../CallStatusAttributionService.cs | 26 + .../DepartmentSettingsService.cs | 20 + ...spatchRecommendationService.Conditional.cs | 9 +- .../DispatchRecommendationService.cs | 8 +- Core/Resgrid.Services/MappingService.cs | 267 +++++- .../NearestDestinationService.cs | 131 +++ Core/Resgrid.Services/NearestUnitService.cs | 20 +- .../Resgrid.Services/OfflineMapPackService.cs | 2 +- Core/Resgrid.Services/PoiImportFileReader.cs | 41 +- Core/Resgrid.Services/PoiImportService.cs | 71 +- .../Records/HydrantImportParser.cs | 225 ++++- .../Records/RecordsHydrantsService.cs | 44 +- Core/Resgrid.Services/ServicesModule.cs | 2 + .../TransportDestinationCheckService.cs | 122 +++ Core/Resgrid.Services/UnitsService.cs | 32 +- Docker/qa-departments/catalog/baseline.json | 5 +- Docker/qa-departments/seeder/build.py | 26 +- Docker/qa-departments/seeder/catalog.py | 2 +- Docker/qa-departments/seeder/cli.py | 2 +- Docker/qa-departments/seeder/verify.py | 2 +- .../Resgrid.Providers.Geo/KmlProvider.cs | 6 +- .../MapLayerFileReader.cs | 777 ++++++++++++++++++ ...M0315_AddPoiCapabilitiesAndAvailability.cs | 74 ++ ...315_AddPoiCapabilitiesAndAvailabilityPg.cs | 64 ++ .../Modules/ApiDataModule.cs | 2 + .../Modules/DataModule.cs | 2 + .../Modules/NonWebDataModule.cs | 2 + .../Modules/TestingDataModule.cs | 2 + .../PoiCapabilitiesRepository.cs | 130 +++ .../UnlinkedDepartmentCleanup.cs | 5 +- .../AdminAssist/ConfigurationRuleTests.cs | 1 + .../AdminAssist/SettingsEvidenceTests.cs | 107 +++ .../Models/MapLayerEventsTests.cs | 77 ++ .../Models/NewCallFieldPolicyTests.cs | 172 +++- .../Models/PostgreSqlMapLayerCodecTests.cs | 27 + .../Providers/MapLayerFileReaderTests.cs | 219 +++++ Tests/Resgrid.Tests/Rms/HydrantImportTests.cs | 296 +++++++ .../CallStatusAttributionServiceTests.cs | 49 ++ .../DispatchRecommendationConditionalTests.cs | 44 + .../DispatchRecommendationServiceTests.cs | 84 ++ .../Services/NearestUnitServiceTests.cs | 39 + .../PoiCapabilitiesAndAvailabilityTests.cs | 403 +++++++++ .../Services/PoiImportServiceTests.cs | 153 ++++ .../TransportDestinationCheckServiceTests.cs | 416 ++++++++++ .../CallsControllerDispatchListTests.cs | 168 +++- .../Web/User/PoiCapabilitiesMvcTests.cs | 612 ++++++++++++++ .../Web/User/PoiCapabilityPagesFormTests.cs | 224 +++++ .../Controllers/v4/CallsController.cs | 111 ++- .../Controllers/v4/ConfigController.cs | 10 + .../Controllers/v4/DispatchController.cs | 4 + .../Controllers/v4/MappingController.cs | 148 +++- .../v4/RecordHydrantsController.cs | 6 +- .../Helpers/DispatchListHelper.cs | 16 +- .../Models/v4/Calls/CallResult.cs | 5 + .../Models/v4/Calls/EditCallInput.cs | 11 +- .../Models/v4/Calls/NewCallInput.cs | 16 +- .../Models/v4/Configs/GetConfigResult.cs | 8 + .../Models/v4/Mapping/GetMapLayersResult.cs | 26 + .../Models/v4/Mapping/PoiResultModels.cs | 84 ++ .../Resgrid.Web.Services.xml | 166 +++- .../adminAssist/AdminAssistElement.tsx | 450 +++++++--- .../adminAssist/AreaSetupChoice.tsx | 43 +- .../src/components/adminAssist/AskPanel.tsx | 216 +++-- .../components/adminAssist/ImpactPreview.tsx | 38 +- .../components/adminAssist/ModuleViews.tsx | 119 ++- .../src/components/adminAssist/PlansPanel.tsx | 295 +++++-- .../components/adminAssist/SetupVisuals.tsx | 12 + .../components/adminAssist/SetupWizard.tsx | 124 ++- .../adminAssist/TroubleshootPanel.tsx | 199 +++-- .../components/adminAssist/adminAssist.css | 445 +++++++++- .../src/components/map/LeafletMapView.tsx | 43 +- .../Apps/src/components/map/MapboxMapView.tsx | 34 +- .../User/Apps/src/components/map/mapTypes.ts | 62 ++ .../User/Controllers/DepartmentController.cs | 4 + .../User/Controllers/DispatchController.cs | 175 +++- .../User/Controllers/EventLayersController.cs | 319 +++++++ .../User/Controllers/MappingController.cs | 329 +++++++- .../Areas/User/Models/Calls/NewCallView.cs | 9 + .../Areas/User/Models/Calls/UpdateCallView.cs | 9 + .../Departments/DispatchSettingsView.cs | 3 + .../Areas/User/Models/Mapping/AddPOIView.cs | 9 +- .../User/Models/Mapping/EventLayerView.cs | 62 ++ .../Areas/User/Models/Mapping/LayersView.cs | 3 + .../User/Models/Mapping/PoiCapabilityViews.cs | 113 +++ .../Areas/User/Models/Mapping/PoiJson.cs | 24 +- .../User/Views/AdminAssist/PrintReport.cshtml | 193 ++++- .../Views/Department/DispatchSettings.cshtml | 51 +- .../Views/Dispatch/AddArchivedCall.cshtml | 150 ++-- .../Areas/User/Views/Dispatch/NewCall.cshtml | 240 ++++-- .../User/Views/Dispatch/UpdateCall.cshtml | 184 +++-- .../User/Views/Dispatch/_AllCallOption.cshtml | 25 + .../User/Views/EventLayers/Details.cshtml | 91 ++ .../User/Views/EventLayers/Import.cshtml | 55 ++ .../EventLayers/_EventLayerFields.cshtml | 60 ++ .../Areas/User/Views/Mapping/AddPOI.cshtml | 27 + .../User/Views/Mapping/Destinations.cshtml | 89 ++ .../Areas/User/Views/Mapping/Index.cshtml | 6 +- .../Areas/User/Views/Mapping/Layers.cshtml | 52 +- .../Areas/User/Views/Mapping/POIs.cshtml | 2 + .../User/Views/Mapping/PoiCapabilities.cshtml | 113 +++ .../Views/Mapping/SetPoiAvailability.cshtml | 179 ++++ .../Areas/User/Views/Mapping/ViewType.cshtml | 17 +- .../Mapping/_PoiAvailabilityBadge.cshtml | 19 + .../User/Views/RecordHydrants/Import.cshtml | 26 +- .../Shared/_AdminAssistSetupPrompt.cshtml | 40 +- .../wwwroot/css/admin-assist-guidance.css | 33 +- .../wwwroot/css/admin-assist-print.css | 144 +++- .../dispatch/resgrid.dispatch.nearestboard.js | 9 +- .../dispatch/resgrid.dispatch.newcall.js | 5 +- .../mapping/resgrid.mapping.viewType.js | 65 +- docs/admin-assist/settings-reference.md | 21 +- 199 files changed, 15716 insertions(+), 991 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.cs create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.uk.resx create mode 100644 Core/Resgrid.Model/MapLayerEvents.cs create mode 100644 Core/Resgrid.Model/MapLayerFileReadResult.cs create mode 100644 Core/Resgrid.Model/PoiAvailability.cs create mode 100644 Core/Resgrid.Model/PoiCapability.cs create mode 100644 Core/Resgrid.Model/Repositories/IPoiCapabilitiesRepository.cs create mode 100644 Core/Resgrid.Model/Services/INearestDestinationService.cs create mode 100644 Core/Resgrid.Model/Services/ITransportDestinationCheckService.cs create mode 100644 Core/Resgrid.Model/TransportDestinationRules.cs create mode 100644 Core/Resgrid.Services/NearestDestinationService.cs create mode 100644 Core/Resgrid.Services/TransportDestinationCheckService.cs create mode 100644 Providers/Resgrid.Providers.Geo/MapLayerFileReader.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0315_AddPoiCapabilitiesAndAvailability.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0315_AddPoiCapabilitiesAndAvailabilityPg.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/PoiCapabilitiesRepository.cs create mode 100644 Tests/Resgrid.Tests/AdminAssist/SettingsEvidenceTests.cs create mode 100644 Tests/Resgrid.Tests/Models/MapLayerEventsTests.cs create mode 100644 Tests/Resgrid.Tests/Providers/MapLayerFileReaderTests.cs create mode 100644 Tests/Resgrid.Tests/Services/PoiCapabilitiesAndAvailabilityTests.cs create mode 100644 Tests/Resgrid.Tests/Services/TransportDestinationCheckServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/PoiCapabilitiesMvcTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/PoiCapabilityPagesFormTests.cs create mode 100644 Web/Resgrid.Web/Areas/User/Controllers/EventLayersController.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Mapping/EventLayerView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Mapping/PoiCapabilityViews.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Dispatch/_AllCallOption.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/EventLayers/Details.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/EventLayers/Import.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/EventLayers/_EventLayerFields.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Mapping/Destinations.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Mapping/PoiCapabilities.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Mapping/SetPoiAvailability.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Mapping/_PoiAvailabilityBadge.cshtml diff --git a/Core/Resgrid.AdminAssist/Catalog/calls.yaml b/Core/Resgrid.AdminAssist/Catalog/calls.yaml index dfd79c82a..f54d4ace2 100644 --- a/Core/Resgrid.AdminAssist/Catalog/calls.yaml +++ b/Core/Resgrid.AdminAssist/Catalog/calls.yaml @@ -730,7 +730,7 @@ "Action": "DispatchSettings" }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z", + "ReviewedOn": "2026-11-13T00:00:00Z", "Impact": { "Risk": "Critical", "AudienceKey": "Impact.Audience", @@ -1147,7 +1147,7 @@ "Field": null }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z", + "ReviewedOn": "2026-11-13T00:00:00Z", "Impact": { "Risk": "Critical", "AudienceKey": "Impact.Audience", @@ -1182,7 +1182,7 @@ "Field": null }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z", + "ReviewedOn": "2026-11-13T00:00:00Z", "Impact": { "Risk": "Critical", "AudienceKey": "Impact.Audience", @@ -1198,7 +1198,7 @@ "Conflicts": [], "DocumentationId": "field.NewCallFieldRule.Key", "DefaultValue": "unset", - "AllowedValues": "Validated by the owning configuration workflow", + "AllowedValues": "address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall", "EvidenceSource": "NewCallFieldRule", "Availability": "reference-only" }, @@ -1217,7 +1217,7 @@ "Field": null }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z", + "ReviewedOn": "2026-11-13T00:00:00Z", "Impact": { "Risk": "Critical", "AudienceKey": "Impact.Audience", @@ -1252,7 +1252,7 @@ "Field": null }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z", + "ReviewedOn": "2026-11-13T00:00:00Z", "Impact": { "Risk": "Critical", "AudienceKey": "Impact.Audience", @@ -2263,6 +2263,39 @@ "EvidenceSource": "DepartmentSettings", "Availability": "available" }, + { + "Id": "setting.TransportDestinationChecks", + "AreaId": "calls", + "LabelKey": "Setting.TransportDestinationChecks", + "HelpKey": "SettingHelp.TransportDestinationChecks", + "Binding": "DepartmentSettingTypes.TransportDestinationChecks", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DispatchSettings", + "Field": "TransportDestinationChecks" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-11-13T00:00:00Z", + "Impact": { + "Risk": "Low", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.TransportDestinationChecks", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, { "Id": "setting.DispatchRenotifyConfig", "AreaId": "calls", @@ -2465,6 +2498,7 @@ "setting.ShiftCallDispatchPersonnelStatusToSet", "setting.ShiftCallReleasePersonnelStatusToSet", "setting.StatusHoldToConfirm", + "setting.TransportDestinationChecks", "setting.UnitCallDispatchStatusToSet", "setting.UnitCallReleaseStatusToSet", "setting.UnitCallStatusOverridesByUnitType", @@ -2473,6 +2507,7 @@ ], "RuleIds": [ "checkin-timers", + "new-call-dispatch-lists-hidden", "shift-auto-without-dispatch", "shift-coverage", "unit-status-targets", @@ -2592,7 +2627,9 @@ }, "Requirements": [], "SettingIds": [], - "RuleIds": [], + "RuleIds": [ + "new-call-dispatch-lists-hidden" + ], "Prominence": "Detail", "DocsPath": "/web-app/dispatch-calls/#creating-a-call" }, @@ -2843,6 +2880,26 @@ } ] }, + { + "Id": "new-call-dispatch-lists-hidden", + "AreaId": "calls", + "Severity": "Warning", + "TitleKey": "Rule.new-call-dispatch-lists-hidden", + "ExplanationKey": "RuleWhy.new-call-dispatch-lists-hidden", + "NextActionKey": "RuleNext.new-call-dispatch-lists-hidden", + "Location": { + "Controller": "Department", + "Action": "DispatchSettings" + }, + "AppliesWhen": [], + "FailsWhen": [ + { + "EvidenceId": "newCallDispatchListsHidden", + "Comparison": "IsTrue", + "Number": null + } + ] + }, { "Id": "shift-coverage", "AreaId": "calls", @@ -3363,12 +3420,12 @@ "Id": "setting.NewCallFieldPolicy", "Locale": "en", "TitleKey": "Setting.NewCallFieldPolicy", - "Body": "New Call Field Policy. Controls optional new-call fields across supported clients. Hidden fields cannot be required; core dispatch fields remain mandatory.", + "Body": "New Call Field Policy. Controls which built-in fields and sections the call forms show and which must be filled in, on the web, in Omni and through the API. Hidden fields cannot be required; name, nature, priority and type are always shown and required. Hiding all four dispatch lists replaces them with an optional All Call, so a call left without it pages nobody.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "setting-newcallfieldpolicy", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-11-13T00:00:00Z" }, { "Id": "setting.GroupDispatchScopeConfig", @@ -3429,45 +3486,45 @@ "Id": "field.NewCallFieldPolicy.Rules", "Locale": "en", "TitleKey": "Field.NewCallFieldPolicy.Rules", - "Body": "New Call Field Policy / Rules. Per-field visibility and requiredness for optional call-creation fields. Unconfigured fields remain visible and optional.", + "Body": "New Call Field Policy / Rules. One rule per configured field: shown or hidden, optional or required. A field with no rule is shown and optional, and a stored rule hiding Dispatch Recipients (from before the lists had their own keys) hides all four lists.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "field-newcallfieldpolicy-rules", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-11-13T00:00:00Z" }, { "Id": "field.NewCallFieldRule.Key", "Locale": "en", "TitleKey": "Field.NewCallFieldRule.Key", - "Body": "New Call Field Rule / Key. One supported built-in call-field key. Name, nature, priority and type cannot be removed through this policy.", + "Body": "New Call Field Rule / Key. The field the rule is for: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Name, nature, priority and type have no key: they are always shown and required.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "field-newcallfieldrule-key", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-11-13T00:00:00Z" }, { "Id": "field.NewCallFieldRule.Visible", "Locale": "en", "TitleKey": "Field.NewCallFieldRule.Visible", - "Body": "New Call Field Rule / Visible. Whether the optional call field is shown on supported creation surfaces. Hidden fields cannot be required.", + "Body": "New Call Field Rule / Visible. Whether the field or section is on the call forms. A hidden field keeps its stored value when a call is edited. Dispatch Recipients has no setting of its own: it shows while any of the four dispatch lists does, and with all four hidden the forms offer an optional All Call instead.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "field-newcallfieldrule-visible", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-11-13T00:00:00Z" }, { "Id": "field.NewCallFieldRule.Required", "Locale": "en", "TitleKey": "Field.NewCallFieldRule.Required", - "Body": "New Call Field Rule / Required. Whether a visible optional field must be supplied. Review imports and each supported client before making a field mandatory.", + "Body": "New Call Field Rule / Required. Whether a shown field must be filled in before a call is created or an edit is saved. Plus Code, Additional Contacts, Check-In Timers, Part 2 Consent, Save as Pending and the four dispatch lists can only be hidden; requiring Dispatch Recipients means at least one recipient from any list. Review imports and each client before making a field mandatory.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "field-newcallfieldrule-required", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-11-13T00:00:00Z" }, { "Id": "field.GroupDispatchScopeConfig.Enabled", @@ -3788,6 +3845,17 @@ "Owner": "Resgrid.Core", "ReviewedOn": "2026-10-07T00:00:00Z" }, + { + "Id": "setting.TransportDestinationChecks", + "Locale": "en", + "TitleKey": "Setting.TransportDestinationChecks", + "Body": "Transport Destination Checks. A call can name the destination its patient or load goes to (the call's Destination). For a status whose base type is Transporting or At Hospital, the Omni, Unit and Dispatch apps pre-select that destination whether or not this is on, and show when a different destination is chosen. With this on, choosing a different destination needs a reason, entered as the status note, and Resgrid adds a call note naming the unit, the status, both destinations and the reason. A unit that then reports At Hospital at the same destination is not noted twice. A status set from an app that predates this setting is saved without asking; its call note then says no reason was given. Off by default.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-transportdestinationchecks", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-11-13T00:00:00Z" + }, { "Id": "setting.DispatchRenotifyConfig", "Locale": "en", diff --git a/Core/Resgrid.AdminAssist/Catalog/security.yaml b/Core/Resgrid.AdminAssist/Catalog/security.yaml index 4b597a637..60da14db7 100644 --- a/Core/Resgrid.AdminAssist/Catalog/security.yaml +++ b/Core/Resgrid.AdminAssist/Catalog/security.yaml @@ -7390,7 +7390,7 @@ "Id": "setting.Require2FAForAdmins", "Locale": "en", "TitleKey": "Setting.Require2FAForAdmins", - "Body": "Require2 FAFor Admins. Administrator MFA requirement mode. Verify actual factors and recovery for each administrator before enforcement changes.", + "Body": "Require 2FA for Admins. Administrator MFA requirement mode. Verify actual factors and recovery for each administrator before enforcement changes.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "setting-require2faforadmins", "PackVersion": "2026.09.25.2", diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx index 4b3aecd43..08b447d58 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx @@ -2197,16 +2197,16 @@ يقصر عروض الإرسال المدعومة على الشجرة الفرعية لمجموعة العضو. يحتفظ مسؤولو القسم والأدوار المُهيَّأة على مستوى القسم بالوصول الكامل للقسم. - رؤية كل حقل وإلزاميته لحقول إنشاء البلاغ الاختيارية. تبقى الحقول غير المُهيَّأة مرئية واختيارية. + قاعدة واحدة لكل حقل مضبوط: ظاهر أو مخفي، اختياري أو إلزامي. الحقل الذي لا قاعدة له ظاهر واختياري، والقاعدة المحفوظة التي تخفي مستلمي الإرسال (من قبل أن تصبح للقوائم مفاتيحها الخاصة) تخفي القوائم الأربع. - مفتاح واحد مدعوم من حقول البلاغ المدمجة. لا يمكن إزالة الاسم أو الطبيعة أو الأولوية أو النوع عبر هذه السياسة. + الحقل الذي تخصه القاعدة: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. لا مفتاح للاسم والطبيعة والأولوية والنوع: فهي تظهر دائماً وإلزامية دائماً. - ما إذا كان يجب تقديم حقل اختياري مرئي. راجع عمليات الاستيراد وكل عميل مدعوم قبل جعل حقل إلزاميًا. + يحدد ما إذا كان يجب ملء الحقل الظاهر قبل إنشاء البلاغ أو حفظ التعديل. لا يمكن إلا إخفاء Plus Code وجهات الاتصال الإضافية ومؤقتات تسجيل الوصول وموافقة Part 2 والحفظ كمعلّقة وقوائم الإرسال الأربع؛ وجعل المستلمين إلزاميين يعني مستلماً واحداً على الأقل من أي قائمة. راجع عمليات الاستيراد وكل تطبيق قبل جعل الحقل إلزامياً. - ما إذا كان حقل البلاغ الاختياري يظهر في واجهات الإنشاء المدعومة. لا يمكن أن يكون الحقل المخفي إلزاميًا. + يحدد ما إذا كان الحقل أو القسم موجوداً في نماذج البلاغات. يحتفظ الحقل المخفي بقيمته المحفوظة عند تحرير البلاغ. لا إعداد خاص لمستلمي الإرسال: يظهرون ما دامت إحدى قوائم الإرسال الأربع ظاهرة، وعند إخفاء الأربع تعرض النماذج بدلاً منها نداءً عاماً اختيارياً. معرّف حالة الأفراد الحالي الذي يجري إعداد موضعه في القائمة. @@ -4299,6 +4299,9 @@ بيانات اعتماد الخريطة المخصصة مفقودة + + البلاغات الجديدة بلا قوائم إرسال + تفضيلات قنوات الإشعارات بحاجة إلى مراجعة @@ -4419,6 +4422,9 @@ راجع تجاوز الخريطة في شاشة إعداده المحمية. + + أظهر قائمة إرسال واحدة على الأقل في حقول نموذج البلاغ الجديد، إلا إذا كان كل بلاغ يجب أن يذهب إلى الجميع («نداء عام») أو يكون للعلم فقط. + راجع تفضيلات إشعارات الأعضاء المعتمدة، ولا تستخدم اختبارات الاتصال إلا بعد مراجعة صريحة للمستلمين والقناة. @@ -4539,6 +4545,9 @@ يحتاج تجاوز الخريطة المُفعَّل إلى بيانات اعتماد المزوّد الخاصة به. + + تُخفي حقول نموذج البلاغ الجديد قوائم الأفراد والمجموعات والأدوار والوحدات، لذلك لا يُنبَّه أحد عند بلاغ جديد ما لم يُحدَّد «نداء عام». + بعض الأعضاء النشطين الظاهرين ليس لديهم تفضيل مؤهَّل للرسائل النصية القصيرة أو البريد الإلكتروني أو الدفع ضمن مرسِل الإشعارات المعتاد. تفضيل الدفع وحده ليس تسجيلًا صالحًا أو دليلًا على التسليم. @@ -4948,7 +4957,7 @@ يتحكم في التنقل بين الوحدات وبوابات الميزات المدعومة. إخفاء وحدة لا يحذف سجلاتها ولا يثبت تعطيل إعدادات إرسال مستقلة. - يتحكم في حقول البلاغ الجديد الاختيارية عبر العملاء المدعومين. لا يمكن جعل الحقول المخفية إلزامية؛ وتبقى حقول الإرسال الأساسية إلزامية. + يحدد الحقول والأقسام المدمجة التي تعرضها نماذج البلاغات وأيها يجب ملؤه، على الويب وفي Omni وعبر واجهة API. لا يمكن جعل الحقول المخفية إلزامية؛ يظهر الاسم والطبيعة والأولوية والنوع دائماً وهي إلزامية دائماً. إخفاء قوائم الإرسال الأربع يستبدلها بنداء عام اختياري؛ والبلاغ الذي يُنشأ دونه لا ينبّه أحداً. مرجع عميل مزوّد الفوترة. تديره مسارات الفوترة المعتمدة ولا يُعاد إظهاره أبدًا في أدلة المساعد. @@ -6759,6 +6768,12 @@ يُغلق البلاغ النشط تلقائياً عندما تُبلغ آخر وحدة أُرسلت إليه أنها عادت إلى الخدمة أو أصبحت خارج الخدمة. تُبقيه مفتوحاً الوحداتُ التي لا تزال تعمل، والبلاغ الذي لم تُرسل إليه أي وحدة، والبلاغ الخاضع لقيادة حادث نشطة. + + التحقق من وجهة النقل + + + عندما تتجه حالة «نقل» أو «في المستشفى» لوحدة إلى وجهة غير الوجهة المحددة في بلاغها، تطلب التطبيقات سببًا ويُسجَّل التغيير على البلاغ. + إعداد ترقيم البلاغات diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx index 7d771342c..dce429e32 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx @@ -2197,16 +2197,16 @@ Beschränkt unterstützte Dispositionsansichten auf den Gruppenzweig des Mitglieds. Abteilungsadministratoren und konfigurierte abteilungsweite Rollen behalten abteilungsweiten Zugriff. - Sichtbarkeit und Pflichtstatus je Feld für optionale Felder bei der Einsatzerstellung. Nicht konfigurierte Felder bleiben sichtbar und optional. + Eine Regel je eingestelltem Feld: angezeigt oder ausgeblendet, optional oder verpflichtend. Ein Feld ohne Regel wird angezeigt und ist optional; eine gespeicherte Regel, die die Alarmierungsempfänger ausblendet (aus der Zeit vor eigenen Schlüsseln für die Listen), blendet alle vier Listen aus. - Ein unterstützter integrierter Schlüssel für ein Einsatzfeld. Name, Art, Priorität und Typ können über diese Richtlinie nicht entfernt werden. + Das Feld, für das die Regel gilt: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Name, Art, Priorität und Typ haben keinen Schlüssel: Sie werden immer angezeigt und sind immer verpflichtend. - Legt fest, ob ein sichtbares optionales Feld ausgefüllt werden muss. Prüfen Sie Importe und jeden unterstützten Client, bevor Sie ein Feld verpflichtend machen. + Legt fest, ob ein angezeigtes Feld ausgefüllt sein muss, bevor ein Einsatz angelegt oder eine Änderung gespeichert wird. Plus Code, weitere Kontakte, Check-in-Timer, Einwilligung nach Part 2, Als ausstehend speichern und die vier Alarmierungslisten können nur ausgeblendet werden; verpflichtende Alarmierungsempfänger bedeuten mindestens einen Empfänger aus einer beliebigen Liste. Prüfen Sie Importe und jeden Client, bevor Sie ein Feld verpflichtend machen. - Legt fest, ob das optionale Einsatzfeld auf unterstützten Erstellungsoberflächen angezeigt wird. Ausgeblendete Felder können nicht verpflichtend sein. + Legt fest, ob das Feld oder der Bereich auf den Einsatzformularen erscheint. Ein ausgeblendetes Feld behält beim Bearbeiten eines Einsatzes seinen gespeicherten Wert. Die Alarmierungsempfänger haben keine eigene Einstellung: Sie erscheinen, solange eine der vier Alarmierungslisten erscheint; sind alle vier ausgeblendet, bieten die Formulare stattdessen einen optionalen Vollalarm. Vorhandene Kennung eines Personalstatus, dessen Listenposition konfiguriert wird. @@ -4299,6 +4299,9 @@ Benutzerdefinierte Kartenzugangsdaten fehlen + + Neue Einsätze haben keine Alarmierungslisten + Benachrichtigungskanal-Einstellungen müssen geprüft werden @@ -4419,6 +4422,9 @@ Prüfen Sie die Kartenüberschreibung auf der geschützten Konfigurationsseite. + + Blenden Sie in den Feldern im Einsatzformular mindestens eine Alarmierungsliste ein, es sei denn, jeder Einsatz soll an alle gehen („Vollalarm“) oder nur zur Information dienen. + Prüfen Sie die autorisierten Benachrichtigungseinstellungen der Mitglieder und nutzen Sie Kommunikationstests nur nach ausdrücklicher Prüfung von Empfänger und Kanal. @@ -4539,6 +4545,9 @@ Eine aktivierte Kartenüberschreibung benötigt die Zugangsdaten des Anbieters. + + Die Felder im Einsatzformular blenden die Listen Personal, Gruppen, Rollen und Einheiten aus, daher alarmiert ein neuer Einsatz niemanden, solange „Vollalarm“ nicht angekreuzt ist. + Einige sichtbare aktive Mitglieder haben keine berechtigte SMS-, E-Mail- oder Push-Einstellung unter dem gewöhnlichen Benachrichtigungsabsender. Eine Push-Einstellung allein ist keine gültige Registrierung und kein Zustellungsnachweis. @@ -4948,7 +4957,7 @@ Steuert die Modulnavigation und unterstützte Funktionsschalter. Das Ausblenden eines Moduls löscht weder dessen Datensätze noch belegt es, dass unabhängige Dispositionseinstellungen deaktiviert sind. - Steuert optionale Felder für neue Einsätze in unterstützten Clients. Ausgeblendete Felder können nicht erforderlich sein; zentrale Dispositionsfelder bleiben verpflichtend. + Legt fest, welche integrierten Felder und Bereiche die Einsatzformulare zeigen und welche ausgefüllt sein müssen – im Web, in Omni und über die API. Ausgeblendete Felder können nicht verpflichtend sein; Name, Art, Priorität und Typ werden immer angezeigt und sind immer verpflichtend. Sind alle vier Alarmierungslisten ausgeblendet, ersetzt sie ein optionaler Vollalarm; ein Einsatz ohne ihn alarmiert niemanden. Kundenreferenz des Abrechnungsanbieters. Wird von autorisierten Abrechnungsabläufen verwaltet und nie in Assistent-Nachweisen zurückgegeben. @@ -6759,6 +6768,12 @@ Ein aktiver Einsatz schließt sich selbst, wenn seine letzte alarmierte Einheit meldet, dass sie wieder einsatzbereit oder außer Dienst ist. Noch arbeitende Einheiten, ein Einsatz ohne alarmierte Einheit und ein Einsatz mit aktiver Einsatzleitung halten ihn offen. + + Prüfung des Transportziels + + + Wenn der Status Transport oder Im Krankenhaus einer Einheit ein anderes Ziel als das im Einsatz zugewiesene hat, verlangen die Apps eine Begründung, und die Änderung wird im Einsatz vermerkt. + Konfiguration der Einsatznummerierung diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx index 064652d53..ea6330531 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx @@ -2197,16 +2197,16 @@ Περιορίζει τις υποστηριζόμενες προβολές αποστολής στο υποδέντρο ομάδας του μέλους. Οι διαχειριστές τμήματος και οι διαμορφωμένοι ρόλοι σε επίπεδο τμήματος διατηρούν πρόσβαση σε επίπεδο τμήματος. - Ορατότητα και υποχρεωτικότητα ανά πεδίο για προαιρετικά πεδία δημιουργίας κλήσης. Τα μη διαμορφωμένα πεδία παραμένουν ορατά και προαιρετικά. + Ένας κανόνας για κάθε ρυθμισμένο πεδίο: εμφανές ή κρυφό, προαιρετικό ή υποχρεωτικό. Ένα πεδίο χωρίς κανόνα είναι εμφανές και προαιρετικό, και ένας αποθηκευμένος κανόνας που κρύβει τους παραλήπτες αποστολής (από πριν αποκτήσουν οι λίστες δικά τους κλειδιά) κρύβει και τις τέσσερις λίστες. - Ένα υποστηριζόμενο ενσωματωμένο κλειδί πεδίου κλήσης. Το όνομα, η φύση, η προτεραιότητα και ο τύπος δεν μπορούν να αφαιρεθούν μέσω αυτής της πολιτικής. + Το πεδίο στο οποίο αφορά ο κανόνας: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Όνομα, φύση, προτεραιότητα και τύπος δεν έχουν κλειδί: εμφανίζονται πάντα και είναι πάντα υποχρεωτικά. - Αν ένα ορατό προαιρετικό πεδίο πρέπει να συμπληρώνεται. Εξετάστε τις εισαγωγές και κάθε υποστηριζόμενο πελάτη πριν καταστήσετε ένα πεδίο υποχρεωτικό. + Ορίζει αν ένα εμφανές πεδίο πρέπει να συμπληρωθεί πριν δημιουργηθεί μια κλήση ή αποθηκευτεί μια αλλαγή. Plus Code, πρόσθετες επαφές, χρονόμετρα παρουσίας, συγκατάθεση Part 2, αποθήκευση ως εκκρεμής και οι τέσσερις λίστες αποστολής μπορούν μόνο να αποκρυφτούν· οι υποχρεωτικοί παραλήπτες σημαίνουν τουλάχιστον έναν παραλήπτη από οποιαδήποτε λίστα. Ελέγξτε τις εισαγωγές και κάθε πρόγραμμα-πελάτη πριν κάνετε ένα πεδίο υποχρεωτικό. - Αν το προαιρετικό πεδίο κλήσης εμφανίζεται στις υποστηριζόμενες επιφάνειες δημιουργίας. Τα κρυφά πεδία δεν μπορούν να είναι υποχρεωτικά. + Ορίζει αν το πεδίο ή η ενότητα υπάρχει στις φόρμες κλήσεων. Ένα κρυφό πεδίο κρατά την αποθηκευμένη τιμή του όταν επεξεργάζεστε μια κλήση. Οι παραλήπτες αποστολής δεν έχουν δική τους ρύθμιση: εμφανίζονται όσο εμφανίζεται οποιαδήποτε από τις τέσσερις λίστες αποστολής· όταν κρυφτούν και οι τέσσερις, οι φόρμες προσφέρουν αντί γι' αυτές μια προαιρετική γενική κλήση. Υπάρχον αναγνωριστικό κατάστασης προσωπικού του οποίου διαμορφώνεται η θέση στη λίστα. @@ -4299,6 +4299,9 @@ Λείπουν προσαρμοσμένα διαπιστευτήρια χάρτη + + Οι νέες κλήσεις δεν έχουν λίστες αποστολής + Οι προτιμήσεις καναλιών ειδοποιήσεων χρειάζονται έλεγχο @@ -4419,6 +4422,9 @@ Ελέγξτε την παράκαμψη χάρτη στην προστατευμένη οθόνη ρύθμισής της. + + Εμφανίστε τουλάχιστον μία λίστα αποστολής στα πεδία φόρμας νέας κλήσης, εκτός αν κάθε κλήση πρέπει να πηγαίνει σε όλους («Γενική κλήση») ή να είναι μόνο για ενημέρωση. + Ελέγξτε τις εξουσιοδοτημένες προτιμήσεις ειδοποιήσεων μελών και χρησιμοποιήστε Δοκιμές Επικοινωνίας μόνο μετά από ρητό έλεγχο παραλήπτη και καναλιού. @@ -4539,6 +4545,9 @@ Μια ενεργοποιημένη παράκαμψη χάρτη χρειάζεται τα διαπιστευτήρια του παρόχου της. + + Τα πεδία φόρμας νέας κλήσης κρύβουν τις λίστες προσωπικού, ομάδων, ρόλων και μονάδων, οπότε μια νέα κλήση δεν ειδοποιεί κανέναν αν δεν επιλεγεί «Γενική κλήση». + Ορισμένα ορατά ενεργά μέλη δεν έχουν επιλέξιμη προτίμηση SMS, email ή push στον συνήθη αποστολέα ειδοποιήσεων. Η προτίμηση push από μόνη της δεν αποτελεί έγκυρη καταχώριση ούτε απόδειξη παράδοσης. @@ -4948,7 +4957,7 @@ Ελέγχει την πλοήγηση ενοτήτων και τις υποστηριζόμενες πύλες λειτουργιών. Η απόκρυψη μιας ενότητας δεν διαγράφει τις εγγραφές της ούτε αποδεικνύει ότι είναι απενεργοποιημένες ανεξάρτητες ρυθμίσεις αποστολής. - Ελέγχει τα προαιρετικά πεδία νέας κλήσης σε όλες τις υποστηριζόμενες εφαρμογές-πελάτες. Τα κρυφά πεδία δεν μπορούν να είναι υποχρεωτικά· τα βασικά πεδία αποστολής παραμένουν υποχρεωτικά. + Ορίζει ποια ενσωματωμένα πεδία και ενότητες εμφανίζουν οι φόρμες κλήσεων και ποια πρέπει να συμπληρωθούν, στον ιστό, στο Omni και μέσω του API. Τα κρυφά πεδία δεν μπορούν να είναι υποχρεωτικά· όνομα, φύση, προτεραιότητα και τύπος εμφανίζονται πάντα και είναι πάντα υποχρεωτικά. Η απόκρυψη και των τεσσάρων λιστών αποστολής τις αντικαθιστά με μια προαιρετική γενική κλήση· μια κλήση που δημιουργείται χωρίς αυτήν δεν ειδοποιεί κανέναν. Αναφορά πελάτη παρόχου χρέωσης. Διαχειρίζεται από εξουσιοδοτημένες ροές χρέωσης και δεν επιστρέφεται ποτέ στα στοιχεία του βοηθού. @@ -6759,6 +6768,12 @@ Μια ενεργή κλήση κλείνει από μόνη της όταν η τελευταία μονάδα που διατέθηκε σε αυτήν αναφέρει ότι είναι ξανά σε υπηρεσία ή εκτός υπηρεσίας. Μονάδες που ακόμη εργάζονται, μια κλήση χωρίς διατεθειμένη μονάδα και μια κλήση με ενεργή διοίκηση συμβάντος την κρατούν ανοιχτή. + + Έλεγχοι προορισμού μεταφοράς + + + Όταν η κατάσταση Μεταφορά ή Στο νοσοκομείο μιας μονάδας έχει προορισμό διαφορετικό από τον ορισμένο της κλήσης της, οι εφαρμογές ζητούν αιτιολογία και η αλλαγή σημειώνεται στην κλήση. + Ρύθμιση αρίθμησης κλήσεων diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx index b1b10aae8..39f9e8d82 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx @@ -2197,16 +2197,16 @@ Limit supported dispatch views to the member's group subtree. Department administrators and configured department-wide roles keep department-wide access. - Per-field visibility and requiredness for optional call-creation fields. Unconfigured fields remain visible and optional. + One rule per configured field: shown or hidden, optional or required. A field with no rule is shown and optional, and a stored rule hiding Dispatch Recipients (from before the lists had their own keys) hides all four lists. - One supported built-in call-field key. Name, nature, priority and type cannot be removed through this policy. + The field the rule is for: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Name, nature, priority and type have no key: they are always shown and required. - Whether a visible optional field must be supplied. Review imports and each supported client before making a field mandatory. + Whether a shown field must be filled in before a call is created or an edit is saved. Plus Code, Additional Contacts, Check-In Timers, Part 2 Consent, Save as Pending and the four dispatch lists can only be hidden; requiring Dispatch Recipients means at least one recipient from any list. Review imports and each client before making a field mandatory. - Whether the optional call field is shown on supported creation surfaces. Hidden fields cannot be required. + Whether the field or section is on the call forms. A hidden field keeps its stored value when a call is edited. Dispatch Recipients has no setting of its own: it shows while any of the four dispatch lists does, and with all four hidden the forms offer an optional All Call instead. Existing personnel-status identifier whose list position is being configured. @@ -4299,6 +4299,9 @@ Custom map credentials are missing + + New calls have no dispatch lists + Notification channel preferences need review @@ -4419,6 +4422,9 @@ Review the map override on its protected configuration screen. + + Show at least one dispatch list in New Call Form Fields, unless every call should go to everyone (All Call) or be for information only. + Review authorized member notification preferences and use Communication Tests only after explicit recipient and channel review. @@ -4539,6 +4545,9 @@ An enabled map override needs its provider credentials. + + New Call Form Fields hides the personnel, groups, roles and units lists, so a new call pages nobody unless All Call is ticked. + Some visible active members have no eligible SMS, email or push preference under the ordinary notification sender. Push preference alone is not a valid registration or proof of delivery. @@ -4762,7 +4771,7 @@ Records Search Config - Require2 FAFor Admins + Require 2FA for Admins Require Password Reset Via Email @@ -4948,7 +4957,7 @@ Controls module navigation and supported feature gates. Hiding a module does not delete its records or prove independent dispatch settings are disabled. - Controls optional new-call fields across supported clients. Hidden fields cannot be required; core dispatch fields remain mandatory. + Controls which built-in fields and sections the call forms show and which must be filled in, on the web, in Omni and through the API. Hidden fields cannot be required; name, nature, priority and type are always shown and required. Hiding all four dispatch lists replaces them with an optional All Call, so a call left without it pages nobody. Billing-provider customer reference. Managed by authorized billing flows and never returned in assistant evidence. @@ -6759,6 +6768,12 @@ An active call closes on its own when its last dispatched unit reports back in service or out of service. Units still working, a call with no unit dispatched and a call under an active incident command keep it open. + + Transport Destination Checks + + + When a unit's Transporting or At Hospital status goes to a destination other than its call's assigned destination, the apps require a reason and the change is noted on the call. + Call Numbering Config diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx index 694ec3a2c..5e3492965 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx @@ -2197,16 +2197,16 @@ Limita las vistas de despacho compatibles al subárbol de grupo del miembro. Los administradores del departamento y los roles configurados a nivel de todo el departamento conservan el acceso a nivel de todo el departamento. - Visibilidad y obligatoriedad por campo para los campos opcionales de creación de llamadas. Los campos sin configurar permanecen visibles y opcionales. + Una regla por campo configurado: visible u oculto, opcional u obligatorio. Un campo sin regla es visible y opcional, y una regla guardada que oculta los destinatarios del despacho (anterior a las claves propias de las listas) oculta las cuatro listas. - Una clave de campo de llamada integrada compatible. El nombre, la naturaleza, la prioridad y el tipo no se pueden quitar mediante esta política. + El campo al que se aplica la regla: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Nombre, naturaleza, prioridad y tipo no tienen clave: siempre se muestran y son obligatorios. - Indica si se debe proporcionar un campo opcional visible. Revise las importaciones y cada cliente compatible antes de hacer obligatorio un campo. + Indica si un campo visible debe completarse antes de crear una llamada o guardar una edición. Plus Code, contactos adicionales, temporizadores de registro, consentimiento Part 2, guardar como pendiente y las cuatro listas de despacho solo se pueden ocultar; exigir destinatarios significa al menos un destinatario de cualquier lista. Revise las importaciones y cada cliente antes de hacer obligatorio un campo. - Indica si el campo de llamada opcional se muestra en las interfaces de creación compatibles. Los campos ocultos no pueden ser obligatorios. + Indica si el campo o la sección aparece en los formularios de llamada. Un campo oculto conserva su valor guardado al editar una llamada. Los destinatarios del despacho no tienen ajuste propio: se muestran mientras se muestre alguna de las cuatro listas; con las cuatro ocultas, los formularios ofrecen en su lugar una llamada general opcional. Identificador de estado de personal existente cuya posición en la lista se está configurando. @@ -4299,6 +4299,9 @@ Faltan las credenciales personalizadas del mapa + + Las llamadas nuevas no tienen listas de despacho + Las preferencias de canal de notificación necesitan revisión @@ -4419,6 +4422,9 @@ Revise la anulación del mapa en su pantalla de configuración protegida. + + Muestre al menos una lista de despacho en los campos del formulario de nueva llamada, salvo que cada llamada deba ir a todos («Llamada general») o ser solo informativa. + Revise las preferencias de notificación autorizadas de los miembros y use las pruebas de comunicación solo después de revisar explícitamente los destinatarios y el canal. @@ -4539,6 +4545,9 @@ Una anulación de mapa habilitada necesita las credenciales de su proveedor. + + Los campos del formulario de nueva llamada ocultan las listas de personal, grupos, roles y unidades, así que una llamada nueva no avisa a nadie a menos que se marque «Llamada general». + Algunos miembros activos visibles no tienen ninguna preferencia elegible de SMS, correo electrónico o push en el remitente de notificaciones ordinario. La preferencia push por sí sola no es un registro válido ni una prueba de entrega. @@ -4948,7 +4957,7 @@ Controla la navegación de módulos y los controles de funciones admitidos. Ocultar un módulo no elimina sus registros ni demuestra que la configuración de despacho independiente esté deshabilitada. - Controla los campos opcionales para llamadas nuevas en los clientes admitidos. Los campos ocultos no se pueden marcar como obligatorios; los campos principales de despacho siguen siendo obligatorios. + Determina qué campos y secciones integrados muestran los formularios de llamada y cuáles deben completarse, en la web, en Omni y mediante la API. Los campos ocultos no pueden ser obligatorios; nombre, naturaleza, prioridad y tipo siempre se muestran y son obligatorios. Ocultar las cuatro listas de despacho las sustituye por una llamada general opcional; una llamada creada sin ella no avisa a nadie. Referencia de cliente del proveedor de facturación. Se administra mediante flujos de facturación autorizados y nunca se devuelve en la evidencia del asistente. @@ -6759,6 +6768,12 @@ Una llamada activa se cierra sola cuando su última unidad despachada informa que está de nuevo en servicio o fuera de servicio. Las unidades que siguen trabajando, una llamada sin unidades despachadas y una llamada con un mando de incidente activo la mantienen abierta. + + Comprobación del destino de traslado + + + Cuando el estado Transportando o En el hospital de una unidad va a un destino distinto del asignado en su llamada, las aplicaciones exigen un motivo y el cambio se anota en la llamada. + Configuración de numeración de llamadas diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx index e326dde08..4215365ba 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx @@ -2197,16 +2197,16 @@ Limite les vues de régulation prises en charge à la sous-arborescence de groupe du membre. Les administrateurs du département et les rôles configurés à l'échelle du département conservent un accès à l'ensemble du département. - Visibilité et caractère obligatoire, par champ, pour les champs facultatifs de création d'appel. Les champs non configurés restent visibles et facultatifs. + Une règle par champ configuré : affiché ou masqué, facultatif ou obligatoire. Un champ sans règle est affiché et facultatif, et une règle enregistrée qui masque les destinataires (antérieure aux clés propres aux listes) masque les quatre listes. - Une clé de champ d'appel intégrée prise en charge. Le nom, la nature, la priorité et le type ne peuvent pas être supprimés via cette règle. + Le champ concerné par la règle : address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Le nom, la nature, la priorité et le type n'ont pas de clé : ils sont toujours affichés et obligatoires. - Indique si un champ facultatif visible doit obligatoirement être renseigné. Vérifiez les importations et chaque client pris en charge avant de rendre un champ obligatoire. + Indique si un champ affiché doit être rempli avant de créer une intervention ou d'enregistrer une modification. Plus Code, contacts supplémentaires, minuteries de pointage, consentement Part 2, enregistrer en attente et les quatre listes d'envoi peuvent seulement être masqués ; rendre les destinataires obligatoires signifie au moins un destinataire, dans n'importe quelle liste. Vérifiez les imports et chaque client avant de rendre un champ obligatoire. - Indique si le champ d'appel facultatif est affiché sur les interfaces de création prises en charge. Un champ masqué ne peut pas être obligatoire. + Indique si le champ ou la section figure sur les formulaires d'intervention. Un champ masqué conserve sa valeur enregistrée lors de la modification d'une intervention. Les destinataires n'ont pas de réglage propre : ils s'affichent tant qu'une des quatre listes d'envoi s'affiche ; si les quatre sont masquées, les formulaires proposent à la place un appel général facultatif. Identifiant de statut de personnel existant dont la position dans la liste est configurée. @@ -4299,6 +4299,9 @@ Les identifiants de carte personnalisés sont manquants + + Les nouvelles interventions n'ont aucune liste d'envoi + Les préférences de canal de notification doivent être examinées @@ -4419,6 +4422,9 @@ Examinez la substitution de carte sur son écran de configuration protégé. + + Affichez au moins une liste d'envoi dans les champs du formulaire d'intervention, sauf si chaque intervention doit aller à tout le monde (« Appel général ») ou rester purement informative. + Examinez les préférences de notification des membres autorisés et n’utilisez les tests de communication qu’après un examen explicite des destinataires et des canaux. @@ -4539,6 +4545,9 @@ Une substitution de carte activée nécessite les identifiants de son prestataire. + + Les champs du formulaire d'intervention masquent les listes personnel, groupes, rôles et unités : une nouvelle intervention n'alerte donc personne si « Appel général » n'est pas coché. + Certains membres actifs visibles n’ont aucune préférence SMS, e-mail ou push éligible sous l’expéditeur de notification ordinaire. Une préférence push seule ne constitue pas un enregistrement valide ni une preuve de distribution. @@ -4948,7 +4957,7 @@ Contrôle la navigation des modules et les restrictions de fonctionnalités prises en charge. Masquer un module ne supprime pas ses dossiers et ne prouve pas que les paramètres de régulation indépendants sont désactivés. - Contrôle les champs facultatifs de nouvel appel sur les clients pris en charge. Les champs masqués ne peuvent pas être obligatoires ; les champs de régulation essentiels restent obligatoires. + Détermine quels champs et sections intégrés les formulaires d'intervention affichent et lesquels doivent être remplis, sur le web, dans Omni et via l'API. Un champ masqué ne peut pas être obligatoire ; le nom, la nature, la priorité et le type sont toujours affichés et obligatoires. Masquer les quatre listes d'envoi les remplace par un appel général facultatif : une intervention créée sans lui n'alerte personne. Référence client du prestataire de facturation. Gérée par les flux de facturation autorisés et jamais renvoyée dans les éléments de preuve de l’assistant. @@ -6759,6 +6768,12 @@ Un appel actif se clôture de lui-même lorsque sa dernière unité engagée signale qu'elle est de nouveau en service ou hors service. Les unités encore au travail, un appel sans unité engagée et un appel sous commandement d'incident actif le laissent ouvert. + + Contrôle de la destination de transport + + + Lorsque le statut En transport ou À l'hôpital d'une unité vise une destination différente de celle attribuée à son appel, les applications exigent un motif et le changement est noté sur l'appel. + Configuration de la numérotation des appels diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx index 8b82c04e5..556a5ab5b 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx @@ -2197,16 +2197,16 @@ Limita le visualizzazioni di dispaccio supportate al sottoalbero di gruppo del membro. Gli amministratori del dipartimento e i ruoli configurati a livello di dipartimento mantengono l'accesso a livello di dipartimento. - Visibilità e obbligatorietà per campo per i campi facoltativi di creazione chiamata. I campi non configurati restano visibili e facoltativi. + Una regola per ogni campo configurato: visibile o nascosto, facoltativo o obbligatorio. Un campo senza regola è visibile e facoltativo, e una regola salvata che nasconde i destinatari (precedente alle chiavi proprie degli elenchi) nasconde tutti e quattro gli elenchi. - Una chiave di campo chiamata predefinita supportata. Nome, natura, priorità e tipo non possono essere rimossi tramite questa politica. + Il campo a cui si applica la regola: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Nome, natura, priorità e tipo non hanno chiave: sono sempre visibili e obbligatori. - Se un campo facoltativo visibile deve essere compilato. Esamina le importazioni e ogni client supportato prima di rendere obbligatorio un campo. + Indica se un campo visibile deve essere compilato prima di creare una chiamata o salvare una modifica. Plus Code, contatti aggiuntivi, timer di registrazione, consenso Part 2, salva in attesa e i quattro elenchi di invio possono solo essere nascosti; rendere obbligatori i destinatari significa almeno un destinatario da qualsiasi elenco. Verifica le importazioni e ogni client prima di rendere obbligatorio un campo. - Se il campo chiamata facoltativo viene mostrato nelle interfacce di creazione supportate. I campi nascosti non possono essere obbligatori. + Indica se il campo o la sezione compare nei moduli di chiamata. Un campo nascosto conserva il valore salvato quando si modifica una chiamata. I destinatari non hanno un'impostazione propria: compaiono finché compare uno dei quattro elenchi di invio; con tutti e quattro nascosti, i moduli offrono invece una chiamata generale facoltativa. Identificatore di stato del personale esistente la cui posizione nell'elenco viene configurata. @@ -4299,6 +4299,9 @@ Le credenziali mappa personalizzate mancano + + Le nuove chiamate non hanno elenchi di invio + Le preferenze dei canali di notifica richiedono una revisione @@ -4419,6 +4422,9 @@ Rivedi l'override della mappa nella relativa schermata di configurazione protetta. + + Mostra almeno un elenco di invio nei campi del modulo di chiamata, a meno che ogni chiamata debba andare a tutti («Chiamata generale») o essere solo informativa. + Rivedi le preferenze di notifica dei membri autorizzati e usa i test di comunicazione solo dopo una revisione esplicita di destinatari e canali. @@ -4539,6 +4545,9 @@ Un override mappa attivo richiede le credenziali del fornitore. + + I campi del modulo di chiamata nascondono gli elenchi di personale, gruppi, ruoli e unità, quindi una nuova chiamata non allerta nessuno se non si seleziona «Chiamata generale». + Alcuni membri attivi visibili non hanno preferenze idonee per SMS, e-mail o push nel mittente di notifica ordinario. La sola preferenza push non è una registrazione valida né una prova di consegna. @@ -4948,7 +4957,7 @@ Controlla la navigazione dei moduli e i gate delle funzionalità supportate. Nascondere un modulo non elimina i suoi record né dimostra che le impostazioni indipendenti della centrale operativa siano disattivate. - Controlla i campi opzionali per le nuove chiamate nei client supportati. I campi nascosti non possono essere obbligatori; i campi principali della centrale operativa restano obbligatori. + Stabilisce quali campi e sezioni predefiniti mostrano i moduli di chiamata e quali devono essere compilati, sul web, in Omni e tramite l'API. I campi nascosti non possono essere obbligatori; nome, natura, priorità e tipo sono sempre visibili e obbligatori. Nascondere tutti e quattro gli elenchi di invio li sostituisce con una chiamata generale facoltativa; una chiamata creata senza di essa non allerta nessuno. Riferimento cliente del fornitore di fatturazione. Gestito dai flussi di fatturazione autorizzati e mai restituito nelle evidenze dell'assistente. @@ -6759,6 +6768,12 @@ Una chiamata attiva si chiude da sola quando la sua ultima unità inviata segnala di essere di nuovo in servizio o fuori servizio. Le unità ancora al lavoro, una chiamata senza unità inviate e una chiamata con un comando dell'incidente attivo la mantengono aperta. + + Controllo della destinazione di trasporto + + + Quando lo stato In trasporto o In ospedale di un'unità va verso una destinazione diversa da quella assegnata alla sua chiamata, le app richiedono un motivo e la modifica viene annotata sulla chiamata. + Configurazione della numerazione delle chiamate diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx index b73003d2c..d24a7a4ee 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx @@ -2197,16 +2197,16 @@ Ogranicza obsługiwane widoki dysponowania do poddrzewa grupy członka. Administratorzy oddziału oraz skonfigurowane role ogólnooddziałowe zachowują dostęp obejmujący cały oddział. - Widoczność i wymagalność poszczególnych opcjonalnych pól tworzenia zgłoszenia. Nieskonfigurowane pola pozostają widoczne i opcjonalne. + Jedna reguła na skonfigurowane pole: widoczne lub ukryte, opcjonalne lub wymagane. Pole bez reguły jest widoczne i opcjonalne, a zapisana reguła ukrywająca odbiorców dysponowania (sprzed własnych kluczy list) ukrywa wszystkie cztery listy. - Jeden obsługiwany wbudowany klucz pola zgłoszenia. Nazwy, rodzaju, priorytetu i typu nie można usunąć za pomocą tej polityki. + Pole, którego dotyczy reguła: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Nazwa, charakter, priorytet i typ nie mają klucza: są zawsze widoczne i wymagane. - Określa, czy widoczne pole opcjonalne musi zostać wypełnione. Przed uczynieniem pola obowiązkowym sprawdź importy i każdy obsługiwany klient. + Określa, czy widoczne pole trzeba wypełnić przed utworzeniem zdarzenia lub zapisaniem zmian. Plus Code, dodatkowe kontakty, czasomierze meldowania, zgoda Part 2, zapis jako oczekujące i cztery listy dysponowania można tylko ukryć; wymaganie odbiorców oznacza co najmniej jednego odbiorcę z dowolnej listy. Przed uczynieniem pola wymaganym sprawdź importy i każdego klienta. - Określa, czy opcjonalne pole zgłoszenia jest wyświetlane w obsługiwanych miejscach tworzenia. Ukrytych pól nie można oznaczyć jako wymagane. + Określa, czy pole lub sekcja jest w formularzach zdarzeń. Ukryte pole zachowuje zapisaną wartość przy edycji zdarzenia. Odbiorcy dysponowania nie mają własnego ustawienia: są widoczni, dopóki widoczna jest którakolwiek z czterech list; gdy wszystkie cztery są ukryte, formularze oferują zamiast nich opcjonalny alarm ogólny. Istniejący identyfikator statusu personelu, którego pozycja na liście jest konfigurowana. @@ -4299,6 +4299,9 @@ Brak niestandardowych danych uwierzytelniających mapy + + Nowe zdarzenia nie mają list dysponowania + Preferencje kanałów powiadomień wymagają przeglądu @@ -4419,6 +4422,9 @@ Sprawdź zastąpienie mapy na jego chronionym ekranie konfiguracji. + + Pokaż co najmniej jedną listę dysponowania w polach formularza nowego zdarzenia, chyba że każde zdarzenie ma trafiać do wszystkich („Alarm ogólny”) lub być tylko informacyjne. + Sprawdź autoryzowane preferencje powiadomień członków i używaj Testów komunikacji dopiero po jawnym przeglądzie odbiorców i kanałów. @@ -4539,6 +4545,9 @@ Włączone zastąpienie mapy wymaga danych uwierzytelniających dostawcy. + + Pola formularza nowego zdarzenia ukrywają listy personelu, grup, ról i jednostek, więc nowe zdarzenie nikogo nie alarmuje, jeśli nie zaznaczono „Alarm ogólny”. + Niektórzy widoczni aktywni członkowie nie mają żadnej kwalifikującej się preferencji SMS, e-mail ani push w standardowym module wysyłki powiadomień. Sama preferencja push nie stanowi ważnej rejestracji ani dowodu doręczenia. @@ -4948,7 +4957,7 @@ Kontroluje nawigację po modułach i obsługiwane bramki funkcji. Ukrycie modułu nie usuwa jego rekordów ani nie jest dowodem wyłączenia niezależnych ustawień dyspozytorskich. - Kontroluje opcjonalne pola nowego zgłoszenia we wszystkich obsługiwanych aplikacjach klienckich. Ukrytych pól nie można uczynić wymaganymi — podstawowe pola dyspozytorskie pozostają obowiązkowe. + Określa, które wbudowane pola i sekcje pokazują formularze zdarzeń i które trzeba wypełnić — w sieci, w Omni i przez API. Ukryte pola nie mogą być wymagane; nazwa, charakter, priorytet i typ są zawsze widoczne i wymagane. Ukrycie wszystkich czterech list dysponowania zastępuje je opcjonalnym alarmem ogólnym; zdarzenie utworzone bez niego nikogo nie alarmuje. Odwołanie klienta u dostawcy rozliczeń. Zarządzane przez autoryzowane procesy rozliczeniowe i nigdy nie zwracane w danych potwierdzających asystenta. @@ -6759,6 +6768,12 @@ Aktywne zgłoszenie zamyka się samo, gdy jego ostatnia zadysponowana jednostka zgłosi powrót do służby lub wyłączenie z eksploatacji. Jednostki nadal pracujące, zgłoszenie bez zadysponowanej jednostki i zgłoszenie z aktywnym dowodzeniem incydentem pozostawiają je otwarte. + + Kontrola miejsca docelowego transportu + + + Gdy status Transport lub W szpitalu jednostki wskazuje inne miejsce docelowe niż przypisane do jej zgłoszenia, aplikacje wymagają podania powodu, a zmiana jest odnotowywana w zgłoszeniu. + Konfiguracja numeracji zgłoszeń diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx index 3331f7031..17d790570 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx @@ -2170,16 +2170,16 @@ Limit supported dispatch views to the member's group subtree. Department administrators and configured department-wide roles keep department-wide access. - Per-field visibility and requiredness for optional call-creation fields. Unconfigured fields remain visible and optional. + One rule per configured field: shown or hidden, optional or required. A field with no rule is shown and optional, and a stored rule hiding Dispatch Recipients (from before the lists had their own keys) hides all four lists. - One supported built-in call-field key. Name, nature, priority and type cannot be removed through this policy. + The field the rule is for: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Name, nature, priority and type have no key: they are always shown and required. - Whether a visible optional field must be supplied. Review imports and each supported client before making a field mandatory. + Whether a shown field must be filled in before a call is created or an edit is saved. Plus Code, Additional Contacts, Check-In Timers, Part 2 Consent, Save as Pending and the four dispatch lists can only be hidden; requiring Dispatch Recipients means at least one recipient from any list. Review imports and each client before making a field mandatory. - Whether the optional call field is shown on supported creation surfaces. Hidden fields cannot be required. + Whether the field or section is on the call forms. A hidden field keeps its stored value when a call is edited. Dispatch Recipients has no setting of its own: it shows while any of the four dispatch lists does, and with all four hidden the forms offer an optional All Call instead. Existing personnel-status identifier whose list position is being configured. @@ -4245,6 +4245,9 @@ Custom map credentials are missing + + New calls have no dispatch lists + Notification channel preferences need review @@ -4365,6 +4368,9 @@ Review the map override on its protected configuration screen. + + Show at least one dispatch list in New Call Form Fields, unless every call should go to everyone (All Call) or be for information only. + Review authorized member notification preferences and use Communication Tests only after explicit recipient and channel review. @@ -4485,6 +4491,9 @@ An enabled map override needs its provider credentials. + + New Call Form Fields hides the personnel, groups, roles and units lists, so a new call pages nobody unless All Call is ticked. + Some visible active members have no eligible SMS, email or push preference under the ordinary notification sender. Push preference alone is not a valid registration or proof of delivery. @@ -4708,7 +4717,7 @@ Records Search Config - Require2 FAFor Admins + Require 2FA for Admins Require Password Reset Via Email @@ -4894,7 +4903,7 @@ Controls module navigation and supported feature gates. Hiding a module does not delete its records or prove independent dispatch settings are disabled. - Controls optional new-call fields across supported clients. Hidden fields cannot be required; core dispatch fields remain mandatory. + Controls which built-in fields and sections the call forms show and which must be filled in, on the web, in Omni and through the API. Hidden fields cannot be required; name, nature, priority and type are always shown and required. Hiding all four dispatch lists replaces them with an optional All Call, so a call left without it pages nobody. Billing-provider customer reference. Managed by authorized billing flows and never returned in assistant evidence. @@ -6705,6 +6714,12 @@ An active call closes on its own when its last dispatched unit reports back in service or out of service. Units still working, a call with no unit dispatched and a call under an active incident command keep it open. + + Transport Destination Checks + + + When a unit's Transporting or At Hospital status goes to a destination other than its call's assigned destination, the apps require a reason and the change is noted on the call. + Call Numbering Config diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx index cc5196bb2..87b5857e8 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx @@ -2197,16 +2197,16 @@ Begränsa utlarmningsvyer som stöds till medlemmens grupp och undergrupper. Avdelningsadministratörer och konfigurerade avdelningsövergripande roller behåller åtkomst för hela avdelningen. - Synlighet och obligatoriskhet per fält för valfria fält vid skapande av larm. Ej konfigurerade fält förblir synliga och valfria. + En regel per inställt fält: visat eller dolt, valfritt eller obligatoriskt. Ett fält utan regel visas och är valfritt, och en sparad regel som döljer larmmottagarna (från innan listorna hade egna nycklar) döljer alla fyra listor. - En nyckel för ett inbyggt larmfält som stöds. Namn, art, prioritet och typ kan inte tas bort genom denna policy. + Fältet regeln gäller: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Namn, art, prioritet och typ har ingen nyckel: de visas och krävs alltid. - Om ett synligt valfritt fält måste fyllas i. Granska importer och varje klient som stöds innan du gör ett fält obligatoriskt. + Anger om ett visat fält måste fyllas i innan ett ärende skapas eller en ändring sparas. Plus Code, ytterligare kontakter, incheckningstimers, samtycke enligt Part 2, spara som väntande och de fyra larmlistorna kan bara döljas; att kräva larmmottagare betyder minst en mottagare från valfri lista. Granska importer och varje klient innan ett fält görs obligatoriskt. - Om det valfria larmfältet visas på de ytor för skapande som stöds. Dolda fält kan inte vara obligatoriska. + Anger om fältet eller avsnittet finns på ärendeformulären. Ett dolt fält behåller sitt sparade värde när ett ärende redigeras. Larmmottagarna har ingen egen inställning: de visas så länge någon av de fyra larmlistorna visas, och med alla fyra dolda erbjuder formulären i stället ett valfritt allmänt larm. Befintlig identifierare för personalstatus vars listposition konfigureras. @@ -4299,6 +4299,9 @@ Anpassade kartuppgifter saknas + + Nya ärenden saknar larmlistor + Inställningar för aviseringskanaler behöver granskas @@ -4419,6 +4422,9 @@ Granska kartåsidosättningen på dess skyddade konfigurationssida. + + Visa minst en larmlista i fälten i ärendeformuläret, om inte varje ärende ska gå till alla (”Allmänt larm”) eller bara vara för information. + Granska auktoriserade medlemmars aviseringsinställningar och använd kommunikationstest först efter uttrycklig granskning av mottagare och kanal. @@ -4539,6 +4545,9 @@ En aktiverad kartåsidosättning behöver sina leverantörsuppgifter. + + Fälten i ärendeformuläret döljer listorna för personal, grupper, roller och enheter, så ett nytt ärende larmar ingen om inte ”Allmänt larm” är markerat. + Vissa synliga aktiva medlemmar saknar behörigt SMS-, e-post- eller push-alternativ under den vanliga aviseringsavsändaren. Push-alternativ i sig är inte en giltig registrering eller bevis på leverans. @@ -4948,7 +4957,7 @@ Styr modulnavigering och funktionsspärrar som stöds. Att dölja en modul tar inte bort dess poster eller bevisar att oberoende utlarmningsinställningar är inaktiverade. - Styr valfria fält för nytt larm i klienter som stöds. Dolda fält kan inte göras obligatoriska; kärnfält för utlarmning förblir obligatoriska. + Styr vilka inbyggda fält och avsnitt ärendeformulären visar och vilka som måste fyllas i, på webben, i Omni och via API:t. Dolda fält kan inte vara obligatoriska; namn, art, prioritet och typ visas och krävs alltid. Att dölja alla fyra larmlistor ersätter dem med ett valfritt allmänt larm; ett ärende som skapas utan det larmar ingen. Kundreferens för faktureringsleverantör. Hanteras av auktoriserade faktureringsflöden och returneras aldrig i assistentens underlag. @@ -6759,6 +6768,12 @@ Ett aktivt larm stängs av sig självt när dess sista larmade enhet rapporterar att den är tillbaka i tjänst eller ur tjänst. Enheter som fortfarande arbetar, ett larm utan larmad enhet och ett larm med aktiv insatsledning håller det öppet. + + Kontroll av transportdestination + + + När en enhets status Transporterar eller På sjukhus går till en annan destination än larmets tilldelade destination kräver apparna en anledning och ändringen antecknas i larmet. + Konfiguration för larmnumrering diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx index df7444f1f..0ca601147 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx @@ -2197,16 +2197,16 @@ Обмежує підтримувані перегляди диспетчеризації підгрупою учасника. Адміністратори підрозділу та налаштовані ролі для всього підрозділу зберігають доступ до всього підрозділу. - Видимість і обов'язковість для кожного опціонального поля створення виклику окремо. Неналаштовані поля залишаються видимими та необов'язковими. + Одне правило на кожне налаштоване поле: показане чи приховане, необов'язкове чи обов'язкове. Поле без правила показується й необов'язкове, а збережене правило, що приховує отримувачів висилання (ще до власних ключів списків), приховує всі чотири списки. - Один підтримуваний вбудований ключ поля виклику. Назву, характер, пріоритет і тип не можна видалити за допомогою цієї політики. + Поле, якого стосується правило: address, geolocation, what3words, pluscode, destinationPoi, indoorLocation, mapPage, note, contactName, contactInfo, primaryContact, additionalContacts, externalId, incidentId, referenceId, protocols, linkedCall, dispatchPersonnel, dispatchGroups, dispatchRoles, dispatchUnits, dispatchList, checkInTimers, part2Consent, dispatchOn, pendingCall. Назва, характер, пріоритет і тип не мають ключа: вони завжди показуються й обов'язкові. - Чи потрібно обов'язково заповнювати видиме опціональне поле. Перегляньте імпорти та кожен підтримуваний клієнт, перш ніж робити поле обов'язковим. + Визначає, чи потрібно заповнити показане поле, перш ніж створити виклик або зберегти зміни. Plus Code, додаткові контакти, таймери реєстрації, згоду Part 2, збереження як очікуваного та чотири списки висилання можна лише приховати; обов'язкові отримувачі означають щонайменше одного отримувача з будь-якого списку. Перш ніж зробити поле обов'язковим, перевірте імпорти та кожен клієнт. - Чи показується опціональне поле виклику на підтримуваних екранах створення. Приховані поля не можуть бути обов'язковими. + Визначає, чи є поле або розділ у формах викликів. Приховане поле зберігає збережене значення під час редагування виклику. Отримувачі висилання не мають власного параметра: вони показуються, поки показується будь-який із чотирьох списків висилання; якщо приховано всі чотири, форми натомість пропонують необов'язковий загальний виклик. Наявний ідентифікатор статусу персоналу, позицію якого в списку налаштовують. @@ -4299,6 +4299,9 @@ Відсутні власні облікові дані карти + + Нові виклики не мають списків висилання + Налаштування каналів сповіщень потребують перегляду @@ -4419,6 +4422,9 @@ Перегляньте перевизначення карти на відповідному захищеному екрані налаштувань. + + Покажіть принаймні один список висилання в полях форми нового виклику, якщо тільки кожен виклик не має надходити всім («Загальний виклик») або бути лише для інформації. + Перегляньте авторизовані налаштування сповіщень учасників і використовуйте Тести зв'язку лише після явного перегляду отримувачів і каналів. @@ -4539,6 +4545,9 @@ Увімкнене перевизначення карти потребує облікових даних провайдера. + + Поля форми нового виклику приховують списки персоналу, груп, ролей і підрозділів, тож новий виклик нікого не сповіщає, якщо не позначено «Загальний виклик». + Деякі видимі активні учасники не мають придатного налаштування SMS, електронної пошти чи push-сповіщень для звичайного відправника сповіщень. Саме лише налаштування push-сповіщень не є дійсною реєстрацією чи доказом доставки. @@ -4948,7 +4957,7 @@ Керує навігацією модулів і підтримуваними перемикачами функцій. Приховування модуля не видаляє його записи та не свідчить про те, що незалежні налаштування диспетчеризації вимкнено. - Керує необов'язковими полями нового виклику в усіх підтримуваних клієнтах. Приховані поля не можуть бути обов'язковими; основні поля диспетчеризації залишаються обов'язковими. + Визначає, які вбудовані поля й розділи показують форми викликів і які потрібно заповнити — у вебі, в Omni та через API. Приховані поля не можуть бути обов'язковими; назва, характер, пріоритет і тип завжди показуються й обов'язкові. Приховування всіх чотирьох списків висилання замінює їх необов'язковим загальним викликом; виклик, створений без нього, нікого не сповіщає. Довідковий ідентифікатор клієнта постачальника білінгу. Керується авторизованими процесами білінгу і ніколи не повертається в даних асистента. @@ -6759,6 +6768,12 @@ Активний виклик закривається сам, коли його остання направлена одиниця повідомляє, що повернулася до служби або виведена з експлуатації. Одиниці, що ще працюють, виклик без направленої одиниці та виклик з активним командуванням інцидентом залишають його відкритим. + + Перевірка пункту призначення транспортування + + + Коли статус «Транспортування» або «У лікарні» одиниці веде до іншого пункту призначення, ніж призначений у її виклику, застосунки вимагають причину, а зміну занотовують у виклику. + Конфігурація нумерації викликів diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index 67e01465e..e4262d8d7 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -1006,7 +1006,7 @@ معلومات الاتصال بالمبلّغ - معرّف خارجي + معرّف البلاغ (معرّف خارجي) معرّف الحادث @@ -1371,6 +1371,12 @@ يُغلق البلاغ النشط تلقائياً عندما تُبلغ آخر وحدة أُرسلت إليه أنها عادت إلى الخدمة أو أصبحت خارج الخدمة (حالة نوعها الأساسي متاحة أو في المقر أو مُخلاة أو غير متاحة أو صيانة). ويجب أن تكون جميع الوحدات الأخرى المرسلة قد أبلغت أيضاً بانتهائها منذ إرسالها. تُحتسب الوحدات فقط: البلاغ الذي لم تُرسل إليه أي وحدة، أو البلاغ الخاضع لقيادة حادث نشطة، أو وحدة لا تزال عائدة أو تقوم بالنقل، يبقى مفتوحاً للمُرسِل. + + التحقق من وجهة النقل + + + عندما تضبط وحدة حالةً نوعها الأساسي «نقل» أو «في المستشفى» إلى وجهة غير الوجهة المحددة في بلاغها، تطلب التطبيقات سببًا (ملاحظة الحالة) قبل الحفظ، ويُضاف التغيير إلى البلاغ كملاحظة. تحدد التطبيقات مسبقًا الوجهة المحددة للبلاغ لهذه الحالات في جميع الأحوال. + إعادة إشعار الإرسال @@ -2622,4 +2628,43 @@ أدخل مفتاح API لمزود البحث عن العناوين هذا. + + صفحة الخريطة + + + جهة الاتصال الرئيسية + + + جهات اتصال إضافية + + + مؤقتات تسجيل الوصول + + + موافقة 42 CFR Part 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 9c636a89f..93621bce1 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -948,7 +948,7 @@ Kontaktdaten des Melders - Externe Einsatz-ID + Einsatzkennung (externe ID) Vorgangs-ID @@ -1313,6 +1313,12 @@ Ein aktiver Einsatz schließt sich selbst, wenn die letzte alarmierte Einheit meldet, dass sie wieder einsatzbereit oder außer Dienst ist (ein Status mit dem Basistyp Verfügbar, Auf der Wache, Freigegeben, Nicht verfügbar oder Wartung). Alle anderen alarmierten Einheiten müssen seit ihrer Alarmierung ebenfalls das Ende gemeldet haben. Es zählen nur Einheiten: Ein Einsatz ohne alarmierte Einheit, ein Einsatz mit aktiver Einsatzleitung oder eine Einheit, die noch zurückfährt oder transportiert, bleibt für den Disponenten offen. + + Prüfung des Transportziels + + + Wenn eine Einheit einen Status mit dem Basistyp Transport oder Im Krankenhaus mit einem anderen Ziel als dem im Einsatz zugewiesenen setzt, verlangen die Apps vor dem Speichern eine Begründung (die Statusnotiz), und die Änderung wird dem Einsatz als Notiz hinzugefügt. Die Apps wählen das zugewiesene Ziel des Einsatzes für diese Status in jedem Fall vor. + Erneute Alarmierung @@ -2564,4 +2570,43 @@ Geben Sie den API-Schlüssel für diesen Anbieter der Adresssuche ein. + + Kartenseite + + + Hauptkontakt + + + Weitere Kontakte + + + Check-in-Timer + + + Einwilligung nach 42 CFR Part 2 + + + Als ausstehend speichern + + + Alarmierung an Personal + + + Alarmierung an Gruppen + + + Alarmierung an Rollen + + + Alarmierung an Einheiten + + + Dieses Feld kann ausgeblendet, aber nicht als Pflichtfeld festgelegt werden. + + + Sichtbar, solange eine Alarmierungsliste sichtbar ist + + + Alle Alarmierungslisten (Personal, Gruppen, Rollen und Einheiten) sind ausgeblendet. Die Einsatzformulare bieten stattdessen eine optionale Schaltfläche „Vollalarm“: Damit geht der Einsatz an alle, ohne sie wird er nur zur Information gespeichert und niemand wird alarmiert. Ausrückeordnungen mit automatischer Alarmierung alarmieren ihre Einheiten weiterhin. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx index a4a732865..9c77c2d99 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx @@ -1039,7 +1039,7 @@ Στοιχεία επικοινωνίας αναφέροντος - Εξωτερικό αναγνωριστικό + Αναγνωριστικό κλήσης (εξωτερικό) Αναγνωριστικό συμβάντος @@ -1371,6 +1371,12 @@ Μια ενεργή κλήση κλείνει από μόνη της όταν η τελευταία μονάδα που διατέθηκε σε αυτήν αναφέρει ότι είναι ξανά σε υπηρεσία ή εκτός υπηρεσίας (κατάσταση με βασικό τύπο Διαθέσιμη, Στον σταθμό, Αποδεσμευμένη, Μη διαθέσιμη ή Συντήρηση). Όλες οι άλλες μονάδες που διατέθηκαν πρέπει επίσης να έχουν αναφέρει ολοκλήρωση από τη διάθεσή τους. Μετρούν μόνο οι μονάδες: μια κλήση χωρίς διατεθειμένη μονάδα, μια κλήση με ενεργή διοίκηση συμβάντος ή μια μονάδα που ακόμη επιστρέφει ή μεταφέρει κρατά την κλήση ανοιχτή για τον διαχειριστή. + + Έλεγχοι προορισμού μεταφοράς + + + Όταν μια μονάδα ορίζει κατάσταση με βασικό τύπο Μεταφορά ή Στο νοσοκομείο προς προορισμό διαφορετικό από αυτόν που έχει οριστεί στην κλήση της, οι εφαρμογές ζητούν αιτιολογία (τη σημείωση της κατάστασης) πριν από την αποθήκευση και η αλλαγή προστίθεται στην κλήση ως σημείωση. Οι εφαρμογές προεπιλέγουν σε κάθε περίπτωση τον ορισμένο προορισμό της κλήσης για αυτές τις καταστάσεις. + Επανειδοποίηση αποστολής @@ -2622,4 +2628,43 @@ Εισαγάγετε το κλειδί API για αυτόν τον πάροχο αναζήτησης διευθύνσεων. + + Σελίδα χάρτη + + + Κύρια επαφή + + + Πρόσθετες επαφές + + + Χρονόμετρα παρουσίας + + + Συγκατάθεση 42 CFR Part 2 + + + Αποθήκευση ως εκκρεμής + + + Αποστολή σε προσωπικό + + + Αποστολή σε ομάδες + + + Αποστολή σε ρόλους + + + Αποστολή σε μονάδες + + + Αυτό το πεδίο μπορεί να αποκρυφτεί αλλά όχι να γίνει υποχρεωτικό. + + + Εμφανίζεται μαζί με οποιαδήποτε λίστα αποστολής + + + Όλες οι λίστες αποστολής (προσωπικό, ομάδες, ρόλοι και μονάδες) είναι κρυφές. Οι φόρμες κλήσεων θα προσφέρουν αντί γι' αυτές ένα προαιρετικό κουμπί «Γενική κλήση»: με αυτό η κλήση πηγαίνει σε όλους, χωρίς αυτό αποθηκεύεται μόνο για ενημέρωση και δεν ειδοποιείται κανείς. Οι κάρτες εξόδου με αυτόματη αποστολή εξακολουθούν να στέλνουν τις μονάδες τους. + \ 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 53a6e0937..d1d2a40d9 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -1039,7 +1039,7 @@ Reporter Contact Info - External Call ID + Call Identifier (External ID) Incident ID @@ -1371,6 +1371,12 @@ An active call closes on its own when the last unit dispatched to it reports back in service or out of service (a status whose base type is Available, In Quarters, Cleared, Unavailable or Maintenance). Every other dispatched unit must also have reported finished since its dispatch. Only units count: a call with no unit dispatched, a call under an active incident command, or a unit still returning or transporting keeps the call open for the dispatcher. + + Transport destination checks + + + When a unit sets a status whose base type is Transporting or At Hospital to a destination other than the one assigned on its call, the apps ask for a reason (the status note) before saving, and the change is added to the call as a note. The apps pre-select the call's assigned destination for these statuses either way. + Dispatch Re-notification @@ -2622,4 +2628,43 @@ Enter the API key for this address lookup provider. + + Map Page + + + Primary Contact + + + Additional Contacts + + + Check-In Timers + + + 42 CFR Part 2 Consent + + + Save as Pending + + + Dispatch to Personnel + + + Dispatch to Groups + + + Dispatch to Roles + + + Dispatch to Units + + + This field can be hidden but not required. + + + Shown with any dispatch list + + + Every dispatch list (personnel, groups, roles and units) is hidden. The call forms will offer an optional All Call button instead: with it a call goes to everyone, without it the call is saved for information only and nobody is paged. Run cards that dispatch automatically still send their units. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index 5d1087556..a8c76e4e8 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -1039,7 +1039,7 @@ Datos de contacto del informante - ID externo de la llamada + Identificador de llamada (ID externo) ID del incidente @@ -1371,6 +1371,12 @@ Una llamada activa se cierra sola cuando la última unidad despachada a ella informa que está de nuevo en servicio o fuera de servicio (un estado cuyo tipo base es Disponible, En cuartel, Liberada, No disponible o Mantenimiento). Todas las demás unidades despachadas también deben haber informado que terminaron desde su despacho. Solo cuentan las unidades: una llamada sin unidades despachadas, una llamada con un mando de incidente activo o una unidad que aún regresa o transporta mantiene la llamada abierta para el despachador. + + Comprobación del destino de traslado + + + Cuando una unidad establece un estado cuyo tipo base es Transportando o En el hospital con un destino distinto del asignado en su llamada, las aplicaciones piden un motivo (la nota del estado) antes de guardar y el cambio se añade a la llamada como nota. Las aplicaciones preseleccionan el destino asignado de la llamada para estos estados en cualquier caso. + Renotificación de despacho @@ -2622,4 +2628,43 @@ Ingrese la clave API de este proveedor de búsqueda de direcciones. + + Página del mapa + + + Contacto principal + + + Contactos adicionales + + + Temporizadores de registro + + + Consentimiento 42 CFR Part 2 + + + Guardar como pendiente + + + Despachar a personal + + + Despachar a grupos + + + Despachar a roles + + + Despachar a unidades + + + Este campo se puede ocultar, pero no hacer obligatorio. + + + Visible con cualquier lista de despacho + + + Todas las listas de despacho (personal, grupos, roles y unidades) están ocultas. Los formularios de llamada ofrecerán en su lugar un botón opcional «Llamada general»: con él la llamada va a todos; sin él, la llamada se guarda solo como información y no se avisa a nadie. Las tarjetas de despacho que despachan automáticamente siguen enviando sus unidades. + \ 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 7ddf31092..8629a6461 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -948,7 +948,7 @@ Coordonnées du déclarant - ID externe de l'intervention + Identifiant de l'intervention (ID externe) ID de l'incident @@ -1313,6 +1313,12 @@ Un appel actif se clôture de lui-même lorsque la dernière unité engagée signale qu'elle est de nouveau en service ou hors service (un statut dont le type de base est Disponible, En caserne, Libéré, Indisponible ou Maintenance). Toutes les autres unités engagées doivent aussi avoir signalé leur fin depuis leur engagement. Seules les unités comptent : un appel sans unité engagée, un appel sous commandement d'incident actif ou une unité encore en retour ou en transport laisse l'appel ouvert pour le dispatcheur. + + Contrôle de la destination de transport + + + Lorsqu'une unité définit un statut dont le type de base est En transport ou À l'hôpital vers une destination différente de celle attribuée à son appel, les applications demandent un motif (la note du statut) avant l'enregistrement, et le changement est ajouté à l'appel sous forme de note. Les applications présélectionnent dans tous les cas la destination attribuée à l'appel pour ces statuts. + Relance des dispatchs @@ -2564,4 +2570,43 @@ Saisissez la clé API de ce fournisseur de recherche d'adresses. + + Page de carte + + + Contact principal + + + Contacts supplémentaires + + + Minuteries de pointage + + + Consentement 42 CFR Part 2 + + + Enregistrer en attente + + + Envoi au personnel + + + Envoi aux groupes + + + Envoi aux rôles + + + Envoi aux unités + + + Ce champ peut être masqué, mais pas rendu obligatoire. + + + Affiché avec n'importe quelle liste d'envoi + + + Toutes les listes d'envoi (personnel, groupes, rôles et unités) sont masquées. Les formulaires d'intervention proposeront à la place un bouton facultatif « Appel général » : avec lui, l'intervention est envoyée à tout le monde ; sans lui, elle est enregistrée pour information et personne n'est alerté. Les plans de départ à envoi automatique continuent d'engager leurs unités. + \ 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 15145cb78..cc4a2d183 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -948,7 +948,7 @@ Contatti del segnalante - ID chiamata esterno + Identificativo chiamata (ID esterno) ID incidente @@ -1313,6 +1313,12 @@ Una chiamata attiva si chiude da sola quando l'ultima unità inviata segnala di essere di nuovo in servizio o fuori servizio (uno stato con tipo base Disponibile, In sede, Liberata, Non disponibile o Manutenzione). Anche tutte le altre unità inviate devono aver segnalato la fine dopo l'invio. Contano solo le unità: una chiamata senza unità inviate, una chiamata con un comando dell'incidente attivo o un'unità ancora in rientro o in trasporto resta aperta per l'operatore. + + Controllo della destinazione di trasporto + + + Quando un'unità imposta uno stato con tipo base In trasporto o In ospedale verso una destinazione diversa da quella assegnata alla sua chiamata, le app chiedono un motivo (la nota dello stato) prima di salvare e la modifica viene aggiunta alla chiamata come nota. Le app preselezionano comunque la destinazione assegnata alla chiamata per questi stati. + Notifiche ripetute dell'invio @@ -2564,4 +2570,43 @@ Inserisci la chiave API di questo provider di ricerca indirizzi. + + Pagina mappa + + + Contatto principale + + + Contatti aggiuntivi + + + Timer di registrazione + + + Consenso 42 CFR Part 2 + + + Salva in attesa + + + Invio al personale + + + Invio ai gruppi + + + Invio ai ruoli + + + Invio alle unità + + + Questo campo può essere nascosto ma non reso obbligatorio. + + + Visibile con qualsiasi elenco di invio + + + Tutti gli elenchi di invio (personale, gruppi, ruoli e unità) sono nascosti. I moduli di chiamata offriranno invece un pulsante facoltativo «Chiamata generale»: con esso la chiamata va a tutti, senza di esso viene salvata solo a titolo informativo e nessuno viene allertato. Le schede di intervento con invio automatico continuano a inviare le loro unità. + \ 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 32db3989d..a39763a57 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -948,7 +948,7 @@ Dane kontaktowe zgłaszającego - Zewnętrzny identyfikator + Identyfikator zgłoszenia (zewnętrzny) Identyfikator zdarzenia @@ -1313,6 +1313,12 @@ Aktywne zgłoszenie zamyka się samo, gdy ostatnia zadysponowana do niego jednostka zgłosi powrót do służby lub wyłączenie z eksploatacji (status o typie bazowym Dostępna, W jednostce, Zwolniona, Niedostępna lub Serwis). Wszystkie pozostałe zadysponowane jednostki również muszą zgłosić zakończenie od chwili zadysponowania. Liczą się tylko jednostki: zgłoszenie bez zadysponowanej jednostki, zgłoszenie z aktywnym dowodzeniem incydentem lub jednostka, która wciąż wraca albo transportuje, pozostaje otwarte dla dyspozytora. + + Kontrola miejsca docelowego transportu + + + Gdy jednostka ustawia status o typie bazowym Transport lub W szpitalu z miejscem docelowym innym niż przypisane do jej zgłoszenia, aplikacje przed zapisaniem proszą o podanie powodu (notatka statusu), a zmiana jest dodawana do zgłoszenia jako notatka. Aplikacje i tak wstępnie wybierają przypisane miejsce docelowe zgłoszenia dla tych statusów. + Ponowne powiadamianie o dyspozycji @@ -2564,4 +2570,43 @@ Podaj klucz API tego dostawcy wyszukiwania adresów. + + Strona mapy + + + Kontakt główny + + + Dodatkowe kontakty + + + Czasomierze meldowania + + + Zgoda 42 CFR Part 2 + + + Zapisz jako oczekujące + + + Dysponowanie do personelu + + + Dysponowanie do grup + + + Dysponowanie do ról + + + Dysponowanie do jednostek + + + To pole można ukryć, ale nie można uczynić go wymaganym. + + + Widoczne razem z dowolną listą dysponowania + + + Wszystkie listy dysponowania (personel, grupy, role i jednostki) są ukryte. Formularze zdarzeń zaoferują zamiast nich opcjonalny przycisk „Alarm ogólny”: z nim zdarzenie trafia do wszystkich, bez niego jest zapisywane tylko informacyjnie i nikt nie jest alarmowany. Karty dysponowania z automatycznym dysponowaniem nadal wysyłają swoje jednostki. + \ 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 e01452777..ed61888b3 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -631,7 +631,7 @@ Reporter Contact Info - External Call ID + Call Identifier (External ID) Incident ID @@ -963,6 +963,12 @@ An active call closes on its own when the last unit dispatched to it reports back in service or out of service (a status whose base type is Available, In Quarters, Cleared, Unavailable or Maintenance). Every other dispatched unit must also have reported finished since its dispatch. Only units count: a call with no unit dispatched, a call under an active incident command, or a unit still returning or transporting keeps the call open for the dispatcher. + + Transport destination checks + + + When a unit sets a status whose base type is Transporting or At Hospital to a destination other than the one assigned on its call, the apps ask for a reason (the status note) before saving, and the change is added to the call as a note. The apps pre-select the call's assigned destination for these statuses either way. + Dispatch Re-notification @@ -2202,4 +2208,43 @@ Enter the API key for this address lookup provider. + + Map Page + + + Primary Contact + + + Additional Contacts + + + Check-In Timers + + + 42 CFR Part 2 Consent + + + Save as Pending + + + Dispatch to Personnel + + + Dispatch to Groups + + + Dispatch to Roles + + + Dispatch to Units + + + This field can be hidden but not required. + + + Shown with any dispatch list + + + Every dispatch list (personnel, groups, roles and units) is hidden. The call forms will offer an optional All Call button instead: with it a call goes to everyone, without it the call is saved for information only and nobody is paged. Run cards that dispatch automatically still send their units. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index 360433aad..4734da2b7 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -948,7 +948,7 @@ Anmälarens kontaktuppgifter - Externt ärende-ID + Ärendeidentifierare (externt ID) Händelse-ID @@ -1313,6 +1313,12 @@ Ett aktivt larm stängs av sig självt när den sista enheten som larmats till det rapporterar att den är tillbaka i tjänst eller ur tjänst (en status med bastypen Tillgänglig, På stationen, Klar, Otillgänglig eller Underhåll). Alla andra larmade enheter måste också ha rapporterat att de är klara sedan de larmades. Bara enheter räknas: ett larm utan larmad enhet, ett larm med aktiv insatsledning eller en enhet som fortfarande återvänder eller transporterar hålls öppet för operatören. + + Kontroll av transportdestination + + + När en enhet sätter en status med bastypen Transporterar eller På sjukhus till en annan destination än den som tilldelats larmet ber apparna om en anledning (statusanteckningen) innan den sparas, och ändringen läggs till i larmet som en anteckning. Apparna förväljer larmets tilldelade destination för dessa statusar i båda fallen. + Påminnelser om utskick @@ -2564,4 +2570,43 @@ Ange API-nyckeln till den här leverantören för adressuppslag. + + Kartsida + + + Primär kontakt + + + Ytterligare kontakter + + + Incheckningstimers + + + Samtycke enligt 42 CFR Part 2 + + + Spara som väntande + + + Larma personal + + + Larma grupper + + + Larma roller + + + Larma enheter + + + Det här fältet kan döljas men inte göras obligatoriskt. + + + Visas med valfri larmlista + + + Alla larmlistor (personal, grupper, roller och enheter) är dolda. Ärendeformulären erbjuder i stället en valfri knapp ”Allmänt larm”: med den går ärendet till alla, utan den sparas ärendet endast som information och ingen larmas. Larmplaner som larmar automatiskt skickar fortfarande sina enheter. + \ 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 767b26ea1..61a5e5a70 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -948,7 +948,7 @@ Контакти заявника - Зовнішній ідентифікатор + Ідентифікатор виклику (зовнішній) Ідентифікатор інциденту @@ -1313,6 +1313,12 @@ Активний виклик закривається сам, коли остання направлена на нього одиниця повідомляє, що повернулася до служби або виведена з експлуатації (статус із базовим типом Доступна, У частині, Звільнена, Недоступна або Обслуговування). Усі інші направлені одиниці також мають повідомити про завершення після направлення. Враховуються лише одиниці: виклик без направленої одиниці, виклик з активним командуванням інцидентом або одиниця, що ще повертається чи транспортує, залишає виклик відкритим для диспетчера. + + Перевірка пункту призначення транспортування + + + Коли одиниця встановлює статус із базовим типом «Транспортування» або «У лікарні» з пунктом призначення, відмінним від призначеного у її виклику, застосунки перед збереженням запитують причину (примітку до статусу), а зміну додають до виклику як примітку. Застосунки в будь-якому разі попередньо вибирають призначений пункт призначення виклику для цих статусів. + Повторне сповіщення про виклик @@ -2564,4 +2570,43 @@ Вкажіть ключ API для цього провайдера пошуку адрес. + + Сторінка карти + + + Основний контакт + + + Додаткові контакти + + + Таймери реєстрації + + + Згода 42 CFR Part 2 + + + Зберегти як очікуваний + + + Висилання персоналу + + + Висилання групам + + + Висилання ролям + + + Висилання підрозділам + + + Це поле можна приховати, але не можна зробити обов'язковим. + + + Відображається з будь-яким списком висилання + + + Усі списки висилання (персонал, групи, ролі та підрозділи) приховано. Натомість форми викликів пропонуватимуть необов'язкову кнопку «Загальний виклик»: з нею виклик надходить усім, без неї виклик зберігається лише для інформації й нікого не сповіщають. Картки виїзду з автоматичним висиланням і надалі надсилають свої підрозділи. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index bb3500ad2..49563aef1 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -549,4 +549,28 @@ موقع البلاغ + + نداء عام + + + نداء عام (إرسال إلى الجميع) + + + جميع قوائم الإرسال مخفية. فعّل النداء العام لإرسال هذا البلاغ إلى الجميع، أو اتركه متوقفًا لحفظ بلاغ للعلم فقط لا يُنبَّه فيه أحد. + + + فعّل النداء العام لإضافة الجميع إلى هذا البلاغ وتنبيههم. اتركه متوقفًا للإبقاء على مستلمي البلاغ الحاليين. + + + محدود + + + تحويل + + + مغلق + + + توفر الوجهات + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index b1476b6b4..b194c277d 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -1107,4 +1107,28 @@ Einsatzort + + Vollalarm + + + Vollalarm (alle alarmieren) + + + Alle Alarmierungslisten sind ausgeblendet. Schalten Sie Vollalarm ein, um diesen Einsatz an alle zu senden, oder lassen Sie ihn aus, um einen Einsatz nur zur Information zu speichern, bei dem niemand alarmiert wird. + + + Schalten Sie Vollalarm ein, um alle diesem Einsatz hinzuzufügen und zu alarmieren. Lassen Sie ihn aus, bleiben die bisherigen Empfänger unverändert. + + + Eingeschränkt + + + Abgemeldet + + + Geschlossen + + + Zielverfügbarkeit + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx index 4c34b7895..6f98b38c3 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx @@ -1019,4 +1019,28 @@ Τοποθεσία συμβάντος + + Γενική κλήση + + + Γενική κλήση (αποστολή σε όλους) + + + Όλες οι λίστες αποστολής είναι κρυφές. Ενεργοποιήστε τη Γενική κλήση για να σταλεί αυτή η κλήση σε όλους ή αφήστε την ανενεργή για να αποθηκευτεί μια ενημερωτική κλήση χωρίς ειδοποίηση. + + + Ενεργοποιήστε τη Γενική κλήση για να προστεθούν όλοι σε αυτή την κλήση και να ειδοποιηθούν. Αφήστε την ανενεργή για να διατηρηθούν οι υπάρχοντες παραλήπτες. + + + Περιορισμένο + + + Εκτροπή + + + Κλειστό + + + Διαθεσιμότητα προορισμών + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index 79add84f8..fe3da004b 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -1019,4 +1019,28 @@ Call location + + All Call + + + All Call (dispatch everyone) + + + Every dispatch list is hidden. Turn on All Call to send this call to everyone, or leave it off to save an informational call that pages nobody. + + + Turn on All Call to add everyone to this call and page them. Leave it off to keep the recipients the call already has. + + + Limited + + + Diversion + + + Closed + + + Destination availability + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index ed74d5ef5..efc872180 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -1165,4 +1165,28 @@ Ubicación de la llamada + + Llamada general + + + Llamada general (despachar a todos) + + + Todas las listas de despacho están ocultas. Active la llamada general para enviar esta llamada a todos, o déjela desactivada para guardar una llamada informativa que no avisa a nadie. + + + Active la llamada general para añadir a todos a esta llamada y avisarles. Déjela desactivada para mantener los destinatarios actuales. + + + Limitado + + + Desvío + + + Cerrado + + + Disponibilidad de destinos + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index 2e03578f8..775a992e4 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -1107,4 +1107,28 @@ Lieu de l'appel + + Appel général + + + Appel général (alerter tout le monde) + + + Toutes les listes d'envoi sont masquées. Activez l'appel général pour envoyer cette intervention à tout le monde, ou laissez-le désactivé pour enregistrer une intervention informative qui n'alerte personne. + + + Activez l'appel général pour ajouter tout le monde à cette intervention et les alerter. Laissez-le désactivé pour conserver les destinataires actuels. + + + Limité + + + Délestage + + + Fermé + + + Disponibilité des destinations + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index 83ba1a06e..e443c539a 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -1107,4 +1107,28 @@ Luogo della chiamata + + Chiamata generale + + + Chiamata generale (invia a tutti) + + + Tutti gli elenchi di invio sono nascosti. Attiva la chiamata generale per inviare questa chiamata a tutti, oppure lasciala disattivata per salvare una chiamata informativa che non allerta nessuno. + + + Attiva la chiamata generale per aggiungere tutti a questa chiamata e allertarli. Lasciala disattivata per mantenere i destinatari attuali. + + + Limitato + + + Dirottamento + + + Chiuso + + + Disponibilità delle destinazioni + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index d9123f360..b709a93de 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -1107,4 +1107,28 @@ Miejsce zgłoszenia + + Alarm ogólny + + + Alarm ogólny (dysponuj wszystkich) + + + Wszystkie listy dysponowania są ukryte. Włącz alarm ogólny, aby wysłać to zdarzenie do wszystkich, lub pozostaw go wyłączonym, aby zapisać zdarzenie informacyjne, które nikogo nie alarmuje. + + + Włącz alarm ogólny, aby dodać wszystkich do tego zdarzenia i ich zaalarmować. Pozostaw go wyłączonym, aby zachować obecnych odbiorców. + + + Ograniczone + + + Przekierowanie + + + Zamknięte + + + Dostępność miejsc docelowych + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index c2aba57f8..157ba4aaa 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -1107,4 +1107,28 @@ Händelsens plats + + Allmänt larm + + + Allmänt larm (larma alla) + + + Alla larmlistor är dolda. Slå på allmänt larm för att skicka ärendet till alla, eller låt det vara av för att spara ett informationsärende som inte larmar någon. + + + Slå på allmänt larm för att lägga till alla i ärendet och larma dem. Låt det vara av för att behålla ärendets nuvarande mottagare. + + + Begränsad + + + Omdirigering + + + Stängd + + + Destinationstillgänglighet + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index 9dfb162e1..0845b65d5 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -1107,4 +1107,28 @@ Місце виклику + + Загальний виклик + + + Загальний виклик (надіслати всім) + + + Усі списки висилання приховано. Увімкніть загальний виклик, щоб надіслати цей виклик усім, або залиште вимкненим, щоб зберегти інформаційний виклик без сповіщень. + + + Увімкніть загальний виклик, щоб додати всіх до цього виклику та сповістити їх. Залиште вимкненим, щоб зберегти поточних отримувачів. + + + Обмежено + + + Перенаправлення + + + Закрито + + + Доступність пунктів призначення + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.ar.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.ar.resx new file mode 100644 index 000000000..58965a110 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.ar.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + استيراد طبقة + + + استيراد طبقة + + + ارفع ملف KML أو KMZ أو GeoJSON (من Google My Maps أو Google Earth أو نظام معلومات جغرافية) يحتوي مسار فعالية أو موقعها أو نقاط توقفها. تصبح كل نقطة وخط ومضلع شكلاً في الطبقة باسمه ووصفه ولونه. أدخل تواريخ الفعالية لتظهر الطبقة على كل خريطة طوال مدة الفعالية. + + + تفاصيل الطبقة + + + الملف + + + KML أو KMZ أو GeoJSON، حتى {0} ميغابايت. + + + استبدال الملف + + + اختياري. يستبدل الملف الجديد كل أشكال الطبقة، وتبقى التفاصيل أعلاه كما هي. + + + حذف الطبقة (الخرائط ← الطبقات) يزيل كل أشكالها وتفاصيلها. + + + الاسم + + + الوصف + + + اللون الافتراضي + + + يُستخدم للأشكال التي لا يحدد لها الملف لوناً. + + + الفعالية + + + البداية + + + النهاية + + + بالمنطقة الزمنية للقسم ({0}). أثناء الفعالية تظهر الطبقة على كل خريطة مهما كان إعداد «مفعّلة افتراضياً». اترك الحقلين فارغين لطبقة غير مرتبطة بفعالية. + + + المنظِّم + + + المنسق / جهة الاتصال + + + هاتف جهة الاتصال + + + بريد جهة الاتصال + + + مفعّلة افتراضياً + + + إظهار الطبقة عند فتح كل خريطة، حتى خارج تواريخ الفعالية. + + + قابلة للبحث + + + استيراد + + + حفظ + + + تحرير الأشكال + + + التفاصيل + + + المصدر + + + مرسومة على الخريطة + + + عدد الأشكال: {0} + + + الفعالية + + + قادمة + + + جارية الآن + + + انتهت + + + تاريخ الاستيراد + + + اختر ملف KML أو KMZ أو GeoJSON لاستيراده. + + + ارفع ملفاً بامتداد ‎.kml أو ‎.kmz أو ‎.geojson أو ‎.json. + + + حجم الملف أكبر من {0} ميغابايت. + + + يحتوي الملف على أكثر من {0} شكل. قسّمه على عدة طبقات. + + + يحتوي الملف على أكثر من {0} إحداثية. بسّط الخطوط (تستطيع ذلك معظم أدوات نظم المعلومات الجغرافية) أو قسّمه على عدة طبقات. + + + لا يحتوي الملف على نقاط أو خطوط أو مضلعات ذات إحداثيات. + + + أماكن الملف ({0}) لها عنوان دون إحداثيات (طبقة Google My Maps محددة بالعنوان). استوردها بدلاً من ذلك كنقاط اهتمام (الخرائط ← نقاط الاهتمام ← استيراد) حيث يُبحث عن العناوين، أو صدّر الخريطة مع الإحداثيات. + + + تعذرت قراءة الملف. تحقق من أنه ملف KML أو KMZ أو GeoJSON صالح. + + + لا يمكن أن تنتهي الفعالية قبل أن تبدأ. + + + أدخل عنوان بريد إلكتروني صالحاً. + + + اختر لوناً. + + + تعذر حفظ الطبقة. حاول مرة أخرى. + + + تم استيراد «{0}»: نقاط {1}، خطوط {2}، مضلعات {3}. + + + تم استبدال أشكال «{0}»: نقاط {1}، خطوط {2}، مضلعات {3}. + + + تم حفظ «{0}». + + + تم استبعاد {0} علامة دون إحداثيات صالحة. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.cs b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.cs new file mode 100644 index 000000000..07a69515f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.cs @@ -0,0 +1,10 @@ +namespace Resgrid.Localization.Areas.User.Mapping +{ + /// + /// Text for Mapping → Layers → Import Layer and a layer's Details page: layers imported from KML, KMZ or GeoJSON files + /// and the event they map (dates, organizer, contact). + /// + public class EventLayers + { + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.de.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.de.resx new file mode 100644 index 000000000..d614fcbb8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.de.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Ebene importieren + + + Ebene importieren + + + Laden Sie eine KML-, KMZ- oder GeoJSON-Datei (aus Google My Maps, Google Earth oder einem GIS) mit der Strecke, dem Gelände oder den Standorten einer Veranstaltung hoch. Jeder Punkt, jede Linie und jedes Polygon wird zu einer Form auf der Ebene, mit eigenem Namen, eigener Beschreibung und Farbe. Geben Sie der Ebene die Termine der Veranstaltung, dann wird sie während der Veranstaltung auf jeder Karte angezeigt. + + + Ebenendetails + + + Datei + + + KML, KMZ oder GeoJSON, bis {0} MB. + + + Datei ersetzen + + + Optional. Eine neue Datei ersetzt alle Formen der Ebene; die Angaben oben bleiben erhalten. + + + Das Löschen der Ebene (Kartierung → Ebenen) entfernt alle ihre Formen und Angaben. + + + Name + + + Beschreibung + + + Standardfarbe + + + Für Formen, denen die Datei keine eigene Farbe gibt. + + + Veranstaltung + + + Beginn + + + Ende + + + In der Zeitzone der Abteilung ({0}). Während die Veranstaltung läuft, wird die Ebene auf jeder Karte angezeigt, unabhängig von „Standardmäßig an“. Lassen Sie beide leer für eine Ebene ohne Veranstaltung. + + + Veranstalter + + + Koordinator / Kontakt + + + Telefon des Kontakts + + + E-Mail des Kontakts + + + Standardmäßig an + + + Die Ebene beim Öffnen jeder Karte anzeigen, auch außerhalb der Veranstaltungstermine. + + + Durchsuchbar + + + Importieren + + + Speichern + + + Formen bearbeiten + + + Details + + + Quelle + + + Auf der Karte gezeichnet + + + {0} Formen + + + Veranstaltung + + + Bevorstehend + + + Läuft gerade + + + Beendet + + + importiert + + + Wählen Sie eine KML-, KMZ- oder GeoJSON-Datei zum Importieren. + + + Laden Sie eine .kml-, .kmz-, .geojson- oder .json-Datei hoch. + + + Die Datei ist größer als {0} MB. + + + Die Datei hat mehr als {0} Formen. Teilen Sie sie auf mehrere Ebenen auf. + + + Die Datei hat mehr als {0} Koordinaten. Vereinfachen Sie die Linien (die meisten GIS-Programme können das) oder teilen Sie sie auf mehrere Ebenen auf. + + + Die Datei enthält keine Punkte, Linien oder Polygone mit Koordinaten. + + + Die {0} Orte der Datei haben eine Adresse, aber keine Koordinaten (eine Google-My-Maps-Ebene, die nach Adresse verortet ist). Importieren Sie sie stattdessen als POIs (Kartierung → POIs → Importieren), wo die Adressen nachgeschlagen werden, oder exportieren Sie die Karte mit Koordinaten. + + + Die Datei konnte nicht gelesen werden. Prüfen Sie, ob es eine gültige KML-, KMZ- oder GeoJSON-Datei ist. + + + Die Veranstaltung kann nicht enden, bevor sie beginnt. + + + Geben Sie eine gültige E-Mail-Adresse ein. + + + Wählen Sie eine Farbe. + + + Die Ebene konnte nicht gespeichert werden. Bitte versuchen Sie es erneut. + + + „{0}“ importiert: {1} Punkte, {2} Linien, {3} Polygone. + + + Formen von „{0}“ ersetzt: {1} Punkte, {2} Linien, {3} Polygone. + + + „{0}“ gespeichert. + + + {0} Ortsmarken ohne verwendbare Koordinaten wurden ausgelassen. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.el.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.el.resx new file mode 100644 index 000000000..01758a215 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.el.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Εισαγωγή επιπέδου + + + Εισαγωγή επιπέδου + + + Ανεβάστε ένα αρχείο KML, KMZ ή GeoJSON (από Google My Maps, Google Earth ή GIS) με τη διαδρομή, τον χώρο ή τα σημεία μιας εκδήλωσης. Κάθε σημείο, γραμμή και πολύγωνο γίνεται σχήμα του επιπέδου με δικό του όνομα, περιγραφή και χρώμα. Δώστε στο επίπεδο τις ημερομηνίες της εκδήλωσης και θα εμφανίζεται σε κάθε χάρτη όσο διαρκεί. + + + Στοιχεία επιπέδου + + + Αρχείο + + + KML, KMZ ή GeoJSON, έως {0} MB. + + + Αντικατάσταση αρχείου + + + Προαιρετικό. Ένα νέο αρχείο αντικαθιστά όλα τα σχήματα του επιπέδου· τα παραπάνω στοιχεία διατηρούνται. + + + Η διαγραφή του επιπέδου (Χαρτογράφηση → Επίπεδα) αφαιρεί όλα τα σχήματα και τα στοιχεία του. + + + Όνομα + + + Περιγραφή + + + Προεπιλεγμένο χρώμα + + + Για σχήματα στα οποία το αρχείο δεν δίνει δικό τους χρώμα. + + + Εκδήλωση + + + Έναρξη + + + Λήξη + + + Στη ζώνη ώρας του τμήματος ({0}). Όσο διαρκεί η εκδήλωση, το επίπεδο εμφανίζεται σε κάθε χάρτη, ανεξάρτητα από τη ρύθμιση «Ενεργό από προεπιλογή». Αφήστε και τα δύο κενά για επίπεδο χωρίς εκδήλωση. + + + Διοργανωτής + + + Συντονιστής / Επαφή + + + Τηλέφωνο επαφής + + + Email επαφής + + + Ενεργό από προεπιλογή + + + Εμφάνιση του επιπέδου με το άνοιγμα κάθε χάρτη, και εκτός των ημερομηνιών της εκδήλωσης. + + + Με δυνατότητα αναζήτησης + + + Εισαγωγή + + + Αποθήκευση + + + Επεξεργασία σχημάτων + + + Στοιχεία + + + Προέλευση + + + Σχεδιασμένο στον χάρτη + + + {0} σχήματα + + + Εκδήλωση + + + Επερχόμενη + + + Σε εξέλιξη + + + Ολοκληρώθηκε + + + εισήχθη + + + Επιλέξτε ένα αρχείο KML, KMZ ή GeoJSON για εισαγωγή. + + + Ανεβάστε ένα αρχείο .kml, .kmz, .geojson ή .json. + + + Το αρχείο υπερβαίνει τα {0} MB. + + + Το αρχείο έχει περισσότερα από {0} σχήματα. Χωρίστε το σε περισσότερα επίπεδα. + + + Το αρχείο έχει περισσότερες από {0} συντεταγμένες. Απλοποιήστε τις γραμμές (τα περισσότερα GIS το κάνουν) ή χωρίστε το σε περισσότερα επίπεδα. + + + Το αρχείο δεν έχει σημεία, γραμμές ή πολύγωνα με συντεταγμένες. + + + Οι {0} τοποθεσίες του αρχείου έχουν διεύθυνση αλλά όχι συντεταγμένες (επίπεδο Google My Maps που τοποθετείται με διεύθυνση). Εισαγάγετέ τες ως σημεία ενδιαφέροντος (Χαρτογράφηση → POI → Εισαγωγή), όπου αναζητούνται οι διευθύνσεις, ή εξαγάγετε τον χάρτη με συντεταγμένες. + + + Δεν ήταν δυνατή η ανάγνωση του αρχείου. Ελέγξτε ότι είναι έγκυρο αρχείο KML, KMZ ή GeoJSON. + + + Η εκδήλωση δεν μπορεί να λήξει πριν ξεκινήσει. + + + Εισαγάγετε έγκυρη διεύθυνση email. + + + Επιλέξτε χρώμα. + + + Δεν ήταν δυνατή η αποθήκευση του επιπέδου. Δοκιμάστε ξανά. + + + Εισήχθη το «{0}»: {1} σημεία, {2} γραμμές, {3} πολύγωνα. + + + Αντικαταστάθηκαν τα σχήματα του «{0}»: {1} σημεία, {2} γραμμές, {3} πολύγωνα. + + + Αποθηκεύτηκε το «{0}». + + + Παραλείφθηκαν {0} σημάνσεις χωρίς χρήσιμες συντεταγμένες. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.en.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.en.resx new file mode 100644 index 000000000..8b969d159 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.en.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Import Layer + + + Import Layer + + + Upload a KML, KMZ or GeoJSON file (from Google My Maps, Google Earth or a GIS system) with an event's route, site or stops. Every point, line and polygon becomes a shape on the layer with its own name, description and color. Give the layer the event's dates and it is shown on every map while the event runs. + + + Layer Details + + + File + + + KML, KMZ or GeoJSON, up to {0} MB. + + + Replace File + + + Optional. A new file replaces every shape on the layer; the details above are kept. + + + Deleting the layer (Mapping → Layers) removes all of its shapes and details. + + + Name + + + Description + + + Default Color + + + Used for shapes the file gives no color of their own. + + + Event + + + Starts + + + Ends + + + In the department's time zone ({0}). While the event runs, the layer is shown on every map whatever "On by default" says. Leave both blank for a layer that is not tied to an event. + + + Organizer + + + Coordinator / Contact + + + Contact Phone + + + Contact Email + + + On by default + + + Show the layer on every map when it opens, outside the event's dates too. + + + Searchable + + + Import + + + Save + + + Edit Shapes + + + Details + + + Source + + + Drawn on the map + + + {0} shapes + + + Event + + + Upcoming + + + Active now + + + Ended + + + imported + + + Choose a KML, KMZ or GeoJSON file to import. + + + Upload a .kml, .kmz, .geojson or .json file. + + + The file is larger than {0} MB. + + + The file has more than {0} shapes. Split it into several layers. + + + The file has more than {0} coordinates. Simplify the lines (most GIS tools can) or split it into several layers. + + + The file has no points, lines or polygons with coordinates. + + + The file's {0} places have an address but no coordinates (a Google My Maps layer located by address). Import them as points of interest instead (Mapping → POIs → Import), which looks the addresses up, or export the map with coordinates. + + + The file could not be read. Check that it is a valid KML, KMZ or GeoJSON file. + + + The event cannot end before it starts. + + + Enter a valid email address. + + + Pick a color. + + + The layer could not be saved. Please try again. + + + Imported "{0}": {1} points, {2} lines, {3} polygons. + + + Replaced the shapes of "{0}": {1} points, {2} lines, {3} polygons. + + + Saved "{0}". + + + {0} placemarks without usable coordinates were left out. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.es.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.es.resx new file mode 100644 index 000000000..04cead9a1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.es.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Importar capa + + + Importar capa + + + Suba un archivo KML, KMZ o GeoJSON (de Google My Maps, Google Earth o un SIG) con el recorrido, el recinto o las paradas de un evento. Cada punto, línea y polígono se convierte en una forma de la capa con su propio nombre, descripción y color. Asigne a la capa las fechas del evento y se mostrará en todos los mapas mientras dure. + + + Detalles de la capa + + + Archivo + + + KML, KMZ o GeoJSON, hasta {0} MB. + + + Reemplazar archivo + + + Opcional. Un archivo nuevo reemplaza todas las formas de la capa; los datos de arriba se conservan. + + + Eliminar la capa (Mapas → Capas) elimina todas sus formas y datos. + + + Nombre + + + Descripción + + + Color predeterminado + + + Se usa para las formas a las que el archivo no da color propio. + + + Evento + + + Inicio + + + Fin + + + En la zona horaria del departamento ({0}). Mientras dura el evento, la capa se muestra en todos los mapas, diga lo que diga «Activa por defecto». Deje ambos en blanco para una capa sin evento. + + + Organizador + + + Coordinador / Contacto + + + Teléfono de contacto + + + Correo de contacto + + + Activa por defecto + + + Mostrar la capa al abrir cada mapa, también fuera de las fechas del evento. + + + Buscable + + + Importar + + + Guardar + + + Editar formas + + + Detalles + + + Origen + + + Dibujada en el mapa + + + {0} formas + + + Evento + + + Próximo + + + En curso + + + Finalizado + + + importada + + + Elija un archivo KML, KMZ o GeoJSON para importar. + + + Suba un archivo .kml, .kmz, .geojson o .json. + + + El archivo supera los {0} MB. + + + El archivo tiene más de {0} formas. Divídalo en varias capas. + + + El archivo tiene más de {0} coordenadas. Simplifique las líneas (la mayoría de los SIG pueden hacerlo) o divídalo en varias capas. + + + El archivo no tiene puntos, líneas ni polígonos con coordenadas. + + + Los {0} lugares del archivo tienen dirección pero no coordenadas (una capa de Google My Maps ubicada por dirección). Impórtelos como puntos de interés (Mapas → POI → Importar), que busca las direcciones, o exporte el mapa con coordenadas. + + + No se pudo leer el archivo. Compruebe que sea un archivo KML, KMZ o GeoJSON válido. + + + El evento no puede terminar antes de empezar. + + + Introduzca un correo electrónico válido. + + + Elija un color. + + + No se pudo guardar la capa. Inténtelo de nuevo. + + + «{0}» importada: {1} puntos, {2} líneas, {3} polígonos. + + + Formas de «{0}» reemplazadas: {1} puntos, {2} líneas, {3} polígonos. + + + «{0}» guardada. + + + Se omitieron {0} marcadores sin coordenadas utilizables. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.fr.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.fr.resx new file mode 100644 index 000000000..aa0b2d519 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.fr.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Importer une couche + + + Importer une couche + + + Téléversez un fichier KML, KMZ ou GeoJSON (Google My Maps, Google Earth ou un SIG) contenant le parcours, le site ou les points d'arrêt d'un événement. Chaque point, ligne et polygone devient une forme de la couche, avec son propre nom, sa description et sa couleur. Donnez à la couche les dates de l'événement : elle s'affiche alors sur toutes les cartes pendant l'événement. + + + Détails de la couche + + + Fichier + + + KML, KMZ ou GeoJSON, jusqu'à {0} Mo. + + + Remplacer le fichier + + + Facultatif. Un nouveau fichier remplace toutes les formes de la couche ; les informations ci-dessus sont conservées. + + + Supprimer la couche (Cartographie → Couches) supprime toutes ses formes et informations. + + + Nom + + + Description + + + Couleur par défaut + + + Utilisée pour les formes auxquelles le fichier ne donne pas de couleur. + + + Événement + + + Début + + + Fin + + + Dans le fuseau horaire du département ({0}). Pendant l'événement, la couche s'affiche sur toutes les cartes, quel que soit le réglage « Affichée par défaut ». Laissez les deux vides pour une couche sans événement. + + + Organisateur + + + Coordinateur / Contact + + + Téléphone du contact + + + E-mail du contact + + + Affichée par défaut + + + Afficher la couche à l'ouverture de chaque carte, y compris en dehors des dates de l'événement. + + + Recherchable + + + Importer + + + Enregistrer + + + Modifier les formes + + + Détails + + + Source + + + Dessinée sur la carte + + + {0} formes + + + Événement + + + À venir + + + En cours + + + Terminé + + + importé le + + + Choisissez un fichier KML, KMZ ou GeoJSON à importer. + + + Téléversez un fichier .kml, .kmz, .geojson ou .json. + + + Le fichier dépasse {0} Mo. + + + Le fichier contient plus de {0} formes. Répartissez-le sur plusieurs couches. + + + Le fichier contient plus de {0} coordonnées. Simplifiez les lignes (la plupart des SIG le permettent) ou répartissez-le sur plusieurs couches. + + + Le fichier ne contient aucun point, ligne ou polygone avec des coordonnées. + + + Les {0} lieux du fichier ont une adresse mais pas de coordonnées (une couche Google My Maps localisée par adresse). Importez-les plutôt comme points d'intérêt (Cartographie → POI → Importer), qui recherche les adresses, ou exportez la carte avec les coordonnées. + + + Le fichier n'a pas pu être lu. Vérifiez qu'il s'agit d'un fichier KML, KMZ ou GeoJSON valide. + + + L'événement ne peut pas se terminer avant de commencer. + + + Saisissez une adresse e-mail valide. + + + Choisissez une couleur. + + + La couche n'a pas pu être enregistrée. Veuillez réessayer. + + + « {0} » importée : {1} points, {2} lignes, {3} polygones. + + + Formes de « {0} » remplacées : {1} points, {2} lignes, {3} polygones. + + + « {0} » enregistrée. + + + {0} repères sans coordonnées utilisables ont été ignorés. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.it.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.it.resx new file mode 100644 index 000000000..776637ede --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.it.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Importa livello + + + Importa livello + + + Carica un file KML, KMZ o GeoJSON (da Google My Maps, Google Earth o un GIS) con il percorso, l'area o le tappe di un evento. Ogni punto, linea e poligono diventa una forma del livello con nome, descrizione e colore propri. Assegna al livello le date dell'evento e verrà mostrato su ogni mappa mentre l'evento è in corso. + + + Dettagli del livello + + + File + + + KML, KMZ o GeoJSON, fino a {0} MB. + + + Sostituisci file + + + Facoltativo. Un nuovo file sostituisce tutte le forme del livello; i dati sopra vengono mantenuti. + + + Eliminare il livello (Mappe → Livelli) rimuove tutte le sue forme e i suoi dati. + + + Nome + + + Descrizione + + + Colore predefinito + + + Usato per le forme a cui il file non assegna un colore. + + + Evento + + + Inizio + + + Fine + + + Nel fuso orario del dipartimento ({0}). Durante l'evento il livello è mostrato su ogni mappa, qualunque sia l'impostazione «Attivo per impostazione predefinita». Lascia entrambi vuoti per un livello non legato a un evento. + + + Organizzatore + + + Coordinatore / Contatto + + + Telefono del contatto + + + E-mail del contatto + + + Attivo per impostazione predefinita + + + Mostra il livello all'apertura di ogni mappa, anche fuori dalle date dell'evento. + + + Ricercabile + + + Importa + + + Salva + + + Modifica forme + + + Dettagli + + + Origine + + + Disegnato sulla mappa + + + {0} forme + + + Evento + + + In arrivo + + + In corso + + + Terminato + + + importato + + + Scegli un file KML, KMZ o GeoJSON da importare. + + + Carica un file .kml, .kmz, .geojson o .json. + + + Il file supera {0} MB. + + + Il file ha più di {0} forme. Dividilo in più livelli. + + + Il file ha più di {0} coordinate. Semplifica le linee (la maggior parte dei GIS lo consente) o dividilo in più livelli. + + + Il file non contiene punti, linee o poligoni con coordinate. + + + I {0} luoghi del file hanno un indirizzo ma non coordinate (un livello di Google My Maps localizzato per indirizzo). Importali invece come punti di interesse (Mappe → POI → Importa), che cerca gli indirizzi, oppure esporta la mappa con le coordinate. + + + Impossibile leggere il file. Verifica che sia un file KML, KMZ o GeoJSON valido. + + + L'evento non può finire prima di iniziare. + + + Inserisci un indirizzo e-mail valido. + + + Scegli un colore. + + + Impossibile salvare il livello. Riprova. + + + «{0}» importato: {1} punti, {2} linee, {3} poligoni. + + + Forme di «{0}» sostituite: {1} punti, {2} linee, {3} poligoni. + + + «{0}» salvato. + + + {0} segnaposto senza coordinate utilizzabili sono stati esclusi. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.pl.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.pl.resx new file mode 100644 index 000000000..419515a97 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.pl.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Importuj warstwę + + + Importuj warstwę + + + Prześlij plik KML, KMZ lub GeoJSON (z Google My Maps, Google Earth lub systemu GIS) z trasą, terenem lub punktami wydarzenia. Każdy punkt, linia i wielokąt staje się kształtem warstwy z własną nazwą, opisem i kolorem. Nadaj warstwie daty wydarzenia, a będzie widoczna na każdej mapie w czasie jego trwania. + + + Szczegóły warstwy + + + Plik + + + KML, KMZ lub GeoJSON, do {0} MB. + + + Zastąp plik + + + Opcjonalnie. Nowy plik zastępuje wszystkie kształty warstwy; powyższe dane zostają zachowane. + + + Usunięcie warstwy (Mapy → Warstwy) usuwa wszystkie jej kształty i dane. + + + Nazwa + + + Opis + + + Kolor domyślny + + + Używany dla kształtów, którym plik nie nadaje własnego koloru. + + + Wydarzenie + + + Początek + + + Koniec + + + W strefie czasowej jednostki ({0}). W czasie wydarzenia warstwa jest widoczna na każdej mapie, niezależnie od ustawienia „Domyślnie włączona”. Pozostaw oba pola puste dla warstwy niezwiązanej z wydarzeniem. + + + Organizator + + + Koordynator / Kontakt + + + Telefon kontaktowy + + + E-mail kontaktowy + + + Domyślnie włączona + + + Pokazuj warstwę po otwarciu każdej mapy, także poza datami wydarzenia. + + + Możliwa do wyszukania + + + Importuj + + + Zapisz + + + Edytuj kształty + + + Szczegóły + + + Źródło + + + Narysowana na mapie + + + Kształty: {0} + + + Wydarzenie + + + Nadchodzące + + + Trwa teraz + + + Zakończone + + + zaimportowano + + + Wybierz plik KML, KMZ lub GeoJSON do zaimportowania. + + + Prześlij plik .kml, .kmz, .geojson lub .json. + + + Plik jest większy niż {0} MB. + + + Plik ma więcej niż {0} kształtów. Podziel go na kilka warstw. + + + Plik ma więcej niż {0} współrzędnych. Uprość linie (potrafi to większość programów GIS) lub podziel go na kilka warstw. + + + Plik nie zawiera punktów, linii ani wielokątów ze współrzędnymi. + + + Miejsca w pliku ({0}) mają adres, ale nie mają współrzędnych (warstwa Google My Maps lokalizowana według adresu). Zaimportuj je jako punkty zainteresowania (Mapy → POI → Importuj), gdzie adresy zostaną wyszukane, albo wyeksportuj mapę ze współrzędnymi. + + + Nie udało się odczytać pliku. Sprawdź, czy to poprawny plik KML, KMZ lub GeoJSON. + + + Wydarzenie nie może skończyć się przed rozpoczęciem. + + + Podaj poprawny adres e-mail. + + + Wybierz kolor. + + + Nie udało się zapisać warstwy. Spróbuj ponownie. + + + Zaimportowano „{0}”: punkty: {1}, linie: {2}, wielokąty: {3}. + + + Zastąpiono kształty „{0}”: punkty: {1}, linie: {2}, wielokąty: {3}. + + + Zapisano „{0}”. + + + Pominięto znaczniki bez użytecznych współrzędnych: {0}. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.resx new file mode 100644 index 000000000..8b969d159 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Import Layer + + + Import Layer + + + Upload a KML, KMZ or GeoJSON file (from Google My Maps, Google Earth or a GIS system) with an event's route, site or stops. Every point, line and polygon becomes a shape on the layer with its own name, description and color. Give the layer the event's dates and it is shown on every map while the event runs. + + + Layer Details + + + File + + + KML, KMZ or GeoJSON, up to {0} MB. + + + Replace File + + + Optional. A new file replaces every shape on the layer; the details above are kept. + + + Deleting the layer (Mapping → Layers) removes all of its shapes and details. + + + Name + + + Description + + + Default Color + + + Used for shapes the file gives no color of their own. + + + Event + + + Starts + + + Ends + + + In the department's time zone ({0}). While the event runs, the layer is shown on every map whatever "On by default" says. Leave both blank for a layer that is not tied to an event. + + + Organizer + + + Coordinator / Contact + + + Contact Phone + + + Contact Email + + + On by default + + + Show the layer on every map when it opens, outside the event's dates too. + + + Searchable + + + Import + + + Save + + + Edit Shapes + + + Details + + + Source + + + Drawn on the map + + + {0} shapes + + + Event + + + Upcoming + + + Active now + + + Ended + + + imported + + + Choose a KML, KMZ or GeoJSON file to import. + + + Upload a .kml, .kmz, .geojson or .json file. + + + The file is larger than {0} MB. + + + The file has more than {0} shapes. Split it into several layers. + + + The file has more than {0} coordinates. Simplify the lines (most GIS tools can) or split it into several layers. + + + The file has no points, lines or polygons with coordinates. + + + The file's {0} places have an address but no coordinates (a Google My Maps layer located by address). Import them as points of interest instead (Mapping → POIs → Import), which looks the addresses up, or export the map with coordinates. + + + The file could not be read. Check that it is a valid KML, KMZ or GeoJSON file. + + + The event cannot end before it starts. + + + Enter a valid email address. + + + Pick a color. + + + The layer could not be saved. Please try again. + + + Imported "{0}": {1} points, {2} lines, {3} polygons. + + + Replaced the shapes of "{0}": {1} points, {2} lines, {3} polygons. + + + Saved "{0}". + + + {0} placemarks without usable coordinates were left out. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.sv.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.sv.resx new file mode 100644 index 000000000..60240b7b9 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.sv.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Importera lager + + + Importera lager + + + Ladda upp en KML-, KMZ- eller GeoJSON-fil (från Google My Maps, Google Earth eller ett GIS) med ett evenemangs bana, område eller stationer. Varje punkt, linje och polygon blir en form i lagret med eget namn, egen beskrivning och färg. Ge lagret evenemangets datum så visas det på alla kartor medan evenemanget pågår. + + + Lagerdetaljer + + + Fil + + + KML, KMZ eller GeoJSON, upp till {0} MB. + + + Ersätt fil + + + Valfritt. En ny fil ersätter alla former i lagret; uppgifterna ovan behålls. + + + Att ta bort lagret (Kartor → Lager) tar bort alla dess former och uppgifter. + + + Namn + + + Beskrivning + + + Standardfärg + + + Används för former som filen inte ger någon egen färg. + + + Evenemang + + + Börjar + + + Slutar + + + I avdelningens tidszon ({0}). Medan evenemanget pågår visas lagret på alla kartor oavsett "På som standard". Lämna båda tomma för ett lager som inte hör till ett evenemang. + + + Arrangör + + + Samordnare / Kontakt + + + Kontaktens telefon + + + Kontaktens e-post + + + På som standard + + + Visa lagret när varje karta öppnas, även utanför evenemangets datum. + + + Sökbart + + + Importera + + + Spara + + + Redigera former + + + Detaljer + + + Källa + + + Ritat på kartan + + + {0} former + + + Evenemang + + + Kommande + + + Pågår nu + + + Avslutat + + + importerat + + + Välj en KML-, KMZ- eller GeoJSON-fil att importera. + + + Ladda upp en .kml-, .kmz-, .geojson- eller .json-fil. + + + Filen är större än {0} MB. + + + Filen har fler än {0} former. Dela upp den i flera lager. + + + Filen har fler än {0} koordinater. Förenkla linjerna (de flesta GIS-verktyg kan) eller dela upp den i flera lager. + + + Filen har inga punkter, linjer eller polygoner med koordinater. + + + Filens {0} platser har en adress men inga koordinater (ett Google My Maps-lager placerat efter adress). Importera dem i stället som intressepunkter (Kartor → POI → Importera), där adresserna slås upp, eller exportera kartan med koordinater. + + + Filen kunde inte läsas. Kontrollera att det är en giltig KML-, KMZ- eller GeoJSON-fil. + + + Evenemanget kan inte sluta innan det börjar. + + + Ange en giltig e-postadress. + + + Välj en färg. + + + Lagret kunde inte sparas. Försök igen. + + + Importerade "{0}": {1} punkter, {2} linjer, {3} polygoner. + + + Ersatte formerna i "{0}": {1} punkter, {2} linjer, {3} polygoner. + + + Sparade "{0}". + + + {0} platsmarkeringar utan användbara koordinater utelämnades. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.uk.resx b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.uk.resx new file mode 100644 index 000000000..ff412ae37 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/EventLayers.uk.resx @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Імпортувати шар + + + Імпортувати шар + + + Завантажте файл KML, KMZ або GeoJSON (з Google My Maps, Google Earth або ГІС) із маршрутом, територією чи пунктами заходу. Кожна точка, лінія й полігон стають фігурою шару з власною назвою, описом і кольором. Вкажіть дати заходу, і шар показуватиметься на кожній карті, поки захід триває. + + + Відомості про шар + + + Файл + + + KML, KMZ або GeoJSON, до {0} МБ. + + + Замінити файл + + + Необов'язково. Новий файл замінює всі фігури шару; відомості вище зберігаються. + + + Видалення шару (Карти → Шари) видаляє всі його фігури й відомості. + + + Назва + + + Опис + + + Колір за замовчуванням + + + Для фігур, яким файл не задає власного кольору. + + + Захід + + + Початок + + + Кінець + + + У часовому поясі підрозділу ({0}). Поки захід триває, шар показується на кожній карті незалежно від параметра «Увімкнено за замовчуванням». Залиште обидва порожніми для шару, не пов'язаного із заходом. + + + Організатор + + + Координатор / Контакт + + + Телефон контакту + + + Ел. пошта контакту + + + Увімкнено за замовчуванням + + + Показувати шар під час відкриття кожної карти, також поза датами заходу. + + + Доступний для пошуку + + + Імпортувати + + + Зберегти + + + Редагувати фігури + + + Відомості + + + Джерело + + + Намальовано на карті + + + Фігур: {0} + + + Захід + + + Майбутній + + + Триває + + + Завершено + + + імпортовано + + + Виберіть файл KML, KMZ або GeoJSON для імпорту. + + + Завантажте файл .kml, .kmz, .geojson або .json. + + + Файл більший за {0} МБ. + + + Файл містить понад {0} фігур. Розділіть його на кілька шарів. + + + Файл містить понад {0} координат. Спростіть лінії (це вміє більшість ГІС) або розділіть його на кілька шарів. + + + Файл не містить точок, ліній чи полігонів із координатами. + + + Місця у файлі ({0}) мають адресу, але не мають координат (шар Google My Maps, розміщений за адресою). Імпортуйте їх як точки інтересу (Карти → POI → Імпорт), де адреси буде знайдено, або експортуйте карту з координатами. + + + Не вдалося прочитати файл. Перевірте, що це дійсний файл KML, KMZ або GeoJSON. + + + Захід не може закінчитися раніше, ніж почнеться. + + + Введіть дійсну адресу ел. пошти. + + + Виберіть колір. + + + Не вдалося зберегти шар. Спробуйте ще раз. + + + Імпортовано «{0}»: точок {1}, ліній {2}, полігонів {3}. + + + Замінено фігури «{0}»: точок {1}, ліній {2}, полігонів {3}. + + + Збережено «{0}». + + + Пропущено позначок без придатних координат: {0}. + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.ar.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.ar.resx index 443fec32e..128d957a4 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.ar.resx @@ -205,4 +205,136 @@ تحديد نقطة الاهتمام + + قدرات نقاط الاهتمام + + + تصف القدرات ما تقدمه وجهاتك ونقاط الاهتمام الأخرى، مثل مختبر القسطرة القلبية أو وحدة السكتة الدماغية أو مركز الإصابات. حدّدها لكل نقطة اهتمام؛ يراها المرسلون عند اختيار الوجهة ويمكنهم الإبلاغ عن عدم توفر إحداها. + + + ما تقدمه نقطة الاهتمام هذه. يراها المرسلون عند اختيار الوجهة. + + + إضافة قدرة + + + اسم القدرة، مثل مختبر القسطرة القلبية + + + ترتيب الفرز + + + تُعرض القدرات حسب ترتيب الفرز ثم حسب الاسم. اترك ترتيب الفرز فارغًا لإضافة قدرة في النهاية. + + + لم يتم تعريف أي قدرات بعد. + + + إدارة القدرات + + + حذف القدرة «{0}»؟ ستُزال من جميع نقاط الاهتمام التي تقدمها ({1}) ومن كل تقرير توفر. + + + أدخل اسم قدرة لا يتجاوز {0} حرفًا. + + + توجد بالفعل قدرة باسم «{0}». + + + تم حفظ القدرة «{0}». + + + تم حذف القدرة «{0}». + + + تعذّر حفظ القدرة. أعد تحميل الصفحة وحاول مرة أخرى. + + + توفر الوجهات + + + سجّل متى تبلغ وجهة مثل مستشفى عن سعة محدودة أو تحويل أو إغلاق، أو أن إحدى قدراتها غير متوفرة. يرى المرسلون ذلك عند اختيار وجهة البلاغ. + + + النوع + + + التوفر + + + تعيين التوفر + + + لا توجد نقاط اهتمام وجهة بعد. حدّد نوع نقطة اهتمام كوجهة لعرض نقاطه هنا. + + + مفتوح + + + محدود + + + تحويل + + + مغلق + + + غير متوفر: + + + حتى {0} + + + الحالي + + + عُيّن في {0} + + + «مفتوح» يلغي التقرير. «محدود» يستقبل المرضى بسعة منخفضة؛ «تحويل» يطلب من الطواقم التوجه إلى مكان آخر ما لم يكن هناك بديل؛ «مغلق» لا يستقبل أحدًا. + + + حدّد ما لا تستطيع هذه الوجهة تقديمه الآن. + + + لا توجد قدرات محددة لهذه الوجهة. + + + اختياري، بحد أقصى {0} حرفًا. + + + ينتهي + + + متى ينتهي التقرير وتُعتبر الوجهة مفتوحة مجددًا. + + + بلا انتهاء (حتى التغيير) + + + الإبقاء على الحالي ({0}) + + + خلال ساعة واحدة + + + خلال {0} ساعة + + + اختر حالة التوفر. + + + يمكن أن تحتوي الملاحظة على {0} حرفًا كحد أقصى. + + + اختر موعد انتهاء التقرير. + + + تم تعيين توفر {0} إلى {1}. + + + تعذّر حفظ التوفر: لم تعد نقطة الاهتمام وجهة. أعد تحميل الصفحة وحاول مرة أخرى. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.de.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.de.resx index 1c13cb946..933b79220 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.de.resx @@ -449,4 +449,136 @@ POI auswählen + + POI-Fähigkeiten + + + Fähigkeiten beschreiben, was Ihre Ziele und anderen Points of Interest bieten, z. B. Herzkatheterlabor, Stroke Unit oder Traumazentrum. Haken Sie sie bei jedem POI an; Disponenten sehen sie bei der Zielauswahl und können eine als nicht verfügbar melden. + + + Was dieser POI bietet. Disponenten sehen dies bei der Zielauswahl. + + + Fähigkeit hinzufügen + + + Name der Fähigkeit, z. B. Herzkatheterlabor + + + Sortierung + + + Fähigkeiten werden nach Sortierung und dann nach Name aufgelistet. Lassen Sie die Sortierung leer, um eine Fähigkeit am Ende hinzuzufügen. + + + Es wurden noch keine Fähigkeiten definiert. + + + Fähigkeiten verwalten + + + Die Fähigkeit „{0}“ löschen? Sie wird von allen POIs entfernt, die sie anbieten ({1}), sowie aus jeder Verfügbarkeitsmeldung. + + + Geben Sie einen Namen mit höchstens {0} Zeichen ein. + + + Eine Fähigkeit namens „{0}“ existiert bereits. + + + Fähigkeit „{0}“ gespeichert. + + + Fähigkeit „{0}“ gelöscht. + + + Die Fähigkeit konnte nicht gespeichert werden. Laden Sie die Seite neu und versuchen Sie es erneut. + + + Zielverfügbarkeit + + + Erfassen Sie, wenn ein Ziel wie ein Krankenhaus eingeschränkte Kapazität, eine Abmeldung oder eine Schließung meldet oder eine seiner Fähigkeiten nicht verfügbar ist. Disponenten sehen dies bei der Auswahl des Einsatzziels. + + + Typ + + + Verfügbarkeit + + + Verfügbarkeit setzen + + + Es gibt noch keine Ziel-POIs. Markieren Sie einen POI-Typ als Ziel, um seine POIs hier aufzulisten. + + + Offen + + + Eingeschränkt + + + Abgemeldet + + + Geschlossen + + + Nicht verfügbar: + + + Bis {0} + + + Aktuell + + + Gesetzt am {0} + + + Offen hebt die Meldung auf. Eingeschränkt nimmt weiterhin Patienten mit verringerter Kapazität auf; Abgemeldet bittet Besatzungen, ein anderes Ziel anzufahren, sofern es eine Alternative gibt; Geschlossen nimmt niemanden auf. + + + Haken Sie an, was dieses Ziel derzeit nicht leisten kann. + + + Für dieses Ziel sind keine Fähigkeiten angehakt. + + + Optional, höchstens {0} Zeichen. + + + Läuft ab + + + Wann die Meldung abläuft und das Ziel wieder als offen gilt. + + + Kein Ablauf (bis zur Änderung) + + + Aktuellen beibehalten ({0}) + + + In 1 Stunde + + + In {0} Stunden + + + Wählen Sie einen Verfügbarkeitsstatus. + + + Die Notiz darf höchstens {0} Zeichen lang sein. + + + Wählen Sie, wann die Meldung abläuft. + + + Verfügbarkeit von {0} auf {1} gesetzt. + + + Die Verfügbarkeit konnte nicht gespeichert werden: Der POI ist kein Ziel mehr. Laden Sie die Seite neu und versuchen Sie es erneut. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.el.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.el.resx index a8351843c..3030ecbab 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.el.resx @@ -449,4 +449,136 @@ Επιλογή σημείου + + Δυνατότητες POI + + + Οι δυνατότητες περιγράφουν τι προσφέρουν οι προορισμοί σας και τα άλλα σημεία ενδιαφέροντος, όπως αιμοδυναμικό εργαστήριο, μονάδα αγγειακών εγκεφαλικών ή κέντρο τραύματος. Επιλέξτε τες σε κάθε POI· οι συντονιστές τις βλέπουν όταν επιλέγουν προορισμό και μπορούν να δηλώσουν μία ως μη διαθέσιμη. + + + Τι προσφέρει αυτό το σημείο ενδιαφέροντος. Οι συντονιστές το βλέπουν όταν επιλέγουν προορισμό. + + + Προσθήκη δυνατότητας + + + Όνομα δυνατότητας, π.χ. Αιμοδυναμικό εργαστήριο + + + Σειρά ταξινόμησης + + + Οι δυνατότητες εμφανίζονται κατά σειρά ταξινόμησης και μετά κατά όνομα. Αφήστε τη σειρά κενή για να προσθέσετε μια δυνατότητα στο τέλος. + + + Δεν έχουν οριστεί ακόμη δυνατότητες. + + + Διαχείριση δυνατοτήτων + + + Διαγραφή της δυνατότητας «{0}»; Αφαιρείται από όλα τα POI που την προσφέρουν ({1}) και από κάθε αναφορά διαθεσιμότητας. + + + Εισαγάγετε όνομα δυνατότητας έως {0} χαρακτήρες. + + + Υπάρχει ήδη δυνατότητα με όνομα «{0}». + + + Η δυνατότητα «{0}» αποθηκεύτηκε. + + + Η δυνατότητα «{0}» διαγράφηκε. + + + Δεν ήταν δυνατή η αποθήκευση της δυνατότητας. Ανανεώστε τη σελίδα και δοκιμάστε ξανά. + + + Διαθεσιμότητα προορισμών + + + Καταγράψτε πότε ένας προορισμός, όπως ένα νοσοκομείο, αναφέρει περιορισμένη δυναμικότητα, εκτροπή ή κλείσιμο, ή ότι μία από τις δυνατότητές του δεν είναι διαθέσιμη. Οι συντονιστές το βλέπουν όταν επιλέγουν τον προορισμό ενός συμβάντος. + + + Τύπος + + + Διαθεσιμότητα + + + Ορισμός διαθεσιμότητας + + + Δεν υπάρχουν ακόμη POI προορισμού. Σημειώστε έναν τύπο POI ως προορισμό για να εμφανιστούν εδώ τα POI του. + + + Ανοιχτό + + + Περιορισμένο + + + Εκτροπή + + + Κλειστό + + + Μη διαθέσιμο: + + + Έως {0} + + + Τρέχουσα + + + Ορίστηκε {0} + + + Το «Ανοιχτό» καταργεί την αναφορά. Το «Περιορισμένο» δέχεται ακόμη ασθενείς με μειωμένη δυναμικότητα· η «Εκτροπή» ζητά από τα πληρώματα να πάνε αλλού εκτός αν δεν υπάρχει εναλλακτική· το «Κλειστό» δεν δέχεται κανέναν. + + + Επιλέξτε ό,τι δεν μπορεί να παρέχει αυτός ο προορισμός αυτή τη στιγμή. + + + Αυτός ο προορισμός δεν έχει επιλεγμένες δυνατότητες. + + + Προαιρετικό, έως {0} χαρακτήρες. + + + Λήξη + + + Πότε λήγει η αναφορά και ο προορισμός θεωρείται ξανά ανοιχτός. + + + Χωρίς λήξη (μέχρι να αλλάξει) + + + Διατήρηση τρέχουσας ({0}) + + + Σε 1 ώρα + + + Σε {0} ώρες + + + Επιλέξτε κατάσταση διαθεσιμότητας. + + + Η σημείωση μπορεί να έχει έως {0} χαρακτήρες. + + + Επιλέξτε πότε λήγει η αναφορά. + + + Η διαθεσιμότητα του {0} ορίστηκε σε {1}. + + + Δεν ήταν δυνατή η αποθήκευση της διαθεσιμότητας: το POI δεν είναι πλέον προορισμός. Ανανεώστε τη σελίδα και δοκιμάστε ξανά. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.en.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.en.resx index d943f00a7..b5ce9392b 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.en.resx @@ -449,4 +449,136 @@ Select POI + + POI Capabilities + + + Capabilities are what your destinations and other points of interest offer, such as Cath Lab, Stroke Center or Trauma Center. Tick them on each POI; dispatchers see them when picking a destination and can report one unavailable. + + + What this point of interest offers. Dispatchers see these when picking a destination. + + + Add Capability + + + Capability name, e.g. Cath Lab + + + Sort Order + + + Capabilities are listed by sort order, then by name. Leave the sort order blank to add a capability at the end. + + + No capabilities have been defined yet. + + + Manage capabilities + + + Delete the capability "{0}"? It is removed from every POI that offers it ({1}) and from every availability report. + + + Enter a capability name of at most {0} characters. + + + A capability named "{0}" already exists. + + + Capability "{0}" saved. + + + Capability "{0}" deleted. + + + The capability could not be saved. Reload the page and try again. + + + Destination Availability + + + Record when a destination such as a hospital reports limited capacity, diversion or closure, or that one of its capabilities is unavailable. Dispatchers see it when picking a call's destination. + + + Type + + + Availability + + + Set Availability + + + There are no destination POIs yet. Mark a POI type as a destination to list its POIs here. + + + Open + + + Limited + + + Diversion + + + Closed + + + Unavailable: + + + Until {0} + + + Current + + + Set {0} + + + Open clears the report. Limited still takes patients with reduced capacity; Diversion asks crews to go elsewhere unless there is no alternative; Closed takes no one. + + + Tick what this destination cannot provide right now. + + + This destination has no capabilities ticked. + + + Optional, at most {0} characters. + + + Expires + + + When the report lapses and the destination reads as open again. + + + No expiry (until changed) + + + Keep current ({0}) + + + In 1 hour + + + In {0} hours + + + Choose an availability status. + + + The note can be at most {0} characters. + + + Choose when the report expires. + + + Availability of {0} set to {1}. + + + The availability could not be saved: the POI is no longer a destination. Reload the page and try again. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.es.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.es.resx index dd77e9e42..5057f757b 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.es.resx @@ -449,4 +449,136 @@ Seleccionar PDI + + Capacidades de POI + + + Las capacidades describen lo que ofrecen sus destinos y otros puntos de interés, como sala de hemodinámica, unidad de ictus o centro de trauma. Márquelas en cada POI; los despachadores las ven al elegir un destino y pueden notificar una como no disponible. + + + Lo que ofrece este punto de interés. Los despachadores lo ven al elegir un destino. + + + Añadir capacidad + + + Nombre de la capacidad, p. ej. Sala de hemodinámica + + + Orden + + + Las capacidades se ordenan por orden y luego por nombre. Deje el orden en blanco para añadir una capacidad al final. + + + Aún no se ha definido ninguna capacidad. + + + Gestionar capacidades + + + ¿Eliminar la capacidad «{0}»? Se quitará de todos los POI que la ofrecen ({1}) y de todos los informes de disponibilidad. + + + Introduzca un nombre de capacidad de {0} caracteres como máximo. + + + Ya existe una capacidad llamada «{0}». + + + Capacidad «{0}» guardada. + + + Capacidad «{0}» eliminada. + + + No se pudo guardar la capacidad. Vuelva a cargar la página e inténtelo de nuevo. + + + Disponibilidad de destinos + + + Registre cuando un destino, como un hospital, informa de capacidad limitada, desvío o cierre, o de que una de sus capacidades no está disponible. Los despachadores lo ven al elegir el destino de un incidente. + + + Tipo + + + Disponibilidad + + + Establecer disponibilidad + + + Aún no hay POI de destino. Marque un tipo de POI como destino para listar aquí sus POI. + + + Abierto + + + Limitado + + + Desvío + + + Cerrado + + + No disponible: + + + Hasta {0} + + + Actual + + + Establecido el {0} + + + Abierto elimina el informe. Limitado sigue aceptando pacientes con capacidad reducida; Desvío pide a las dotaciones que vayan a otro sitio salvo que no haya alternativa; Cerrado no acepta a nadie. + + + Marque lo que este destino no puede ofrecer ahora mismo. + + + Este destino no tiene capacidades marcadas. + + + Opcional, {0} caracteres como máximo. + + + Caduca + + + Cuándo caduca el informe y el destino vuelve a figurar como abierto. + + + Sin caducidad (hasta que se cambie) + + + Mantener la actual ({0}) + + + En 1 hora + + + En {0} horas + + + Elija un estado de disponibilidad. + + + La nota puede tener como máximo {0} caracteres. + + + Elija cuándo caduca el informe. + + + Disponibilidad de {0} establecida en {1}. + + + No se pudo guardar la disponibilidad: el POI ya no es un destino. Vuelva a cargar la página e inténtelo de nuevo. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.fr.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.fr.resx index 660ab7934..2e754e9b3 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.fr.resx @@ -449,4 +449,136 @@ Sélectionner le POI + + Capacités des POI + + + Les capacités décrivent ce qu'offrent vos destinations et autres points d'intérêt, comme une salle de cathétérisme, une unité neurovasculaire ou un centre de traumatologie. Cochez-les sur chaque POI ; les régulateurs les voient en choisissant une destination et peuvent en signaler une comme indisponible. + + + Ce qu'offre ce point d'intérêt. Les régulateurs le voient en choisissant une destination. + + + Ajouter une capacité + + + Nom de la capacité, p. ex. Salle de cathétérisme + + + Ordre de tri + + + Les capacités sont classées par ordre de tri, puis par nom. Laissez l'ordre de tri vide pour ajouter une capacité à la fin. + + + Aucune capacité n'a encore été définie. + + + Gérer les capacités + + + Supprimer la capacité « {0} » ? Elle est retirée de tous les POI qui l'offrent ({1}) et de tous les signalements de disponibilité. + + + Saisissez un nom de capacité de {0} caractères au maximum. + + + Une capacité nommée « {0} » existe déjà. + + + Capacité « {0} » enregistrée. + + + Capacité « {0} » supprimée. + + + La capacité n'a pas pu être enregistrée. Rechargez la page et réessayez. + + + Disponibilité des destinations + + + Enregistrez quand une destination, comme un hôpital, signale une capacité réduite, un délestage ou une fermeture, ou qu'une de ses capacités est indisponible. Les régulateurs le voient en choisissant la destination d'une intervention. + + + Type de POI + + + Disponibilité + + + Définir la disponibilité + + + Il n'y a pas encore de POI de destination. Marquez un type de POI comme destination pour lister ses POI ici. + + + Ouvert + + + Limité + + + Délestage + + + Fermé + + + Indisponible : + + + Jusqu'au {0} + + + Actuel + + + Défini le {0} + + + Ouvert efface le signalement. Limité accepte encore des patients avec une capacité réduite ; Délestage demande aux équipages d'aller ailleurs sauf s'il n'y a pas d'alternative ; Fermé n'accepte personne. + + + Cochez ce que cette destination ne peut pas assurer pour le moment. + + + Aucune capacité n'est cochée pour cette destination. + + + Facultatif, {0} caractères au maximum. + + + Expire + + + Quand le signalement expire et que la destination redevient ouverte. + + + Pas d'expiration (jusqu'à modification) + + + Conserver l'actuelle ({0}) + + + Dans 1 heure + + + Dans {0} heures + + + Choisissez un statut de disponibilité. + + + La note peut comporter au maximum {0} caractères. + + + Choisissez quand le signalement expire. + + + Disponibilité de {0} définie sur {1}. + + + La disponibilité n'a pas pu être enregistrée : le POI n'est plus une destination. Rechargez la page et réessayez. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.it.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.it.resx index 0be6fd494..5061cfdfb 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.it.resx @@ -449,4 +449,136 @@ Seleziona POI + + Capacità dei POI + + + Le capacità descrivono ciò che offrono le vostre destinazioni e gli altri punti di interesse, come emodinamica, stroke unit o trauma center. Spuntatele su ogni POI; gli operatori le vedono quando scelgono una destinazione e possono segnalarne una come non disponibile. + + + Ciò che offre questo punto di interesse. Gli operatori lo vedono quando scelgono una destinazione. + + + Aggiungi capacità + + + Nome della capacità, ad es. Emodinamica + + + Ordinamento + + + Le capacità sono elencate per ordinamento, poi per nome. Lasciate vuoto l'ordinamento per aggiungere una capacità in fondo. + + + Non è stata ancora definita alcuna capacità. + + + Gestisci capacità + + + Eliminare la capacità «{0}»? Viene rimossa da tutti i POI che la offrono ({1}) e da ogni segnalazione di disponibilità. + + + Inserite un nome di capacità di al massimo {0} caratteri. + + + Esiste già una capacità chiamata «{0}». + + + Capacità «{0}» salvata. + + + Capacità «{0}» eliminata. + + + Impossibile salvare la capacità. Ricaricate la pagina e riprovate. + + + Disponibilità delle destinazioni + + + Registrate quando una destinazione, come un ospedale, segnala capacità ridotta, dirottamento o chiusura, o che una delle sue capacità non è disponibile. Gli operatori lo vedono quando scelgono la destinazione di un intervento. + + + Tipo + + + Disponibilità + + + Imposta disponibilità + + + Non ci sono ancora POI di destinazione. Contrassegnate un tipo di POI come destinazione per elencarne qui i POI. + + + Aperto + + + Limitato + + + Dirottamento + + + Chiuso + + + Non disponibile: + + + Fino al {0} + + + Attuale + + + Impostato il {0} + + + Aperto cancella la segnalazione. Limitato accetta ancora pazienti con capacità ridotta; Dirottamento chiede agli equipaggi di andare altrove salvo non ci siano alternative; Chiuso non accetta nessuno. + + + Spuntate ciò che questa destinazione non può offrire in questo momento. + + + Questa destinazione non ha capacità spuntate. + + + Facoltativa, al massimo {0} caratteri. + + + Scadenza + + + Quando la segnalazione scade e la destinazione torna aperta. + + + Nessuna scadenza (fino a modifica) + + + Mantieni l'attuale ({0}) + + + Tra 1 ora + + + Tra {0} ore + + + Scegliete uno stato di disponibilità. + + + La nota può contenere al massimo {0} caratteri. + + + Scegliete quando scade la segnalazione. + + + Disponibilità di {0} impostata su {1}. + + + Impossibile salvare la disponibilità: il POI non è più una destinazione. Ricaricate la pagina e riprovate. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.pl.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.pl.resx index cad292076..9f9c3fdf0 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.pl.resx @@ -449,4 +449,136 @@ Zaznacz POI + + Możliwości POI + + + Możliwości opisują, co oferują Twoje miejsca docelowe i inne punkty POI, np. pracownia hemodynamiki, oddział udarowy lub centrum urazowe. Zaznacz je przy każdym POI; dyspozytorzy widzą je przy wyborze miejsca docelowego i mogą zgłosić jedną z nich jako niedostępną. + + + Co oferuje ten punkt POI. Dyspozytorzy widzą to przy wyborze miejsca docelowego. + + + Dodaj możliwość + + + Nazwa możliwości, np. Pracownia hemodynamiki + + + Kolejność + + + Możliwości są wyświetlane według kolejności, a następnie nazwy. Pozostaw kolejność pustą, aby dodać możliwość na końcu. + + + Nie zdefiniowano jeszcze żadnych możliwości. + + + Zarządzaj możliwościami + + + Usunąć możliwość „{0}”? Zostanie usunięta ze wszystkich POI, które ją oferują ({1}), oraz z każdego zgłoszenia dostępności. + + + Wpisz nazwę możliwości o długości maksymalnie {0} znaków. + + + Możliwość o nazwie „{0}” już istnieje. + + + Zapisano możliwość „{0}”. + + + Usunięto możliwość „{0}”. + + + Nie udało się zapisać możliwości. Odśwież stronę i spróbuj ponownie. + + + Dostępność miejsc docelowych + + + Odnotuj, gdy miejsce docelowe, np. szpital, zgłasza ograniczone możliwości przyjęć, kierowanie gdzie indziej lub zamknięcie albo niedostępność jednej ze swoich możliwości. Dyspozytorzy widzą to przy wyborze miejsca docelowego zdarzenia. + + + Typ + + + Dostępność + + + Ustaw dostępność + + + Nie ma jeszcze POI będących miejscami docelowymi. Oznacz typ POI jako miejsce docelowe, aby wyświetlić tu jego POI. + + + Otwarte + + + Ograniczone + + + Przekierowanie + + + Zamknięte + + + Niedostępne: + + + Do {0} + + + Obecnie + + + Ustawiono {0} + + + Otwarte usuwa zgłoszenie. Ograniczone nadal przyjmuje pacjentów przy zmniejszonych możliwościach; Przekierowanie prosi zespoły o jazdę gdzie indziej, chyba że nie ma alternatywy; Zamknięte nie przyjmuje nikogo. + + + Zaznacz, czego to miejsce docelowe nie może teraz zapewnić. + + + To miejsce docelowe nie ma zaznaczonych możliwości. + + + Opcjonalnie, maksymalnie {0} znaków. + + + Wygasa + + + Kiedy zgłoszenie wygasa i miejsce docelowe znów jest otwarte. + + + Bez wygaśnięcia (do zmiany) + + + Zachowaj obecne ({0}) + + + Za 1 godzinę + + + Za {0} godz. + + + Wybierz status dostępności. + + + Notatka może mieć maksymalnie {0} znaków. + + + Wybierz, kiedy zgłoszenie wygasa. + + + Ustawiono dostępność {0}: {1}. + + + Nie udało się zapisać dostępności: POI nie jest już miejscem docelowym. Odśwież stronę i spróbuj ponownie. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.sv.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.sv.resx index 49af0447d..ea5854f71 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.sv.resx @@ -449,4 +449,136 @@ Markera POI + + POI-förmågor + + + Förmågor beskriver vad dina destinationer och andra intressepunkter erbjuder, till exempel PCI-lab, strokeenhet eller traumacenter. Markera dem på varje POI; larmoperatörer ser dem när de väljer destination och kan rapportera en som otillgänglig. + + + Vad den här intressepunkten erbjuder. Larmoperatörer ser det när de väljer destination. + + + Lägg till förmåga + + + Förmågans namn, t.ex. PCI-lab + + + Sorteringsordning + + + Förmågor listas efter sorteringsordning och sedan namn. Lämna sorteringsordningen tom för att lägga till en förmåga sist. + + + Inga förmågor har definierats ännu. + + + Hantera förmågor + + + Ta bort förmågan ”{0}”? Den tas bort från alla POI som erbjuder den ({1}) och från varje tillgänglighetsrapport. + + + Ange ett namn på högst {0} tecken. + + + Det finns redan en förmåga som heter ”{0}”. + + + Förmågan ”{0}” sparades. + + + Förmågan ”{0}” togs bort. + + + Förmågan kunde inte sparas. Ladda om sidan och försök igen. + + + Destinationstillgänglighet + + + Registrera när en destination, till exempel ett sjukhus, rapporterar begränsad kapacitet, omdirigering eller stängning, eller att en av dess förmågor inte är tillgänglig. Larmoperatörer ser det när de väljer ett ärendes destination. + + + Typ + + + Tillgänglighet + + + Ange tillgänglighet + + + Det finns inga destinations-POI ännu. Markera en POI-typ som destination för att lista dess POI här. + + + Öppen + + + Begränsad + + + Omdirigering + + + Stängd + + + Ej tillgängligt: + + + Till {0} + + + Nuvarande + + + Angiven {0} + + + Öppen rensar rapporten. Begränsad tar fortfarande emot patienter med minskad kapacitet; Omdirigering ber besättningar åka någon annanstans om det finns ett alternativ; Stängd tar inte emot någon. + + + Markera det som den här destinationen inte kan erbjuda just nu. + + + Den här destinationen har inga förmågor markerade. + + + Valfritt, högst {0} tecken. + + + Upphör + + + När rapporten upphör och destinationen räknas som öppen igen. + + + Inget slut (tills den ändras) + + + Behåll nuvarande ({0}) + + + Om 1 timme + + + Om {0} timmar + + + Välj en tillgänglighetsstatus. + + + Anteckningen får vara högst {0} tecken. + + + Välj när rapporten upphör. + + + Tillgängligheten för {0} har satts till {1}. + + + Tillgängligheten kunde inte sparas: POI:n är inte längre en destination. Ladda om sidan och försök igen. + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.uk.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.uk.resx index 4781aa62c..c6331ba88 100644 --- a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.uk.resx @@ -449,4 +449,136 @@ Вибрати POI + + Можливості POI + + + Можливості описують, що пропонують ваші пункти призначення та інші точки інтересу, наприклад катетеризаційна лабораторія, інсультний центр чи травмоцентр. Позначте їх для кожного POI; диспетчери бачать їх під час вибору пункту призначення й можуть позначити одну з них як недоступну. + + + Що пропонує ця точка інтересу. Диспетчери бачать це під час вибору пункту призначення. + + + Додати можливість + + + Назва можливості, напр. Катетеризаційна лабораторія + + + Порядок сортування + + + Можливості впорядковуються за порядком сортування, потім за назвою. Залиште порядок порожнім, щоб додати можливість у кінець. + + + Ще не визначено жодної можливості. + + + Керувати можливостями + + + Видалити можливість «{0}»? Її буде знято з усіх POI, що її пропонують ({1}), і з кожного звіту про доступність. + + + Введіть назву можливості довжиною не більше {0} символів. + + + Можливість із назвою «{0}» уже існує. + + + Можливість «{0}» збережено. + + + Можливість «{0}» видалено. + + + Не вдалося зберегти можливість. Перезавантажте сторінку й спробуйте ще раз. + + + Доступність пунктів призначення + + + Фіксуйте, коли пункт призначення, наприклад лікарня, повідомляє про обмежену спроможність, перенаправлення чи закриття або про недоступність однієї зі своїх можливостей. Диспетчери бачать це під час вибору пункту призначення виклику. + + + Тип + + + Доступність + + + Встановити доступність + + + Ще немає POI — пунктів призначення. Позначте тип POI як пункт призначення, щоб його POI з’явилися тут. + + + Відкрито + + + Обмежено + + + Перенаправлення + + + Закрито + + + Недоступно: + + + До {0} + + + Поточна + + + Встановлено {0} + + + «Відкрито» знімає звіт. «Обмежено» ще приймає пацієнтів зі зменшеною спроможністю; «Перенаправлення» просить бригади їхати деінде, якщо є альтернатива; «Закрито» не приймає нікого. + + + Позначте, чого цей пункт призначення зараз не може забезпечити. + + + Для цього пункту призначення не позначено жодної можливості. + + + Необов’язково, не більше {0} символів. + + + Спливає + + + Коли звіт спливає і пункт призначення знову вважається відкритим. + + + Без терміну (до зміни) + + + Залишити поточний ({0}) + + + Через 1 годину + + + Через {0} год + + + Виберіть стан доступності. + + + Примітка може містити не більше {0} символів. + + + Виберіть, коли спливає звіт. + + + Доступність {0} встановлено: {1}. + + + Не вдалося зберегти доступність: POI більше не є пунктом призначення. Перезавантажте сторінку й спробуйте ще раз. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 693d9e920..e2e3958c8 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -2160,6 +2160,14 @@ أمر خارجي جديد العودة إلى صنابير الإطفاء لم يتم حفظ أي صنبور. صحح الصفوف أدناه ثم استورد الملف مجددًا. + أعمدة لم يتم استيرادها لعدم وجود حقل صنبور مطابق في Resgrid: {0}. لاستيراد أحدها، أعد تسمية عنوانه إلى اسم عمود من المثال ثم استورد الملف مجددًا. + من عمود الحالة في الملف: {0} صنبور أُخرج من الخدمة، و{1} أُعيد إلى الخدمة. + قيم استُوردت بقيمة افتراضية + لم يتم التعرف على هذه القيم من أعمدة نظام آخر. تم استيراد الصنابير مع ذلك: الأنواع غير المعروفة بالنوع Other، وتُركت التدفقات وألوان التدفق غير المقروءة فارغة، وبقيت حالة الخدمة دون تغيير عند الحالات غير المعروفة. لتعيينها، غيّر القيم في الملف ثم استورده مجددًا؛ يتم تحديث الصنابير ذات الأرقام المطابقة. + العمود + القيمة + الصفوف + الملفات المصدَّرة من أنظمة أخرى: تتم قراءة hydrant_type وflow_rate وlocation_address على أنها type وflow_gpm وaddress. التدفق بالغالون في الدقيقة ما لم تذكر القيمة L/min. يحدد flow_rating_color (light blue أو green أو orange أو red أو AA–C أو لون #hex) فئة التدفق للصنابير التي ليس لها تدفق. يُخرج status (active أو in service أو out of service أو broken…) الصنابير من الخدمة أو يعيدها إليها، وتصبح الحالة سبب الإخراج من الخدمة. تُستورد الأنواع غير المعروفة بالنوع Other، ويتم تخطي الأعمدة الأخرى وإدراجها بعد الاستيراد. عرض {0} من أصل {1} صفوف بها مشكلات. يستخدم JSON أرقام العناصر ويستخدم CSV أرقام الأسطر. العنصر / السطر كيفية التصحيح diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 532c402e9..57cf09336 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -2160,6 +2160,14 @@ Neuer externer Auftrag Zurück zu Hydranten Es wurden keine Hydranten gespeichert. Korrigieren Sie die folgenden Zeilen und importieren Sie die Datei erneut. + Nicht importierte Spalten, da Resgrid kein passendes Hydrantenfeld hat: {0}. Um eine davon zu importieren, benennen Sie die Spaltenüberschrift in einen Spaltennamen aus dem Beispiel um und importieren Sie die Datei erneut. + Aus der Statusspalte der Datei: {0} Hydranten außer Betrieb gesetzt, {1} wieder in Betrieb genommen. + Mit Standardwert importierte Werte + Diese Werte aus Spalten eines anderen Systems wurden nicht erkannt. Die Hydranten wurden trotzdem importiert: unbekannte Typen als Other, nicht lesbare Durchflüsse und Durchflussfarben leer, und bei unbekanntem Status bleibt der Betriebszustand unverändert. Um sie festzulegen, ändern Sie die Werte in der Datei und importieren Sie sie erneut; Hydranten mit gleicher Nummer werden aktualisiert. + Spalte + Wert + Zeilen + Exporte aus anderen Systemen: hydrant_type, flow_rate und location_address werden als type, flow_gpm und address gelesen. Der Durchfluss gilt in Gallonen pro Minute, sofern der Wert nicht L/min angibt. flow_rating_color (light blue, green, orange, red, AA–C oder eine #Hex-Farbe) legt die Durchflussklasse von Hydranten ohne Durchfluss fest. status (active, in service, out of service, broken…) nimmt Hydranten außer Betrieb oder wieder in Betrieb; der Status wird zum Grund für die Außerbetriebnahme. Unbekannte Typen werden als Other importiert; andere Spalten werden übersprungen und nach dem Import aufgeführt. {0} von {1} fehlerhaften Zeilen werden angezeigt. JSON verwendet Eintragsnummern, CSV Zeilennummern. Eintrag / Zeile So beheben Sie das Problem diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 548de3287..068abfa7c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -2160,6 +2160,14 @@ Νέα εξωτερική εντολή Επιστροφή στους κρουνούς Δεν αποθηκεύτηκε κανένας κρουνός. Διορθώστε τις παρακάτω γραμμές και εισαγάγετε ξανά το αρχείο. + Στήλες που δεν εισήχθησαν επειδή το Resgrid δεν έχει αντίστοιχο πεδίο κρουνού: {0}. Για να εισαγάγετε μία από αυτές, μετονομάστε την κεφαλίδα της σε όνομα στήλης από το παράδειγμα και εισαγάγετε ξανά το αρχείο. + Από τη στήλη κατάστασης του αρχείου: {0} κρουνοί τέθηκαν εκτός λειτουργίας, {1} επανήλθαν σε λειτουργία. + Τιμές που εισήχθησαν με προεπιλογή + Αυτές οι τιμές από στήλες άλλου συστήματος δεν αναγνωρίστηκαν. Οι κρουνοί εισήχθησαν κανονικά: οι άγνωστοι τύποι ως Other, οι μη αναγνώσιμες παροχές και τα χρώματα παροχής έμειναν κενά, και με άγνωστη κατάσταση η λειτουργία τους έμεινε αμετάβλητη. Για να τις ορίσετε, αλλάξτε τις τιμές στο αρχείο και εισαγάγετέ το ξανά· οι κρουνοί με ίδιο αριθμό ενημερώνονται. + Στήλη + Τιμή + Γραμμές + Αρχεία από άλλα συστήματα: τα hydrant_type, flow_rate και location_address διαβάζονται ως type, flow_gpm και address. Η παροχή είναι σε γαλόνια ανά λεπτό, εκτός αν η τιμή αναφέρει L/min. Το flow_rating_color (light blue, green, orange, red, AA–C ή χρώμα #hex) ορίζει την κλάση παροχής των κρουνών χωρίς παροχή. Το status (active, in service, out of service, broken…) θέτει κρουνούς εκτός λειτουργίας ή τους επαναφέρει σε λειτουργία· η κατάσταση γίνεται ο λόγος εκτός λειτουργίας. Οι άγνωστοι τύποι εισάγονται ως Other· οι υπόλοιπες στήλες παραλείπονται και εμφανίζονται μετά την εισαγωγή. Εμφανίζονται {0} από {1} γραμμές με προβλήματα. Το JSON χρησιμοποιεί αριθμούς στοιχείων, το CSV αριθμούς γραμμών. Στοιχείο / γραμμή Τρόπος διόρθωσης diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index e8da62b9d..e74e02571 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -2158,6 +2158,14 @@ Open deployment workspace Back to hydrants No hydrants were saved. Correct the rows below and import the file again. + Columns not imported because Resgrid has no matching hydrant field: {0}. To import one, rename its header to a column name from the example and import the file again. + From the file's status column: {0} hydrants set out of service, {1} returned to service. + Values imported with a default + These values from another system's columns were not recognized. The hydrants were still imported: unrecognized types as Other, unreadable flows and flow colours left blank, and unrecognized statuses with their service state unchanged. To set them, change the values in the file and import it again; matching hydrant numbers are updated. + Column + Value + Rows + Files exported from other systems: hydrant_type, flow_rate and location_address are read as type, flow_gpm and address. Flow is in gallons per minute unless the value says L/min. flow_rating_color (light blue, green, orange, red, AA–C or a #hex colour) sets the flow class of hydrants with no flow. status (active, in service, out of service, broken…) takes hydrants out of service or returns them to service; the status becomes the out-of-service reason. Unrecognized types are imported as Other; other columns are skipped and listed after the import. Showing {0} of {1} rows with issues. JSON uses item numbers; CSV uses line numbers. Row / line How to fix it diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index bc4259414..2edbb007c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -2160,6 +2160,14 @@ Nueva orden externa Volver a hidrantes No se guardó ningún hidrante. Corrija las filas siguientes e importe el archivo de nuevo. + Columnas no importadas porque Resgrid no tiene un campo de hidrante equivalente: {0}. Para importar una, cambie su encabezado por un nombre de columna del ejemplo e importe el archivo de nuevo. + Según la columna de estado del archivo: {0} hidrantes puestos fuera de servicio y {1} devueltos al servicio. + Valores importados con un valor predeterminado + Estos valores de columnas de otro sistema no se reconocieron. Los hidrantes se importaron igualmente: los tipos desconocidos como Other, los caudales y colores de caudal ilegibles en blanco, y con un estado desconocido su situación de servicio no cambió. Para asignarlos, cambie los valores en el archivo e impórtelo de nuevo; los hidrantes con el mismo número se actualizan. + Columna + Valor + Filas + Archivos exportados de otros sistemas: hydrant_type, flow_rate y location_address se leen como type, flow_gpm y address. El caudal se expresa en galones por minuto salvo que el valor indique L/min. flow_rating_color (light blue, green, orange, red, AA–C o un color #hex) establece la clase de caudal de los hidrantes sin caudal. status (active, in service, out of service, broken…) pone hidrantes fuera de servicio o los devuelve al servicio; el estado pasa a ser el motivo de fuera de servicio. Los tipos desconocidos se importan como Other; las demás columnas se omiten y se muestran tras la importación. Se muestran {0} de {1} filas con problemas. JSON usa números de elemento; CSV usa números de línea. Fila / línea Cómo corregirlo diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index cf867bacf..86d05ccf4 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -2160,6 +2160,14 @@ Nouvel ordre externe Retour aux hydrants Aucun hydrant n’a été enregistré. Corrigez les lignes ci-dessous et importez à nouveau le fichier. + Colonnes non importées, car Resgrid n’a aucun champ d’hydrant correspondant : {0}. Pour en importer une, renommez son en-tête avec un nom de colonne de l’exemple, puis importez à nouveau le fichier. + D’après la colonne d’état du fichier : {0} hydrants mis hors service, {1} remis en service. + Valeurs importées avec une valeur par défaut + Ces valeurs provenant des colonnes d’un autre système n’ont pas été reconnues. Les hydrants ont tout de même été importés : types inconnus en Other, débits et couleurs de débit illisibles laissés vides, et état de service inchangé pour les statuts inconnus. Pour les définir, modifiez les valeurs dans le fichier et importez-le à nouveau ; les hydrants de même numéro sont mis à jour. + Colonne + Valeur + Lignes + Fichiers exportés d’autres systèmes : hydrant_type, flow_rate et location_address sont lus comme type, flow_gpm et address. Le débit est en gallons par minute, sauf si la valeur indique L/min. flow_rating_color (light blue, green, orange, red, AA–C ou une couleur #hex) définit la classe de débit des hydrants sans débit. status (active, in service, out of service, broken…) met les hydrants hors service ou les remet en service ; le statut devient le motif de mise hors service. Les types inconnus sont importés en Other ; les autres colonnes sont ignorées et listées après l’import. Affichage de {0} lignes sur {1} comportant des erreurs. JSON utilise les numéros d’élément, CSV les numéros de ligne. Élément / ligne Comment corriger diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index 451731e7c..77a4f7359 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -2160,6 +2160,14 @@ Nuovo ordine esterno Torna agli idranti Nessun idrante è stato salvato. Correggi le righe seguenti e importa nuovamente il file. + Colonne non importate perché Resgrid non ha un campo idrante corrispondente: {0}. Per importarne una, rinomina la sua intestazione con un nome di colonna dell’esempio e importa nuovamente il file. + Dalla colonna di stato del file: {0} idranti messi fuori servizio, {1} rimessi in servizio. + Valori importati con un valore predefinito + Questi valori provenienti da colonne di un altro sistema non sono stati riconosciuti. Gli idranti sono stati comunque importati: i tipi sconosciuti come Other, le portate e i colori di portata illeggibili lasciati vuoti e, con uno stato sconosciuto, lo stato di servizio invariato. Per impostarli, modifica i valori nel file e importalo nuovamente; gli idranti con lo stesso numero vengono aggiornati. + Colonna + Valore + Righe + File esportati da altri sistemi: hydrant_type, flow_rate e location_address vengono letti come type, flow_gpm e address. La portata è in galloni al minuto, salvo che il valore indichi L/min. flow_rating_color (light blue, green, orange, red, AA–C o un colore #hex) imposta la classe di portata degli idranti senza portata. status (active, in service, out of service, broken…) mette gli idranti fuori servizio o li rimette in servizio; lo stato diventa il motivo del fuori servizio. I tipi sconosciuti vengono importati come Other; le altre colonne vengono ignorate ed elencate dopo l’importazione. Sono mostrate {0} di {1} righe con problemi. JSON usa i numeri degli elementi; CSV i numeri di riga. Elemento / riga Come correggere diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index 8f7b92a1f..3081fbb4b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -2160,6 +2160,14 @@ Nowe zlecenie zewnętrzne Wróć do hydrantów Nie zapisano żadnych hydrantów. Popraw poniższe wiersze i ponownie zaimportuj plik. + Kolumny niezaimportowane, ponieważ Resgrid nie ma odpowiadającego pola hydrantu: {0}. Aby zaimportować jedną z nich, zmień jej nagłówek na nazwę kolumny z przykładu i ponownie zaimportuj plik. + Na podstawie kolumny stanu w pliku: wyłączone z użytku hydranty: {0}, przywrócone do użytku: {1}. + Wartości zaimportowane z wartością domyślną + Tych wartości z kolumn innego systemu nie rozpoznano. Hydranty i tak zaimportowano: nieznane typy jako Other, nieczytelne przepływy i kolory przepływu pozostawiono puste, a przy nieznanym stanie stan użytkowania się nie zmienił. Aby je ustawić, zmień wartości w pliku i ponownie go zaimportuj; hydranty o tym samym numerze zostaną zaktualizowane. + Kolumna + Wartość + Wiersze + Pliki wyeksportowane z innych systemów: hydrant_type, flow_rate i location_address są odczytywane jako type, flow_gpm i address. Przepływ jest w galonach na minutę, chyba że wartość podaje L/min. flow_rating_color (light blue, green, orange, red, AA–C lub kolor #hex) ustawia klasę przepływu hydrantów bez przepływu. status (active, in service, out of service, broken…) wyłącza hydranty z użytku lub przywraca je do użytku; stan staje się powodem wyłączenia. Nieznane typy są importowane jako Other; pozostałe kolumny są pomijane i wyświetlane po imporcie. Wyświetlono {0} z {1} wierszy z błędami. JSON używa numerów elementów, a CSV numerów wierszy. Element / wiersz Jak poprawić diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index 2b6a7928c..466c426ed 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -2160,6 +2160,14 @@ Ny extern order Tillbaka till brandposter Inga brandposter sparades. Rätta raderna nedan och importera filen igen. + Kolumner som inte importerades eftersom Resgrid saknar ett motsvarande brandpostfält: {0}. Om du vill importera en av dem byter du namn på rubriken till ett kolumnnamn från exemplet och importerar filen igen. + Från filens statuskolumn: {0} brandposter togs ur drift, {1} togs i drift igen. + Värden som importerades med ett standardvärde + Dessa värden från ett annat systems kolumner kändes inte igen. Brandposterna importerades ändå: okända typer som Other, oläsliga flöden och flödesfärger lämnades tomma, och vid okänd status ändrades inte driftläget. Om du vill ange dem ändrar du värdena i filen och importerar den igen; brandposter med samma nummer uppdateras. + Kolumn + Värde + Rader + Filer exporterade från andra system: hydrant_type, flow_rate och location_address läses som type, flow_gpm och address. Flödet anges i gallon per minut om inte värdet anger L/min. flow_rating_color (light blue, green, orange, red, AA–C eller en #hex-färg) anger flödesklassen för brandposter utan flöde. status (active, in service, out of service, broken…) tar brandposter ur drift eller i drift igen; statusen blir orsaken till att de tas ur drift. Okända typer importeras som Other; övriga kolumner hoppas över och listas efter importen. Visar {0} av {1} rader med problem. JSON använder postnummer; CSV använder radnummer. Post / rad Så rättar du felet diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 2fa6db597..95221f854 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -2160,6 +2160,14 @@ Нове зовнішнє замовлення Назад до гідрантів Жодного гідранта не збережено. Виправте рядки нижче та імпортуйте файл знову. + Стовпці не імпортовано, оскільки в Resgrid немає відповідного поля гідранта: {0}. Щоб імпортувати один із них, перейменуйте його заголовок на назву стовпця з прикладу та імпортуйте файл знову. + За стовпцем стану у файлі: виведено з роботи гідрантів: {0}, повернуто в роботу: {1}. + Значення, імпортовані зі значенням за замовчуванням + Ці значення зі стовпців іншої системи не розпізнано. Гідранти все одно імпортовано: невідомі типи як Other, нечитабельні витрати й кольори витрати залишено порожніми, а за невідомого стану робочий стан не змінено. Щоб задати їх, змініть значення у файлі та імпортуйте його знову; гідранти з тим самим номером оновлюються. + Стовпець + Значення + Рядки + Файли, експортовані з інших систем: hydrant_type, flow_rate і location_address читаються як type, flow_gpm і address. Витрату задано в галонах за хвилину, якщо значення не вказує L/min. flow_rating_color (light blue, green, orange, red, AA–C або колір #hex) задає клас витрати гідрантів без витрати. status (active, in service, out of service, broken…) виводить гідранти з роботи або повертає їх у роботу; стан стає причиною виведення з роботи. Невідомі типи імпортуються як Other; інші стовпці пропускаються й показуються після імпорту. Показано {0} із {1} рядків із помилками. JSON використовує номери елементів, CSV — номери рядків. Елемент / рядок Як виправити diff --git a/Core/Resgrid.Model/AdminAssist/AdminAssistWorkflowPayload.cs b/Core/Resgrid.Model/AdminAssist/AdminAssistWorkflowPayload.cs index 1cb3854ac..7c2eb9dff 100644 --- a/Core/Resgrid.Model/AdminAssist/AdminAssistWorkflowPayload.cs +++ b/Core/Resgrid.Model/AdminAssist/AdminAssistWorkflowPayload.cs @@ -12,7 +12,7 @@ public static class AdminAssistWorkflowPayload { public static readonly int[] Triggers = { 189, 190, 191 }; public static readonly (string Variable, string Property)[] Variables = { ("id", "FindingId"), ("rule_id", "RuleId"), ("episode", "Episode"), ("result", "Result"), ("severity", "Severity"), ("review_status", "ReviewStatus") }; - public static readonly string[] RuleIds = { "shift-auto-without-dispatch", "shift-coverage", "text-sources", "command-sources", "admin-mfa", "admin-enrollment", "admin-succession", "empty-groups", "unit-type", "unit-group", "station-address", "person-location-age", "unit-location-age", "map-token", "map-style", "personnel-limit", "unit-limit", "run-cards", "checkin-timers", "status-meanings", "unit-status-targets", "unit-status-timers", "weather-zones", "communication-tests", "qualified-coverage", "credential-expiry", "checklist-overdue", "equipment-holds", "stock-expiry", "workflow-failures", "import-heartbeat", "email-import-failures", "policy-references", "policy-expiry", "site-references", "continuity-reference", "record-review", "password-recovery", "shift-open-slots", "shift-overlaps", "shift-trades", "notification-channels", "notification-profiles", "import-blocking-issues", "import-unreconciled", "import-dormant-members", "import-paused-intake", "import-disabled-automation", "import-enforcement-relaxed", "import-files-retained" }; + public static readonly string[] RuleIds = { "shift-auto-without-dispatch", "shift-coverage", "text-sources", "command-sources", "admin-mfa", "admin-enrollment", "admin-succession", "empty-groups", "unit-type", "unit-group", "station-address", "person-location-age", "unit-location-age", "map-token", "map-style", "personnel-limit", "unit-limit", "run-cards", "checkin-timers", "status-meanings", "unit-status-targets", "unit-status-timers", "weather-zones", "communication-tests", "qualified-coverage", "credential-expiry", "checklist-overdue", "equipment-holds", "stock-expiry", "workflow-failures", "import-heartbeat", "email-import-failures", "policy-references", "policy-expiry", "site-references", "continuity-reference", "record-review", "password-recovery", "shift-open-slots", "shift-overlaps", "shift-trades", "notification-channels", "notification-profiles", "import-blocking-issues", "import-unreconciled", "import-dormant-members", "import-paused-intake", "import-disabled-automation", "import-enforcement-relaxed", "import-files-retained", "new-call-dispatch-lists-hidden" }; public static string Routing(JObject payload) { var safe = new JObject(); diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index d6aa7c828..e026e8e6b 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -238,5 +238,13 @@ public enum DepartmentSettingTypes /// system defaults. /// MappingProviderOverrides = 125, + + /// + /// Cached scalar, Dispatch Settings screen: "true" turns on transport destination checks (). + /// When a unit reports a Transporting or At Hospital status to a destination POI other than its call's assigned + /// destination, the apps require a reason (the status note) and Resgrid adds a call note recording the change. Missing = + /// false (the apps still pre-select the call's destination and show the difference, nothing is required or recorded). + /// + TransportDestinationChecks = 126, } } diff --git a/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs b/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs index bd2af1dae..390bb094d 100644 --- a/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs +++ b/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs @@ -26,6 +26,10 @@ public class NewCallFieldValues public string ExternalId { get; set; } public string IncidentId { get; set; } public string ReferenceId { get; set; } + public string MapPage { get; set; } + + /// The contact chosen as the call's primary contact (Contacts module), blank for none. + public string PrimaryContactId { get; set; } public int? DestinationPoiId { get; set; } public string IndoorMapZoneId { get; set; } public bool HasProtocols { get; set; } @@ -102,6 +106,8 @@ void Require(string key, bool hasValue) RequireText(NewCallFieldKeys.ExternalId, values.ExternalId); RequireText(NewCallFieldKeys.IncidentId, values.IncidentId); RequireText(NewCallFieldKeys.ReferenceId, values.ReferenceId); + RequireText(NewCallFieldKeys.MapPage, values.MapPage); + RequireText(NewCallFieldKeys.PrimaryContact, values.PrimaryContactId); RequireText(NewCallFieldKeys.IndoorLocation, values.IndoorMapZoneId); Require(NewCallFieldKeys.DestinationPoi, values.DestinationPoiId.HasValue && values.DestinationPoiId.Value > 0); diff --git a/Core/Resgrid.Model/KmlImportResult.cs b/Core/Resgrid.Model/KmlImportResult.cs index aec154baf..cd13a9e8f 100644 --- a/Core/Resgrid.Model/KmlImportResult.cs +++ b/Core/Resgrid.Model/KmlImportResult.cs @@ -22,6 +22,16 @@ public class KmlImportResult public string Error { get; set; } } + /// + /// A placemark with a usable position ( holds these) that also carries ExtendedData, + /// such as a "Capabilities" column a POI import reads. Never serialized: the protobuf members are the base class's. + /// + public class KmlPositionedPlacemark : Coordinates + { + /// ExtendedData Data and SimpleData values in document order, keyed by their name attribute. + public List> Fields { get; set; } = new List>(); + } + /// A placemark that has no usable position but may be located from its address. public class KmlAddressPlacemark { diff --git a/Core/Resgrid.Model/MapLayer.cs b/Core/Resgrid.Model/MapLayer.cs index e7862d9da..ea7a8fdb0 100644 --- a/Core/Resgrid.Model/MapLayer.cs +++ b/Core/Resgrid.Model/MapLayer.cs @@ -9,6 +9,7 @@ namespace Resgrid.Model { [BsonCollection("maplayers")] + [BsonIgnoreExtraElements] public class MapLayer: NoSqlDocument { [BsonElement("departmentId")] @@ -63,6 +64,73 @@ public class MapLayer: NoSqlDocument [JsonProperty(PropertyName = "updatedOn")] public DateTime UpdatedOn { get; set; } + /// What the layer is for (an event layer's summary, shown with its details). Optional. + [MaxLength(2000)] + [BsonElement("description")] + [JsonProperty(PropertyName = "description")] + public string Description { get; set; } + + /// + /// When the event the layer maps starts (UTC). With it makes the layer an event layer: shown on + /// every map by default while the event runs (). Optional. + /// + [BsonElement("eventStartsOn")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + [JsonProperty(PropertyName = "eventStartsOn")] + public DateTime? EventStartsOn { get; set; } + + /// When the event ends (UTC). Optional. + [BsonElement("eventEndsOn")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + [JsonProperty(PropertyName = "eventEndsOn")] + public DateTime? EventEndsOn { get; set; } + + /// Who runs the event (organization or owner). + [MaxLength(250)] + [BsonElement("organizer")] + [JsonProperty(PropertyName = "organizer")] + public string Organizer { get; set; } + + /// The event's coordinator or contact person. + [MaxLength(250)] + [BsonElement("contactName")] + [JsonProperty(PropertyName = "contactName")] + public string ContactName { get; set; } + + [MaxLength(100)] + [BsonElement("contactPhone")] + [JsonProperty(PropertyName = "contactPhone")] + public string ContactPhone { get; set; } + + [MaxLength(250)] + [BsonElement("contactEmail")] + [JsonProperty(PropertyName = "contactEmail")] + public string ContactEmail { get; set; } + + /// + /// The file the layer's shapes were imported from (KML, KMZ or GeoJSON), blank for a drawn layer. An imported layer's + /// shapes carry their own names, descriptions and colors, so it is changed by replacing the file, not in the drawing + /// editor (which writes the layer's name and color onto every shape). + /// + [MaxLength(260)] + [BsonElement("sourceFileName")] + [JsonProperty(PropertyName = "sourceFileName")] + public string SourceFileName { get; set; } + + [BsonElement("importedById")] + [JsonProperty(PropertyName = "importedById")] + public string ImportedById { get; set; } + + [BsonElement("importedOn")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + [JsonProperty(PropertyName = "importedOn")] + public DateTime? ImportedOn { get; set; } + + /// True for a layer whose shapes came from an uploaded file. + [BsonIgnore] + [JsonIgnore] + public bool IsImported => !String.IsNullOrWhiteSpace(SourceFileName); + [BsonIgnore()] [JsonProperty(PropertyName = "id")] public string PgId { get; set; } diff --git a/Core/Resgrid.Model/MapLayerEvents.cs b/Core/Resgrid.Model/MapLayerEvents.cs new file mode 100644 index 000000000..0117dbb80 --- /dev/null +++ b/Core/Resgrid.Model/MapLayerEvents.cs @@ -0,0 +1,53 @@ +using System; + +namespace Resgrid.Model +{ + /// Where an event layer's event is relative to now. + public enum MapLayerEventPhases + { + /// The layer has no event dates. + None = 0, + Upcoming = 1, + Active = 2, + Ended = 3 + } + + /// + /// Event layers: map layers (usually imported from KML, KMZ or GeoJSON) for a marathon route, a festival site, a parade + /// and the like, with the event's dates and contacts. While the event runs the layer is shown on every map by default, + /// whatever its "on by default" setting, so crews see it without anyone remembering to switch it on and off. + /// + public static class MapLayerEvents + { + public static bool HasEventDates(MapLayer layer) => + layer != null && (layer.EventStartsOn.HasValue || layer.EventEndsOn.HasValue); + + /// + /// The event's phase at . A missing start means "already started", a missing end "still on". + /// + public static MapLayerEventPhases GetPhase(MapLayer layer, DateTime utcNow) + { + if (!HasEventDates(layer)) + return MapLayerEventPhases.None; + + if (layer.EventStartsOn.HasValue && utcNow < AsUtc(layer.EventStartsOn.Value)) + return MapLayerEventPhases.Upcoming; + + if (layer.EventEndsOn.HasValue && utcNow > AsUtc(layer.EventEndsOn.Value)) + return MapLayerEventPhases.Ended; + + return MapLayerEventPhases.Active; + } + + /// + /// Whether maps start with the layer switched on: the department's "on by default", or the layer's event running now. + /// Before and after the event the layer follows "on by default", so it can be reviewed beforehand and switched off + /// for good afterwards. + /// + public static bool IsShownByDefault(MapLayer layer, DateTime utcNow) => + layer != null && (layer.IsOnByDefault || GetPhase(layer, utcNow) == MapLayerEventPhases.Active); + + private static DateTime AsUtc(DateTime value) => + value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc); + } +} diff --git a/Core/Resgrid.Model/MapLayerFileReadResult.cs b/Core/Resgrid.Model/MapLayerFileReadResult.cs new file mode 100644 index 000000000..0cb9efdaa --- /dev/null +++ b/Core/Resgrid.Model/MapLayerFileReadResult.cs @@ -0,0 +1,57 @@ +using GeoJSON.Net.Feature; + +namespace Resgrid.Model +{ + /// Why an uploaded map layer file could not be used. + public enum MapLayerFileReadErrors + { + None = 0, + /// Not a KML, KMZ or GeoJSON file. + UnsupportedType = 1, + /// The file could not be parsed (malformed XML/JSON, damaged KMZ). + Unreadable = 2, + /// Parsed, but no point, line or polygon with usable coordinates. + NoShapes = 3, + /// More shapes than a layer can hold (). + TooManyShapes = 4, + /// More coordinates than a layer can hold (). + TooManyPoints = 5, + /// The file, or the KML inside a KMZ, is larger than . + TooLarge = 6 + } + + /// Limits on one imported layer. A layer is one document, and every map that shows it loads all of it. + public static class MapLayerFileLimits + { + public const long MaxFileBytes = 30L * 1024 * 1024; + public const int MaxFeatures = 20000; + public const int MaxPositions = 250000; + } + + /// + /// The shapes read from an uploaded KML, KMZ or GeoJSON file: plain points, lines and polygons (multi-geometries are + /// split), each with "name", "description", "color" and "category" properties where the file had them. + /// + public class MapLayerFileReadResult + { + public FeatureCollection Features { get; set; } = new FeatureCollection(); + + public MapLayerFileReadErrors Error { get; set; } + + public int Points { get; set; } + + public int Lines { get; set; } + + public int Polygons { get; set; } + + /// Placemarks or features with no coordinates (for example a Google My Maps place located only by address). + public int SkippedWithoutGeometry { get; set; } + + /// Shapes dropped for coordinates outside the world or too few points to draw. + public int SkippedInvalid { get; set; } + + public bool Success => Error == MapLayerFileReadErrors.None; + + public int ShapeCount => Points + Lines + Polygons; + } +} diff --git a/Core/Resgrid.Model/NewCallFieldPolicy.cs b/Core/Resgrid.Model/NewCallFieldPolicy.cs index 37e6c52b8..cf44cbab8 100644 --- a/Core/Resgrid.Model/NewCallFieldPolicy.cs +++ b/Core/Resgrid.Model/NewCallFieldPolicy.cs @@ -32,8 +32,35 @@ public static class NewCallFieldKeys public const string Protocols = "protocols"; public const string LinkedCall = "linkedCall"; public const string DispatchOn = "dispatchOn"; + public const string MapPage = "mapPage"; + public const string PrimaryContact = "primaryContact"; + public const string AdditionalContacts = "additionalContacts"; + public const string CheckInTimers = "checkInTimers"; + public const string Part2Consent = "part2Consent"; + public const string PendingCall = "pendingCall"; + + /// + /// The dispatch recipients section as a whole. Its requirement means "at least one recipient". Its visibility is + /// not set on its own: the section shows while any of does (see + /// ), and the stored rule follows them so a client that only knows this key + /// hides its picker exactly when every list is hidden. + /// public const string DispatchList = "dispatchList"; + public const string DispatchPersonnel = "dispatchPersonnel"; + public const string DispatchGroups = "dispatchGroups"; + public const string DispatchRoles = "dispatchRoles"; + public const string DispatchUnits = "dispatchUnits"; + + /// The four recipient lists a call can be dispatched to, each of which can be hidden on its own. + public static readonly IReadOnlyList DispatchTargets = new List + { + DispatchPersonnel, + DispatchGroups, + DispatchRoles, + DispatchUnits + }; + /// Every configurable key, in the order the admin screen should list them. public static readonly IReadOnlyList All = new List { @@ -43,28 +70,64 @@ public static class NewCallFieldKeys PlusCode, DestinationPoi, IndoorLocation, + MapPage, Note, ContactName, ContactInfo, + PrimaryContact, + AdditionalContacts, ExternalId, IncidentId, ReferenceId, Protocols, LinkedCall, + DispatchPersonnel, + DispatchGroups, + DispatchRoles, + DispatchUnits, + DispatchList, + CheckInTimers, + Part2Consent, DispatchOn, - DispatchList + PendingCall + }; + + /// + /// Fields that can be shown or hidden but never required. A plus code is only a way of finding the location; it is + /// never stored on the call, so requiring it guarantees the crews nothing (require GPS coordinates instead). The + /// check-in timers, Part 2 consent and save-as-pending inputs are switches whose "off" is an answer, and a single + /// recipient list is covered by requiring the recipients as a whole (), which any list + /// satisfies. + /// + private static readonly IReadOnlyList HideOnly = new List + { + PlusCode, + AdditionalContacts, + CheckInTimers, + Part2Consent, + PendingCall, + DispatchPersonnel, + DispatchGroups, + DispatchRoles, + DispatchUnits }; public static bool IsKnown(string key) => !string.IsNullOrWhiteSpace(key) && All.Any(x => string.Equals(x, key, StringComparison.OrdinalIgnoreCase)); + /// True for one of the four recipient lists (). + public static bool IsDispatchTarget(string key) => + !string.IsNullOrWhiteSpace(key) && DispatchTargets.Any(x => string.Equals(x, key, StringComparison.OrdinalIgnoreCase)); + + /// False for a field that can be shown or hidden but never required; see . + public static bool CanBeRequired(string key) => + IsKnown(key) && !HideOnly.Any(x => string.Equals(x, key, StringComparison.OrdinalIgnoreCase)); + /// - /// False for a field that can be shown or hidden but never required. A plus code is only a way of finding the - /// location; it is never stored on the call, so requiring it guarantees the crews nothing, and the web form has - /// no plus code input at all -- a requirement there blocked every call. Require GPS coordinates instead. + /// False for , whose visibility follows the four recipient lists rather than being set. /// - public static bool CanBeRequired(string key) => - IsKnown(key) && !string.Equals(key, PlusCode, StringComparison.OrdinalIgnoreCase); + public static bool CanBeHidden(string key) => + IsKnown(key) && !string.Equals(key, DispatchList, StringComparison.OrdinalIgnoreCase); } /// Visibility and requiredness for one built-in new-call field. @@ -111,13 +174,38 @@ private NewCallFieldRule Find(string key) => Rules?.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)); /// True when the field should be shown. Unknown or unconfigured fields are shown. + /// + /// The recipients section () shows while any of its four lists does. A + /// list with no rule of its own follows the section's stored rule: before the lists could be hidden one by one, + /// hiding the section hid all of them, and a policy saved then must keep doing so. + /// public bool IsVisible(string key) { + if (string.Equals(key, NewCallFieldKeys.DispatchList, StringComparison.OrdinalIgnoreCase)) + { + if (NewCallFieldKeys.DispatchTargets.Any(x => Find(x) != null)) + return NewCallFieldKeys.DispatchTargets.Any(IsVisible); + + return Find(key)?.Visible ?? true; + } + var rule = Find(key); - return rule?.Visible ?? true; + if (rule != null) + return rule.Visible; + + if (NewCallFieldKeys.IsDispatchTarget(key)) + return Find(NewCallFieldKeys.DispatchList)?.Visible ?? true; + + return true; } + /// + /// True when every recipient list is hidden: the call forms then offer an optional "All Call" (dispatch everyone) + /// instead, and a call left without it is informational and pages nobody. + /// + public bool AreAllDispatchTargetsHidden => !NewCallFieldKeys.DispatchTargets.Any(IsVisible); + /// /// True when the field must have a value. Hidden fields, and fields that can never be required /// (), are not required whatever the stored rule says — @@ -130,7 +218,7 @@ public bool IsRequired(string key) if (rule == null) return false; - return rule.Visible && rule.Required && NewCallFieldKeys.CanBeRequired(key); + return rule.Required && NewCallFieldKeys.CanBeRequired(key) && IsVisible(key); } /// True when nothing is configured, i.e. stock behaviour. @@ -140,6 +228,12 @@ public bool IsRequired(string key) /// Drops rules for keys we do not recognise and rules that say nothing (visible, not required), /// so what gets stored stays small and a renamed key cannot linger forever. /// + /// + /// The recipients section's own rule is rewritten to follow its four lists (shown while any list is), so a client + /// that predates the per-list keys and reads only hides its whole + /// picker exactly when the department hid every list. Each list's visibility is worked out before that, with the + /// fallback applies, and written down when it is hidden. + /// public NewCallFieldPolicy Normalize() { Rules = (Rules ?? new List()) @@ -154,6 +248,28 @@ public NewCallFieldPolicy Normalize() return x; }) + .ToList(); + + var targetVisibility = NewCallFieldKeys.DispatchTargets.ToDictionary(x => x, IsVisible); + + foreach (var target in targetVisibility) + { + var rule = Find(target.Key); + + if (rule == null) + Rules.Add(new NewCallFieldRule { Key = target.Key, Visible = target.Value }); + else + rule.Visible = target.Value; + } + + var section = Find(NewCallFieldKeys.DispatchList); + + if (section == null) + Rules.Add(new NewCallFieldRule { Key = NewCallFieldKeys.DispatchList, Visible = targetVisibility.Values.Any(x => x) }); + else + section.Visible = targetVisibility.Values.Any(x => x); + + Rules = Rules .Where(x => !x.Visible || x.Required) .ToList(); diff --git a/Core/Resgrid.Model/Poi.cs b/Core/Resgrid.Model/Poi.cs index b72c9e53e..04cb65fd0 100644 --- a/Core/Resgrid.Model/Poi.cs +++ b/Core/Resgrid.Model/Poi.cs @@ -38,6 +38,21 @@ public class Poi : IEntity /// public int? PoiImportRow { get; set; } + /// + /// The ids this POI offers, in the department's capability order. Filled by + /// IMappingService.PopulatePoiDetailsAsync; not a column (see ). + /// + [NotMapped] + public List CapabilityIds { get; set; } + + /// The names of , same order. Filled with them. + [NotMapped] + public List Capabilities { get; set; } + + /// The POI's current availability report, or null when it is open. Filled with . + [NotMapped] + public PoiAvailability Availability { get; set; } + [NotMapped] [JsonIgnore] public object IdValue @@ -57,6 +72,6 @@ public object IdValue public int IdType => 0; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Type" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Type", "CapabilityIds", "Capabilities", "Availability" }; } } diff --git a/Core/Resgrid.Model/PoiAvailability.cs b/Core/Resgrid.Model/PoiAvailability.cs new file mode 100644 index 000000000..582e5ffa2 --- /dev/null +++ b/Core/Resgrid.Model/PoiAvailability.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// How far a destination can take patients or loads right now (). Open is never + /// stored: a destination with no availability row, or with an expired one, is open. + /// + public enum PoiAvailabilityStatuses + { + /// Taking everything it offers. + Open = 0, + + /// Taking patients, but some capabilities or capacity are reduced (see the unavailable capabilities and note). + Limited = 1, + + /// Asking crews to go elsewhere unless there is no alternative (emergency department diversion / capacity overload). + Diversion = 2, + + /// Not taking anyone. + Closed = 3 + } + + /// + /// A destination's current availability (M0315), set by a dispatcher or administrator when a hospital reports capacity + /// overload, a closed emergency department, or one service down (e.g. the cath lab). One row per POI, replaced on each + /// change; setting it back to open deletes it, and a row past reads as open. No foreign keys: POI + /// deletes and the department purge remove the rows. + /// + [Table("PoiAvailabilities")] + public class PoiAvailability : IEntity + { + public const int MaxNoteLength = 500; + + [Key] + [Required] + public string PoiAvailabilityId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [Required] + public int PoiId { get; set; } + + /// ; never Open in a stored row. + public int Status { get; set; } + + /// Comma-separated s the destination cannot provide right now. + public string UnavailableCapabilityIds { get; set; } + + [MaxLength(MaxNoteLength)] + public string Note { get; set; } + + public string SetByUserId { get; set; } + + public DateTime SetOn { get; set; } + + /// When the report lapses and the destination reads as open again; null lasts until someone changes it. + public DateTime? ExpiresOn { get; set; } + + /// The parsed , without blanks or duplicates. + public List GetUnavailableCapabilityIds() + { + return PoiAvailabilityRules.ParseIds(UnavailableCapabilityIds); + } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return PoiAvailabilityId; } + set { PoiAvailabilityId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "PoiAvailabilities"; + + [NotMapped] + public string IdName => "PoiAvailabilityId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// Reading a : what counts as current, and what a destination can still take. + public static class PoiAvailabilityRules + { + /// The longest a report may run before it lapses (a week); a longer or missing expiry is kept as given. + public static readonly TimeSpan MaxExpiry = TimeSpan.FromDays(7); + + /// The report when it is still current (not open, not expired), else null. + public static PoiAvailability Current(PoiAvailability availability, DateTime nowUtc) + { + if (availability == null || availability.Status == (int)PoiAvailabilityStatuses.Open) + return null; + + if (!Enum.IsDefined(typeof(PoiAvailabilityStatuses), availability.Status)) + return null; + + return availability.ExpiresOn.HasValue && availability.ExpiresOn.Value <= nowUtc ? null : availability; + } + + /// The destination's current status: Open when there is no current report. + public static PoiAvailabilityStatuses StatusOf(PoiAvailability availability, DateTime nowUtc) + { + var current = Current(availability, nowUtc); + return current == null ? PoiAvailabilityStatuses.Open : (PoiAvailabilityStatuses)current.Status; + } + + /// + /// True when the destination can take a patient needing the capability now: it is not closed, and the capability + /// (when one is asked for) is not reported unavailable. A destination on diversion still can, but crews are warned. + /// + public static bool CanProvide(PoiAvailability availability, string poiCapabilityId, DateTime nowUtc) + { + var current = Current(availability, nowUtc); + if (current == null) + return true; + + if (current.Status == (int)PoiAvailabilityStatuses.Closed) + return false; + + if (string.IsNullOrWhiteSpace(poiCapabilityId)) + return true; + + return !current.GetUnavailableCapabilityIds().Contains(poiCapabilityId, StringComparer.OrdinalIgnoreCase); + } + + /// Comma-separated ids to a list, trimmed, without blanks or duplicates (case-insensitive). + public static List ParseIds(string ids) + { + if (string.IsNullOrWhiteSpace(ids)) + return new List(); + + return ids.Split(',') + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/Core/Resgrid.Model/PoiCapability.cs b/Core/Resgrid.Model/PoiCapability.cs new file mode 100644 index 000000000..58e4bc92f --- /dev/null +++ b/Core/Resgrid.Model/PoiCapability.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// A capability a department's points of interest can offer (M0315): "Cath Lab", "Stroke Center", "Trauma Center" for + /// hospitals, and equally "Pet friendly" for a shelter or "Night landing" for a helipad. The department manages the list; + /// POIs are tagged through , so a dispatcher can find the nearest destination that + /// offers what a patient needs, and a destination can report one capability unavailable (). + /// No foreign keys: capability and POI deletes and the department purge (UnlinkedDepartmentCleanup) remove the rows. + /// + [Table("PoiCapabilities")] + public class PoiCapability : IEntity + { + public const int MaxNameLength = 100; + + [Key] + [Required] + public string PoiCapabilityId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [Required] + [MaxLength(MaxNameLength)] + public string Name { get; set; } + + /// Display order in lists and filters (ascending, then by name). + public int Sort { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return PoiCapabilityId; } + set { PoiCapabilityId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "PoiCapabilities"; + + [NotMapped] + public string IdName => "PoiCapabilityId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + /// + /// Tags one POI with one of the department's entries (M0315). DepartmentId is carried so a + /// department's tags load in one query and the department purge sweeps them. + /// + [Table("PoiCapabilityAssignments")] + public class PoiCapabilityAssignment : IEntity + { + [Key] + [Required] + public string PoiCapabilityAssignmentId { get; set; } + + [Required] + public int DepartmentId { get; set; } + + [Required] + public int PoiId { get; set; } + + [Required] + public string PoiCapabilityId { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return PoiCapabilityAssignmentId; } + set { PoiCapabilityAssignmentId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "PoiCapabilityAssignments"; + + [NotMapped] + public string IdName => "PoiCapabilityAssignmentId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/PoiImportFile.cs b/Core/Resgrid.Model/PoiImportFile.cs index 8cbcf4bd3..857926971 100644 --- a/Core/Resgrid.Model/PoiImportFile.cs +++ b/Core/Resgrid.Model/PoiImportFile.cs @@ -17,6 +17,14 @@ public class PoiImportRow public string Note { get; set; } + /// + /// The capability names a "Capabilities" column gave ("Cath Lab; Stroke Center"), trimmed, de-duplicated and cut to + /// ; null without the column (and then left out of RowsJson, as before). The import + /// adds the names the department does not have yet and tags the saved POI with them. + /// + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public List Capabilities { get; set; } + /// Address lookup state () of a row the file gave no position. public int Lookup { get; set; } diff --git a/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs b/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs index 36e21fc17..2af656765 100644 --- a/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsPreventionContracts.cs @@ -160,6 +160,26 @@ public class HydrantImportResult public int Created { get; set; } public int Updated { get; set; } public List Rejected { get; set; } = new List(); + /// CSV header names (as written in the file) that map to no hydrant field and were skipped, e.g. extra columns in another system's export. + public List IgnoredColumns { get; set; } = new List(); + /// + /// Values in a column read under another system's name (hydrant_type, flow_rate, flow_rating_color, status) that could not be read: + /// the hydrant was still imported, with type Other, a blank flow, no flow class or its service state unchanged. Most frequent first. + /// + public List UnmappedValues { get; set; } = new List(); + /// Hydrants another system's status column set out of service, including new hydrants imported out of service. + public int TakenOutOfService { get; set; } + /// Out-of-service hydrants another system's status column returned to service. + public int ReturnedToService { get; set; } + } + + public class HydrantImportUnmappedValue + { + /// The column header as written in the file. + public string Column { get; set; } + public string Value { get; set; } + /// How many rows held this value (compared without regard to case). + public int Rows { get; set; } } /// Which live hydrants a bulk removal takes. diff --git a/Core/Resgrid.Model/Repositories/IPoiCapabilitiesRepository.cs b/Core/Resgrid.Model/Repositories/IPoiCapabilitiesRepository.cs new file mode 100644 index 000000000..618a4fd4d --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IPoiCapabilitiesRepository.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// + /// A department's POI capabilities (PoiCapabilities, M0315) and which POIs offer them (PoiCapabilityAssignments). Every + /// query begins at DepartmentId. + /// + public interface IPoiCapabilitiesRepository : IRepository + { + Task> GetAssignmentsByDepartmentIdAsync(int departmentId); + + Task> GetAssignmentsByPoiIdAsync(int departmentId, int poiId); + + /// Makes the POI's capabilities, in one transaction. + Task ReplacePoiAssignmentsAsync(int departmentId, int poiId, IEnumerable capabilityIds, CancellationToken cancellationToken = default); + + /// Removes every capability from the POIs (they are being deleted). Returns the rows removed. + Task DeleteAssignmentsForPoisAsync(int departmentId, IEnumerable poiIds, CancellationToken cancellationToken = default); + + /// Takes the capability off every POI (it is being deleted). Returns the rows removed. + Task DeleteAssignmentsForCapabilityAsync(int departmentId, string poiCapabilityId, CancellationToken cancellationToken = default); + } + + /// Destination availability reports (PoiAvailabilities, M0315), one per POI. Every query begins at DepartmentId. + public interface IPoiAvailabilitiesRepository : IRepository + { + Task GetByPoiIdAsync(int departmentId, int poiId); + + /// Removes the POIs' reports (they reopened or are being deleted). Returns the rows removed. + Task DeleteForPoisAsync(int departmentId, IEnumerable poiIds, CancellationToken cancellationToken = default); + + /// Takes a deleted capability out of every report that names it. + Task RemoveCapabilityAsync(int departmentId, string poiCapabilityId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs b/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs index f11686968..f85ac35c6 100644 --- a/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs +++ b/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs @@ -70,5 +70,14 @@ public interface ICallStatusAttributionService /// queries for the department, plus one per call a status points at that the unit is not dispatched to. /// Task> GetWorkingCallIdsForUnitsAsync(int departmentId, IReadOnlyCollection latestStates); + + /// + /// One unit's working call, as resolves it from the given status (or no + /// status), reading only that unit's open dispatches. Null when there is none or more than one to choose from. + /// + /// The unit's department. + /// The unit. + /// The status to resolve from (e.g. the one before a transport status), or null. + Task GetWorkingCallIdForUnitAsync(int departmentId, int unitId, UnitState state); } } diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 4c97b6198..0aa2d508e 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -437,6 +437,13 @@ Task SetUnitCallStatusOverridesByUnitTypeAsync(int department /// Task GetCloseCallWhenUnitsClearAsync(int departmentId, bool bypassCache = false); + /// + /// Whether the apps require a reason when a unit's transport status goes to a destination other than its call's + /// assigned one, and Resgrid records the change as a call note (). + /// False when unset. Cached. + /// + Task GetTransportDestinationChecksAsync(int departmentId, bool bypassCache = false); + /// Department-wide dispatch recommendation mode (Off / StationBased / ClosestUnit). Cached. Task GetDispatchRecommendationModeAsync(int departmentId, bool bypassCache = false); diff --git a/Core/Resgrid.Model/Services/IMappingService.cs b/Core/Resgrid.Model/Services/IMappingService.cs index 0b016b2b0..cf6ede93f 100644 --- a/Core/Resgrid.Model/Services/IMappingService.cs +++ b/Core/Resgrid.Model/Services/IMappingService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -43,6 +44,51 @@ public interface IMappingService Task GetDestinationPOIByIdAsync(int departmentId, int poiId); + /// The department's POI capabilities (e.g. "Cath Lab"), by sort order then name. + Task> GetPoiCapabilitiesForDepartmentAsync(int departmentId); + + /// One of the department's POI capabilities, or null when it is not the department's. + Task GetPoiCapabilityByIdAsync(int departmentId, string poiCapabilityId); + + /// + /// Adds or renames a capability. The name is trimmed and must be unique in the department (case-insensitive); a + /// duplicate or blank name is an ArgumentException. A capability of another department is an ArgumentException. + /// + Task SavePoiCapabilityAsync(PoiCapability capability, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// The department's capabilities with these names (case-insensitive), adding the missing ones (POI import). Blank + /// names are skipped and names are cut to . + /// + Task> GetOrAddPoiCapabilitiesByNameAsync(int departmentId, IEnumerable names, CancellationToken cancellationToken = default(CancellationToken)); + + /// Deletes the capability, takes it off every POI and out of every availability report. + Task DeletePoiCapabilityAsync(int departmentId, string poiCapabilityId, CancellationToken cancellationToken = default(CancellationToken)); + + /// The ids of the capabilities the POI offers. + Task> GetPoiCapabilityIdsForPoiAsync(int departmentId, int poiId); + + /// Makes the given capabilities (ids of the department's capabilities; others are ignored) the POI's. + Task SetPoiCapabilitiesAsync(int departmentId, int poiId, IEnumerable poiCapabilityIds, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Fills , and (current + /// reports only) on the department's POIs. Two queries at most, one when the department has no capabilities. + /// + Task PopulatePoiDetailsAsync(int departmentId, IEnumerable pois); + + /// The department's current availability reports (not open, not expired), by POI id. + Task> GetCurrentPoiAvailabilitiesAsync(int departmentId); + + /// + /// Sets a destination's availability. Open (or an expiry in the past) deletes its report. Unavailable capabilities + /// are limited to the department's; the note is trimmed and cut to ; an + /// expiry is capped at from now. A POI of another department, or one that + /// is not a destination, is an ArgumentException. Returns the current report, or null when open. + /// + Task SetPoiAvailabilityAsync(int departmentId, int poiId, PoiAvailabilityStatuses status, IEnumerable unavailableCapabilityIds, + string note, DateTime? expiresOnUtc, string userId, CancellationToken cancellationToken = default(CancellationToken)); + /// /// Gets the type by identifier asynchronous. /// diff --git a/Core/Resgrid.Model/Services/INearestDestinationService.cs b/Core/Resgrid.Model/Services/INearestDestinationService.cs new file mode 100644 index 000000000..820ec5a04 --- /dev/null +++ b/Core/Resgrid.Model/Services/INearestDestinationService.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// One destination in a nearest-destination ranking (). + public class NearestDestination + { + /// The destination, with its capabilities and current availability filled ( set). + public Poi Poi { get; set; } + + /// Straight-line distance from the origin. + public double DistanceMeters { get; set; } + + /// Travel seconds from the origin; -1 when no travel time was looked up (beyond the shortlist, or the lookup failed). + public double TravelSeconds { get; set; } = -1; + + /// The destination's current availability (Open when it has no current report). + public PoiAvailabilityStatuses Status { get; set; } + + /// + /// True when it can take the patient now: not closed and, with a capability asked for, that capability not reported + /// unavailable (). A destination on diversion still can. + /// + public bool CanProvide { get; set; } + } + + /// + /// Ranks a department's destination POIs from a point (a unit's position or a call's location), optionally only those + /// offering a capability: the closest by straight line get routed travel times (the dispatch shortlist size), then + /// destinations that can take the patient come first, diversion after them, closed or capability-down last; within each, + /// routed times before straight-line estimates. + /// + public interface INearestDestinationService + { + /// The default number of destinations routed. + const int DefaultShortlistSize = 5; + + /// The most destinations one ranking returns. + const int MaximumResults = 50; + + /// The department. + /// Origin latitude. + /// Origin longitude. + /// Only destinations offering this capability; null or blank for all. + /// The unit travelling, so its travel mode and vehicle limits apply; null routes plain driving. + /// How many to return (1 to ). + /// Cancels the travel time lookups. + Task> GetNearestDestinationsAsync(int departmentId, double latitude, double longitude, string poiCapabilityId, int? unitId, + int maxResults, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Model/Services/ITransportDestinationCheckService.cs b/Core/Resgrid.Model/Services/ITransportDestinationCheckService.cs new file mode 100644 index 000000000..b558a1e0b --- /dev/null +++ b/Core/Resgrid.Model/Services/ITransportDestinationCheckService.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Transport destination checks (, rules in + /// ): records on the call when a unit's transport status goes to a destination other + /// than the call's assigned one. Runs from UnitsService after a status is saved. + /// + public interface ITransportDestinationCheckService + { + /// + /// After a unit status is saved: when the department turned the checks on, the status is a transport status (base type + /// Transporting or At Hospital) whose destination is a POI other than its working call's assigned destination, and the + /// unit's previous status was not already headed to that POI, adds a call note naming both destinations and the reason + /// (the status note). Never throws: the status is already saved. + /// + /// The note added, or null when there was nothing to record. + Task OnUnitStateSavedAsync(int departmentId, UnitState state, UnitState previousState, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Model/TransportDestinationRules.cs b/Core/Resgrid.Model/TransportDestinationRules.cs new file mode 100644 index 000000000..ae9630d74 --- /dev/null +++ b/Core/Resgrid.Model/TransportDestinationRules.cs @@ -0,0 +1,56 @@ +namespace Resgrid.Model +{ + /// + /// Transport destination checks (): a call can name the + /// destination its patient or load is taken to (), and a unit reporting a transport + /// status to another destination POI is a deviation. For a transport status the apps pre-select the POI the unit's current + /// transport status already points at, else the call's destination, and, with the setting on, require a reason for a + /// deviation unless it is the POI the unit already reported; the server records each new deviation as a call note + /// (). The apps (Omni, Unit, Dispatch) carry the same rules, so keep them in step. + /// + public static class TransportDestinationRules + { + /// + /// True for the status meanings that take a unit to a call's destination: Transporting and At Hospital. Meaning comes + /// from the base type only, never from a status name or id. + /// + public static bool IsTransportBaseType(int? baseType) + { + return baseType == (int)ActionBaseTypes.Transporting || baseType == (int)ActionBaseTypes.AtHospital; + } + + /// The call's assigned destination POI id, or null when it has none. + public static int? AssignedDestinationPoiId(Call call) + { + return call?.DestinationPoiId is int id && id > 0 ? id : null; + } + + /// + /// True when a status's destination is a POI other than the call's assigned destination. No destination, a call or a + /// station is never a deviation, and neither is any destination when the call has none assigned. + /// + public static bool IsDeviation(int? destinationId, int? destinationType, int? assignedDestinationPoiId) + { + if (!(assignedDestinationPoiId > 0) || !(destinationId > 0)) + return false; + + if (destinationType != (int)DestinationEntityTypes.Poi) + return false; + + return destinationId.Value != assignedDestinationPoiId.Value; + } + + /// + /// True when a new status repeats the previous status's POI destination (Transporting then At Hospital at the same + /// place): the deviation was already recorded when the unit first headed there. + /// + public static bool RepeatsPreviousDestination(UnitState state, UnitState previousState) + { + if (state == null || previousState == null || previousState.UnitId != state.UnitId) + return false; + + return previousState.DestinationType == (int)DestinationEntityTypes.Poi && state.DestinationType == (int)DestinationEntityTypes.Poi && + previousState.DestinationId.HasValue && previousState.DestinationId == state.DestinationId; + } + } +} diff --git a/Core/Resgrid.Model/UnitResponseOrigin.cs b/Core/Resgrid.Model/UnitResponseOrigin.cs index b7625cc82..69e35ab66 100644 --- a/Core/Resgrid.Model/UnitResponseOrigin.cs +++ b/Core/Resgrid.Model/UnitResponseOrigin.cs @@ -73,5 +73,23 @@ public static double EstimateTravelSeconds(double distanceMeters) { return Math.Round(distanceMeters * EstimatedRoadDistanceFactor / EstimatedSpeedMetersPerSecond); } + + /// + /// The response time a closest-unit ranking orders on: whole seconds, rounded as the dispatch screens show them + /// (m:ss, half up), so the order always agrees with the times on screen. Resources level on the second then go + /// nearest first (straight-line distance) in every ranking: units sharing a station share its drive time, and a + /// router's fraction of a second is not a reason to send the farther one. Thresholds (time conditions, + /// waiting-call limits) still compare the unrounded seconds. + /// + public static double RankingSeconds(double responseSeconds) + { + return Math.Round(responseSeconds, MidpointRounding.AwayFromZero); + } + + /// , with no time ranking last. + public static double RankingSeconds(double? responseSeconds) + { + return responseSeconds.HasValue ? RankingSeconds(responseSeconds.Value) : double.MaxValue; + } } } diff --git a/Core/Resgrid.Services/AdminAssist/SettingsEvidenceSource.cs b/Core/Resgrid.Services/AdminAssist/SettingsEvidenceSource.cs index d33c18917..a6ba6fa8b 100644 --- a/Core/Resgrid.Services/AdminAssist/SettingsEvidenceSource.cs +++ b/Core/Resgrid.Services/AdminAssist/SettingsEvidenceSource.cs @@ -4,13 +4,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.AdminAssist; using Resgrid.Model.Repositories; namespace Resgrid.Services.AdminAssist { - /// One uncached repository read. Only explicit scalar bindings and secret-presence markers leave it. + /// One uncached repository read. Only explicit scalar bindings, secret-presence markers and derived booleans leave it. public sealed class SettingsEvidenceSource(IDepartmentSettingsRepository settings) : IAdminAssistEvidenceSource { private static readonly DepartmentSettingTypes[] BooleanSettings = @@ -39,9 +40,11 @@ public sealed class SettingsEvidenceSource(IDepartmentSettingsRepository setting ["mapTokenPresent"] = DepartmentSettingTypes.MappingMapboxAccessToken, ["mapStylePresent"] = DepartmentSettingTypes.MappingMapboxStyleUrl }; + /// True when New Call Form Fields hides every dispatch list (personnel, groups, roles and units). + public const string NewCallDispatchListsHiddenId = "newCallDispatchListsHidden"; public string SourceId => "DepartmentSettings"; public IReadOnlyList EvidenceIds { get; } = BooleanSettings.Select(s => s.ToString()) - .Concat(NumberSettings.Keys.Select(s => s.ToString())).Concat(PresenceSettings.Keys).ToArray(); + .Concat(NumberSettings.Keys.Select(s => s.ToString())).Concat(PresenceSettings.Keys).Append(NewCallDispatchListsHiddenId).ToArray(); public async Task> ReadAsync(AdminAssistActor actor, DateTime now, CancellationToken ct) { @@ -68,7 +71,24 @@ public async Task> ReadAsync(AdminAssistAct foreach (var (id, type) in PresenceSettings) values.Add(new ConfigurationEvidence(id, EvidenceState.Known, SourceId, "1", now, Boolean: data.TryGetValue(type, out var value) && !string.IsNullOrWhiteSpace(value))); + values.Add(NewCallDispatchListsHidden(data.TryGetValue(DepartmentSettingTypes.NewCallFieldPolicy, out var policy) ? policy : null, now)); return values; } + + /// + /// Only the derived answer leaves the source, never the stored rules. No policy row means the stock form (every list shown). + /// A blob that does not read is Unknown here, although the call forms fall back to the stock form for it. + /// + private ConfigurationEvidence NewCallDispatchListsHidden(string raw, DateTime now) + { + bool? hidden = false; + if (!string.IsNullOrWhiteSpace(raw)) + { + try { hidden = ObjectSerialization.Deserialize(raw)?.Normalize().AreAllDispatchTargetsHidden; } + catch (Exception) { hidden = null; } + } + return new ConfigurationEvidence(NewCallDispatchListsHiddenId, hidden.HasValue ? EvidenceState.Known : EvidenceState.Unknown, SourceId, "1", now, + Boolean: hidden, ReasonCode: hidden.HasValue ? null : "InvalidStoredValue"); + } } } diff --git a/Core/Resgrid.Services/CallStatusAttributionService.cs b/Core/Resgrid.Services/CallStatusAttributionService.cs index 4d35025fb..ed182eac6 100644 --- a/Core/Resgrid.Services/CallStatusAttributionService.cs +++ b/Core/Resgrid.Services/CallStatusAttributionService.cs @@ -201,6 +201,32 @@ public async Task> GetWorkingCallIdsForUnitsAsync(int depar return working; } + + public async Task GetWorkingCallIdForUnitAsync(int departmentId, int unitId, UnitState state) + { + if (unitId <= 0) + return null; + + var latest = state != null && state.UnitId == unitId ? state : null; + var dispatched = ((await _callDispatchUnitRepository.GetOpenCallUnitDispatchesForUnitAsync(departmentId, unitId)) ?? Enumerable.Empty()) + .Where(x => x != null) + .ToList(); + + var openCallIds = new HashSet(dispatched.Select(x => x.CallId)); + + // A status can point at an open call the unit was never dispatched to (a unit that took the call itself). + var linkedCallId = latest != null && latest.DestinationType == (int)DestinationEntityTypes.Call ? latest.DestinationId : null; + if (linkedCallId > 0 && !openCallIds.Contains(linkedCallId.Value) && await IsOpenCallAsync(departmentId, linkedCallId.Value)) + openCallIds.Add(linkedCallId.Value); + + if (openCallIds.Count == 0) + return null; + + var isClearing = latest != null && latest.UnitStateId > 0 && + CallStatusLinkage.IsClearingUnitState(latest.State, await GetBaseTypesAsync(departmentId, CustomStateTypes.Unit)); + + return UnitCallInvolvement.ResolveWorkingCallId(latest, isClearing, openCallIds, dispatched); + } #endregion Write-time attribution #region Read-time inference diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 7a15184c7..9dc2fa8d2 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -25,6 +25,7 @@ public partial class DepartmentSettingsService : IDepartmentSettingsService private static string PersonnelOnUnitSetUnitStatusCacheKey = "DSetPersonnelOnUnitSetUnitStatus_{0}"; private static string StatusHoldToConfirmCacheKey = "DSetStatusHoldToConfirm_{0}"; private static string CloseCallWhenUnitsClearCacheKey = "DSetCloseCallWhenUnitsClear_{0}"; + private static string TransportDestinationChecksCacheKey = "DSetTransportDestinationChecks_{0}"; private static string ModernNotificationsCacheKey = "DSetModernNotifications_{0}"; private static string RequirePasswordResetViaEmailCacheKey = "DSetRequirePasswordResetViaEmail_{0}"; private static string ForceChatbotSecurityPinCacheKey = "DSetForceChatbotSecurityPin_{0}"; @@ -1790,6 +1791,22 @@ async Task getSetting() return bool.TryParse(value, out var enabled) && enabled; } + public async Task GetTransportDestinationChecksAsync(int departmentId, bool bypassCache = false) + { + async Task getSetting() + { + var s = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.TransportDestinationChecks); + return s?.Setting ?? "false"; + } + + var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache + ? await _cacheProvider.RetrieveAsync(string.Format(TransportDestinationChecksCacheKey, departmentId), getSetting, LongCacheLength) + : await getSetting(); + + // A blank cached entry or a hand-edited row reads as the default. + return bool.TryParse(value, out var enabled) && enabled; + } + public async Task SetDepartmentModuleSettingsAsync(int departmentId, DepartmentModuleSettings settings, CancellationToken cancellationToken = default(CancellationToken)) { return await SaveOrUpdateSettingAsync(departmentId, ObjectSerialization.Serialize(settings), @@ -2005,6 +2022,9 @@ private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSetti case DepartmentSettingTypes.CloseCallWhenUnitsClear: cacheKey = string.Format(CloseCallWhenUnitsClearCacheKey, departmentId); break; + case DepartmentSettingTypes.TransportDestinationChecks: + cacheKey = string.Format(TransportDestinationChecksCacheKey, departmentId); + break; case DepartmentSettingTypes.EnableModernNotifications: cacheKey = string.Format(ModernNotificationsCacheKey, departmentId); break; diff --git a/Core/Resgrid.Services/DispatchRecommendationService.Conditional.cs b/Core/Resgrid.Services/DispatchRecommendationService.Conditional.cs index 6598803fd..2a1458457 100644 --- a/Core/Resgrid.Services/DispatchRecommendationService.Conditional.cs +++ b/Core/Resgrid.Services/DispatchRecommendationService.Conditional.cs @@ -815,9 +815,10 @@ private static List SortRanked(ConditionalState state, IEnumerab .ThenBy(e => e.SortName, StringComparer.OrdinalIgnoreCase) .ToList(); + // Response in the whole seconds the screens show (UnitResponseOrigin.RankingSeconds): level on the second, nearest first. return ordered .ThenBy(e => e.ResponseSeconds.HasValue ? 0 : 1) - .ThenBy(e => e.ResponseSeconds ?? 0) + .ThenBy(e => UnitResponseOrigin.RankingSeconds(e.ResponseSeconds)) .ThenBy(e => e.Distance ?? double.MaxValue) .ThenBy(e => e.SortName, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -1083,11 +1084,13 @@ private async Task FillFirstArrivingRowAsync(RecommendationContext context, Cond var ranking = await RankAsync(context, state, row.Targets, row.StatusFilter, row.StatusBaseTypes, 1); - // Station order is not arrival order: the earliest arrival is the fastest response, rested resources first. + // Station order is not arrival order: the earliest arrival is the fastest response, rested resources first, and + // level on the second (as the closest-unit ranking compares), the nearer one. var first = ranking.Ranked .OrderBy(e => e.InRestPeriod ? 1 : 0) .ThenBy(e => e.ResponseSeconds.HasValue ? 0 : 1) - .ThenBy(e => e.ResponseSeconds ?? 0) + .ThenBy(e => UnitResponseOrigin.RankingSeconds(e.ResponseSeconds)) + .ThenBy(e => e.Distance ?? double.MaxValue) .FirstOrDefault(); if (first == null) diff --git a/Core/Resgrid.Services/DispatchRecommendationService.cs b/Core/Resgrid.Services/DispatchRecommendationService.cs index 1a9551764..42f1a9c02 100644 --- a/Core/Resgrid.Services/DispatchRecommendationService.cs +++ b/Core/Resgrid.Services/DispatchRecommendationService.cs @@ -1174,6 +1174,8 @@ private async Task FillUnitRequirementByProximityAsync(RecommendationContext con // Ranked on response time by default: the turnout for where the unit is plus estimated travel. With no turnout // configured this orders exactly as distance does; with one, a unit already out can beat a nearer unit that // still has to leave its station. A department that ranks by distance gets the nearest unit whatever its turnout. + // Response compares in whole seconds (UnitResponseOrigin.RankingSeconds), as the screens show it: units level + // on the second go nearest first. var byDistance = context.Config.RanksByDistance; var ranked = located .Select(c => @@ -1189,7 +1191,7 @@ private async Task FillUnitRequirementByProximityAsync(RecommendationContext con }) .Where(x => context.Config.MaxRadiusMeters <= 0 || x.Distance <= context.Config.MaxRadiusMeters) .OrderBy(x => IsUnitInRestPeriod(context, x.Candidate.Unit.UnitId) ? 1 : 0) - .ThenBy(x => byDistance ? x.Distance : x.EstimatedResponse) + .ThenBy(x => byDistance ? x.Distance : UnitResponseOrigin.RankingSeconds(x.EstimatedResponse)) .ThenBy(x => byDistance ? x.EstimatedResponse : x.Distance) .ToList(); @@ -1221,7 +1223,7 @@ private async Task FillUnitRequirementByProximityAsync(RecommendationContext con // dropping to the end of the shortlist, where it would lose to slower units with a route. var reranked = shortlist .OrderBy(x => IsUnitInRestPeriod(context, x.Candidate.Unit.UnitId) ? 1 : 0) - .ThenBy(x => etaByUnitId.TryGetValue(x.Candidate.Unit.UnitId, out var eta) ? x.Candidate.TurnoutSeconds + eta : x.EstimatedResponse) + .ThenBy(x => UnitResponseOrigin.RankingSeconds(etaByUnitId.TryGetValue(x.Candidate.Unit.UnitId, out var eta) ? x.Candidate.TurnoutSeconds + eta : x.EstimatedResponse)) .ThenBy(x => x.Distance) .ToList(); @@ -1338,7 +1340,7 @@ private async Task FillRoleRequirementByProximityAsync(RecommendationContext con // Same as units: a failed lookup ranks on the straight-line estimate, in seconds like the routed ETAs. var reranked = shortlist .OrderBy(x => IsUserInRestPeriod(context, x.Candidate.UserId) ? 1 : 0) - .ThenBy(x => etaByUserId.TryGetValue(x.Candidate.UserId, out var eta) ? eta : UnitResponseOrigin.EstimateTravelSeconds(x.Distance)) + .ThenBy(x => UnitResponseOrigin.RankingSeconds(etaByUserId.TryGetValue(x.Candidate.UserId, out var eta) ? eta : UnitResponseOrigin.EstimateTravelSeconds(x.Distance))) .ThenBy(x => x.Distance) .ToList(); diff --git a/Core/Resgrid.Services/MappingService.cs b/Core/Resgrid.Services/MappingService.cs index 73016223a..3c2908ca3 100644 --- a/Core/Resgrid.Services/MappingService.cs +++ b/Core/Resgrid.Services/MappingService.cs @@ -18,11 +18,16 @@ public class MappingService : IMappingService private readonly IMapLayersDocRepository _mapLayersDocRepository; private readonly Lazy _searchProjections; private readonly ITypeIconsRepository _typeIconsRepository; + private readonly IPoiCapabilitiesRepository _poiCapabilitiesRepository; + private readonly IPoiAvailabilitiesRepository _poiAvailabilitiesRepository; public MappingService(IPoiTypesRepository poiTypesRepository, IPoisRepository poisRepository, Lazy> mapLayersRepository, IMapLayersDocRepository mapLayersDocRepository, Lazy searchProjections = null, - ITypeIconsRepository typeIconsRepository = null) + ITypeIconsRepository typeIconsRepository = null, IPoiCapabilitiesRepository poiCapabilitiesRepository = null, + IPoiAvailabilitiesRepository poiAvailabilitiesRepository = null) { + _poiCapabilitiesRepository = poiCapabilitiesRepository; + _poiAvailabilitiesRepository = poiAvailabilitiesRepository; _searchProjections = searchProjections; _typeIconsRepository = typeIconsRepository; _poiTypesRepository = poiTypesRepository; @@ -137,6 +142,9 @@ public async Task GetTypeByIdAsync(int poiTypeId) if (deleted && _typeIconsRepository != null) await _typeIconsRepository.DeleteForTypeAsync(type.DepartmentId, TypeIconKinds.PoiType, type.PoiTypeId, null, cancellationToken); + if (deleted) + await DeletePoiDetailsAsync(type.DepartmentId, (type.Pois ?? Enumerable.Empty()).Select(x => x.PoiId).ToList(), cancellationToken); + if (deleted && _searchProjections != null) foreach (var poi in type.Pois ?? Enumerable.Empty()) await _searchProjections.Value.RemoveAsync(type.DepartmentId, SearchEntityTypes.Poi, poi.PoiId.ToString(), cancellationToken); @@ -152,9 +160,11 @@ public async Task GetTypeByIdAsync(int poiTypeId) if (poi != null) { - var type = _searchProjections != null ? await GetTypeByIdAsync(poi.PoiTypeId) : null; + var type = await GetTypeByIdAsync(poi.PoiTypeId); var deleted = await _poisRepository.DeleteAsync(poi, cancellationToken); if (deleted && type != null) + await DeletePoiDetailsAsync(type.DepartmentId, new List { poi.PoiId }, cancellationToken); + if (deleted && type != null && _searchProjections != null) await _searchProjections.Value.RemoveAsync(type.DepartmentId, SearchEntityTypes.Poi, poi.PoiId.ToString(), cancellationToken); return deleted; } @@ -162,6 +172,257 @@ public async Task GetTypeByIdAsync(int poiTypeId) return false; } + /// + /// Capability tags and availability reports have no foreign key to their POI; they go with it. A failure leaves only + /// unreachable rows (the department purge sweeps them), never a failed delete. + /// + private async Task DeletePoiDetailsAsync(int departmentId, IReadOnlyCollection poiIds, CancellationToken cancellationToken) + { + if (poiIds == null || poiIds.Count == 0) + return; + + try + { + if (_poiCapabilitiesRepository != null) + await _poiCapabilitiesRepository.DeleteAssignmentsForPoisAsync(departmentId, poiIds, cancellationToken); + + if (_poiAvailabilitiesRepository != null) + await _poiAvailabilitiesRepository.DeleteForPoisAsync(departmentId, poiIds, cancellationToken); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex, $"Removing the capabilities and availability of {poiIds.Count} deleted POIs of department {departmentId} failed."); + } + } + + public async Task> GetPoiCapabilitiesForDepartmentAsync(int departmentId) + { + if (_poiCapabilitiesRepository == null) + return new List(); + + var capabilities = await _poiCapabilitiesRepository.GetAllByDepartmentIdAsync(departmentId); + + return (capabilities ?? Enumerable.Empty()) + .Where(x => x != null && x.DepartmentId == departmentId) + .OrderBy(x => x.Sort) + .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task GetPoiCapabilityByIdAsync(int departmentId, string poiCapabilityId) + { + if (string.IsNullOrWhiteSpace(poiCapabilityId)) + return null; + + return (await GetPoiCapabilitiesForDepartmentAsync(departmentId)) + .FirstOrDefault(x => string.Equals(x.PoiCapabilityId, poiCapabilityId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + public async Task SavePoiCapabilityAsync(PoiCapability capability, CancellationToken cancellationToken = default(CancellationToken)) + { + if (capability == null || capability.DepartmentId <= 0) + throw new ArgumentException("The capability has no department."); + + var name = CapabilityName(capability.Name); + if (name == null) + throw new ArgumentException("Give the capability a name."); + + var existing = await GetPoiCapabilitiesForDepartmentAsync(capability.DepartmentId); + var isNew = string.IsNullOrWhiteSpace(capability.PoiCapabilityId); + + if (!isNew && !existing.Any(x => string.Equals(x.PoiCapabilityId, capability.PoiCapabilityId, StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("The capability does not exist."); + + if (existing.Any(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase) && + (isNew || !string.Equals(x.PoiCapabilityId, capability.PoiCapabilityId, StringComparison.OrdinalIgnoreCase)))) + throw new ArgumentException($"A capability named \"{name}\" already exists."); + + capability.Name = name; + if (isNew && capability.Sort == 0 && existing.Count > 0) + capability.Sort = existing.Max(x => x.Sort) + 1; + + return await _poiCapabilitiesRepository.SaveOrUpdateAsync(capability, cancellationToken); + } + + public async Task> GetOrAddPoiCapabilitiesByNameAsync(int departmentId, IEnumerable names, CancellationToken cancellationToken = default(CancellationToken)) + { + var wanted = (names ?? Enumerable.Empty()).Select(CapabilityName).Where(x => x != null).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var result = new List(); + if (wanted.Count == 0 || _poiCapabilitiesRepository == null) + return result; + + var existing = await GetPoiCapabilitiesForDepartmentAsync(departmentId); + var sort = existing.Count == 0 ? 0 : existing.Max(x => x.Sort); + + foreach (var name in wanted) + { + var match = existing.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + if (match == null) + { + match = await _poiCapabilitiesRepository.SaveOrUpdateAsync(new PoiCapability { DepartmentId = departmentId, Name = name, Sort = ++sort }, cancellationToken); + existing.Add(match); + } + + result.Add(match); + } + + return result; + } + + private static string CapabilityName(string name) + { + var trimmed = name?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + return null; + + return trimmed.Length > PoiCapability.MaxNameLength ? trimmed.Substring(0, PoiCapability.MaxNameLength).TrimEnd() : trimmed; + } + + public async Task DeletePoiCapabilityAsync(int departmentId, string poiCapabilityId, CancellationToken cancellationToken = default(CancellationToken)) + { + var capability = await GetPoiCapabilityByIdAsync(departmentId, poiCapabilityId); + + if (capability == null) + return false; + + await _poiCapabilitiesRepository.DeleteAssignmentsForCapabilityAsync(departmentId, capability.PoiCapabilityId, cancellationToken); + + if (_poiAvailabilitiesRepository != null) + await _poiAvailabilitiesRepository.RemoveCapabilityAsync(departmentId, capability.PoiCapabilityId, cancellationToken); + + return await _poiCapabilitiesRepository.DeleteAsync(capability, cancellationToken); + } + + public async Task> GetPoiCapabilityIdsForPoiAsync(int departmentId, int poiId) + { + if (_poiCapabilitiesRepository == null) + return new List(); + + var assignments = await _poiCapabilitiesRepository.GetAssignmentsByPoiIdAsync(departmentId, poiId); + + return (assignments ?? Enumerable.Empty()).Select(x => x.PoiCapabilityId).ToList(); + } + + public async Task SetPoiCapabilitiesAsync(int departmentId, int poiId, IEnumerable poiCapabilityIds, CancellationToken cancellationToken = default(CancellationToken)) + { + if (_poiCapabilitiesRepository == null) + return; + + // Only the department's own capabilities: a posted id from another department is dropped. + var departmentCapabilityIds = (await GetPoiCapabilitiesForDepartmentAsync(departmentId)).Select(x => x.PoiCapabilityId).ToList(); + var ids = (poiCapabilityIds ?? Enumerable.Empty()) + .Select(x => departmentCapabilityIds.FirstOrDefault(y => string.Equals(y, x?.Trim(), StringComparison.OrdinalIgnoreCase))) + .Where(x => x != null) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + await _poiCapabilitiesRepository.ReplacePoiAssignmentsAsync(departmentId, poiId, ids, cancellationToken); + } + + public async Task PopulatePoiDetailsAsync(int departmentId, IEnumerable pois) + { + var poiList = (pois ?? Enumerable.Empty()).Where(x => x != null).ToList(); + foreach (var poi in poiList) + { + poi.CapabilityIds = new List(); + poi.Capabilities = new List(); + poi.Availability = null; + } + + if (poiList.Count == 0) + return; + + var capabilities = await GetPoiCapabilitiesForDepartmentAsync(departmentId); + if (capabilities.Count > 0) + { + var order = capabilities.Select((capability, index) => (capability, index)) + .ToDictionary(x => x.capability.PoiCapabilityId, x => x, StringComparer.OrdinalIgnoreCase); + var assignments = await _poiCapabilitiesRepository.GetAssignmentsByDepartmentIdAsync(departmentId); + var byPoi = (assignments ?? Enumerable.Empty()) + .Where(x => x.PoiCapabilityId != null && order.ContainsKey(x.PoiCapabilityId)) + .GroupBy(x => x.PoiId) + .ToDictionary(g => g.Key, g => g.Select(x => order[x.PoiCapabilityId]).Distinct().OrderBy(x => x.index).Select(x => x.capability).ToList()); + + foreach (var poi in poiList) + { + if (byPoi.TryGetValue(poi.PoiId, out var offered)) + { + poi.CapabilityIds = offered.Select(x => x.PoiCapabilityId).ToList(); + poi.Capabilities = offered.Select(x => x.Name).ToList(); + } + } + } + + var availabilities = await GetCurrentPoiAvailabilitiesAsync(departmentId); + foreach (var poi in poiList) + if (availabilities.TryGetValue(poi.PoiId, out var availability)) + poi.Availability = availability; + } + + public async Task> GetCurrentPoiAvailabilitiesAsync(int departmentId) + { + var result = new Dictionary(); + if (_poiAvailabilitiesRepository == null) + return result; + + var now = DateTime.UtcNow; + foreach (var report in await _poiAvailabilitiesRepository.GetAllByDepartmentIdAsync(departmentId) ?? Enumerable.Empty()) + { + var current = report != null && report.DepartmentId == departmentId ? PoiAvailabilityRules.Current(report, now) : null; + if (current != null) + result[current.PoiId] = current; + } + + return result; + } + + public async Task SetPoiAvailabilityAsync(int departmentId, int poiId, PoiAvailabilityStatuses status, IEnumerable unavailableCapabilityIds, + string note, DateTime? expiresOnUtc, string userId, CancellationToken cancellationToken = default(CancellationToken)) + { + if (_poiAvailabilitiesRepository == null) + throw new InvalidOperationException("Destination availability is not available."); + + if (!Enum.IsDefined(typeof(PoiAvailabilityStatuses), status)) + throw new ArgumentException("Unknown availability."); + + if (await GetDestinationPOIByIdAsync(departmentId, poiId) == null) + throw new ArgumentException("The destination does not exist."); + + var now = DateTime.UtcNow; + var existing = await _poiAvailabilitiesRepository.GetByPoiIdAsync(departmentId, poiId); + + if (status == PoiAvailabilityStatuses.Open || (expiresOnUtc.HasValue && expiresOnUtc.Value <= now)) + { + if (existing != null) + await _poiAvailabilitiesRepository.DeleteForPoisAsync(departmentId, new[] { poiId }, cancellationToken); + + return null; + } + + var departmentCapabilityIds = (await GetPoiCapabilitiesForDepartmentAsync(departmentId)).Select(x => x.PoiCapabilityId).ToList(); + var unavailable = (unavailableCapabilityIds ?? Enumerable.Empty()) + .Select(x => departmentCapabilityIds.FirstOrDefault(y => string.Equals(y, x?.Trim(), StringComparison.OrdinalIgnoreCase))) + .Where(x => x != null) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var trimmedNote = note?.Trim(); + if (string.IsNullOrEmpty(trimmedNote)) + trimmedNote = null; + else if (trimmedNote.Length > PoiAvailability.MaxNoteLength) + trimmedNote = trimmedNote.Substring(0, PoiAvailability.MaxNoteLength); + + var report = existing ?? new PoiAvailability { DepartmentId = departmentId, PoiId = poiId }; + report.Status = (int)status; + report.UnavailableCapabilityIds = unavailable.Count == 0 ? null : string.Join(",", unavailable); + report.Note = trimmedNote; + report.SetByUserId = userId; + report.SetOn = now; + report.ExpiresOn = expiresOnUtc.HasValue && expiresOnUtc.Value > now + PoiAvailabilityRules.MaxExpiry ? now + PoiAvailabilityRules.MaxExpiry : expiresOnUtc; + + return await _poiAvailabilitiesRepository.SaveOrUpdateAsync(report, cancellationToken); + } + /// Most POIs one bulk removal accepts. public const int MaxBulkPoiDelete = 50000; @@ -187,6 +448,8 @@ public async Task GetTypeByIdAsync(int poiTypeId) // Only the rows the statements removed lose their search projection: a ticked POI moved to another type meanwhile is kept. var deleted = await _poisRepository.DeleteForTypeAsync(type.PoiTypeId, matched, cancellationToken) ?? Array.Empty(); + if (deleted.Count > 0) + await DeletePoiDetailsAsync(type.DepartmentId, deleted.ToList(), cancellationToken); if (deleted.Count > 0 && _searchProjections != null) await _searchProjections.Value.RemoveManyAsync(type.DepartmentId, SearchEntityTypes.Poi, deleted.Select(id => id.ToString()), cancellationToken); return deleted.Count; diff --git a/Core/Resgrid.Services/NearestDestinationService.cs b/Core/Resgrid.Services/NearestDestinationService.cs new file mode 100644 index 000000000..98076cef6 --- /dev/null +++ b/Core/Resgrid.Services/NearestDestinationService.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// Ranks destination POIs from a point (). + public class NearestDestinationService : INearestDestinationService + { + private readonly IMappingService _mappingService; + private readonly IGeoService _geoService; + private readonly IUnitsService _unitsService; + + public NearestDestinationService(IMappingService mappingService, IGeoService geoService, IUnitsService unitsService) + { + _mappingService = mappingService; + _geoService = geoService; + _unitsService = unitsService; + } + + public async Task> GetNearestDestinationsAsync(int departmentId, double latitude, double longitude, string poiCapabilityId, + int? unitId, int maxResults, CancellationToken cancellationToken = default(CancellationToken)) + { + if (!IsUsableCoordinate(latitude, longitude)) + return new List(); + + var take = Math.Clamp(maxResults, 1, INearestDestinationService.MaximumResults); + var capabilityId = string.IsNullOrWhiteSpace(poiCapabilityId) ? null : poiCapabilityId.Trim(); + + var pois = await _mappingService.GetDestinationPOIsForDepartmentAsync(departmentId) ?? new List(); + await _mappingService.PopulatePoiDetailsAsync(departmentId, pois); + + var now = DateTime.UtcNow; + var candidates = pois + .Where(poi => IsUsableCoordinate(poi.Latitude, poi.Longitude)) + .Where(poi => capabilityId == null || (poi.CapabilityIds ?? new List()).Contains(capabilityId, StringComparer.OrdinalIgnoreCase)) + .Select(poi => new NearestDestination + { + Poi = poi, + DistanceMeters = GeoMath.HaversineMeters(latitude, longitude, poi.Latitude, poi.Longitude), + Status = PoiAvailabilityRules.StatusOf(poi.Availability, now), + CanProvide = PoiAvailabilityRules.CanProvide(poi.Availability, capabilityId, now) + }) + .OrderBy(x => x.DistanceMeters) + .ToList(); + + if (candidates.Count == 0) + return candidates; + + // The closest that can take the patient are routed first; a closed one is only routed if nothing else is near. + var lookup = await TravelLookupForAsync(departmentId, unitId); + var origin = FormatPoint(latitude, longitude); + var shortlist = candidates.Where(x => x.CanProvide).Take(INearestDestinationService.DefaultShortlistSize).ToList(); + if (shortlist.Count < INearestDestinationService.DefaultShortlistSize) + shortlist.AddRange(candidates.Where(x => !x.CanProvide).Take(INearestDestinationService.DefaultShortlistSize - shortlist.Count)); + + await Task.WhenAll(shortlist.Select(async candidate => + { + try + { + var estimate = await _geoService.GetTravelEstimateAsync(origin, FormatPoint(candidate.Poi.Latitude, candidate.Poi.Longitude), lookup, cancellationToken); + if (estimate != null && estimate.HasValue) + candidate.TravelSeconds = estimate.Seconds; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Travel time to destination {candidate.Poi.PoiId} for department {departmentId} failed; it is ranked by distance."); + } + })); + + return Rank(candidates).Take(take).ToList(); + } + + /// Can provide (not on diversion) first, then diversion, then cannot provide; routed times before straight-line ones. + public static IEnumerable Rank(IEnumerable candidates) + { + return candidates + .OrderBy(x => !x.CanProvide ? 2 : x.Status == PoiAvailabilityStatuses.Diversion ? 1 : 0) + .ThenBy(x => x.TravelSeconds >= 0 ? 0 : 1) + .ThenBy(x => x.TravelSeconds >= 0 ? x.TravelSeconds : x.DistanceMeters) + .ThenBy(x => x.DistanceMeters) + .ThenBy(x => x.Poi.PoiId); + } + + private async Task TravelLookupForAsync(int departmentId, int? unitId) + { + var lookup = new TravelLookup { DepartmentId = departmentId }; + if (!unitId.HasValue || unitId.Value <= 0) + return lookup; + + try + { + var unit = await _unitsService.GetUnitByIdAsync(unitId.Value); + if (unit == null || unit.DepartmentId != departmentId) + return lookup; + + var travel = (await _unitsService.GetTravelProfilesForUnitsAsync(departmentId, new[] { unit }))?.GetValueOrDefault(unit.UnitId) ?? UnitTravelProfile.Default; + lookup.Travel = travel; + + if (travel.Mode == UnitTravelModes.Driving) + lookup.Vehicle = (await _unitsService.GetVehicleProfilesForUnitsAsync(departmentId, new[] { unit }))?.GetValueOrDefault(unit.UnitId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Travel profile of unit {unitId} failed; destinations are routed for plain driving."); + } + + return lookup; + } + + private static bool IsUsableCoordinate(double latitude, double longitude) + { + return !double.IsNaN(latitude) && !double.IsNaN(longitude) && Math.Abs(latitude) <= 90 && Math.Abs(longitude) <= 180 && !(latitude == 0 && longitude == 0); + } + + private static string FormatPoint(double latitude, double longitude) + { + return string.Format(CultureInfo.InvariantCulture, "{0},{1}", latitude, longitude); + } + } +} diff --git a/Core/Resgrid.Services/NearestUnitService.cs b/Core/Resgrid.Services/NearestUnitService.cs index 756a9a49e..12adc6ac7 100644 --- a/Core/Resgrid.Services/NearestUnitService.cs +++ b/Core/Resgrid.Services/NearestUnitService.cs @@ -148,13 +148,13 @@ public NearestUnitService(IDispatchScopeService dispatchScopeService, IDepartmen .ToList(); // A responder whose drive time was looked up comes before one left on a straight-line estimate, as a run card's - // shortlist does: the estimate is the cheaper guess, not a comparable time. + // shortlist does: the estimate is the cheaper guess, not a comparable time. Times compare in the seconds shown. board.Personnel = personnelCandidates .OrderBy(p => !p.IsAvailable) .ThenBy(p => !p.DistanceMeters.HasValue) .ThenBy(p => p.LocationIsStale) .ThenBy(p => byDistance || p.DriveTimeLookedUp ? 0 : 1) - .ThenBy(p => byDistance ? p.DistanceMeters ?? double.MaxValue : p.EtaSeconds ?? double.MaxValue) + .ThenBy(p => byDistance ? p.DistanceMeters ?? double.MaxValue : UnitResponseOrigin.RankingSeconds(p.EtaSeconds)) .ThenBy(p => p.DistanceMeters ?? double.MaxValue) .ThenBy(p => p.Person.Name, StringComparer.OrdinalIgnoreCase) .Select(c => c.ToResult()) @@ -166,15 +166,17 @@ public NearestUnitService(IDispatchScopeService dispatchScopeService, IDepartmen /// /// The board's unit order. Available units with a distance come first, each unit type in the order a closest-unit /// run card ranks that type: fastest response (drive time first for the type's shortlist when routed ETAs are on, - /// then the straight-line estimates), or nearest when the department ranks by distance. The types are then merged - /// by taking whichever type's next unit is fastest (or nearest), so a type's own order is never broken: the unit a - /// run card picks for a type is always that type's first row. A stale fix is flagged on the row rather than ranked - /// down: sorting it below every fresh unit put a unit two minutes out in the middle of the list. Available units with - /// no distance (no position, or one hidden from the viewer) follow, then the units that aren't available. + /// then the straight-line estimates), or nearest when the department ranks by distance. Response is compared in the + /// whole seconds the board shows (), and units level on the + /// second go nearest first. The types are then merged by taking whichever type's next unit is fastest (or nearest), + /// so a type's own order is never broken: the unit a run card picks for a type is always that type's first row. A + /// stale fix is flagged on the row rather than ranked down: sorting it below every fresh unit put a unit two minutes + /// out in the middle of the list. Available units with no distance (no position, or one hidden from the viewer) + /// follow, then the units that aren't available. /// private static List RankUnits(List units, bool byDistance) { - double Key(UnitCandidate c) => byDistance ? c.DistanceMeters ?? double.MaxValue : c.ResponseSeconds ?? double.MaxValue; + double Key(UnitCandidate c) => byDistance ? c.DistanceMeters ?? double.MaxValue : UnitResponseOrigin.RankingSeconds(c.ResponseSeconds); var queues = units .Where(c => c.IsAvailable && c.DistanceMeters.HasValue) @@ -698,7 +700,7 @@ private async Task ApplyRoadEtasAsync(NearestUnitRequest request, DispatchRecomm var groups = units.Where(Routable) .GroupBy(UnitTypeKey, StringComparer.OrdinalIgnoreCase) .Select(g => g - .OrderBy(c => config.RanksByDistance ? c.DistanceMeters.Value : c.ResponseSeconds ?? double.MaxValue) + .OrderBy(c => config.RanksByDistance ? c.DistanceMeters.Value : UnitResponseOrigin.RankingSeconds(c.ResponseSeconds)) .ThenBy(c => c.DistanceMeters.Value) .Take(perGroup) .Cast() diff --git a/Core/Resgrid.Services/OfflineMapPackService.cs b/Core/Resgrid.Services/OfflineMapPackService.cs index 8149d6180..4835b27f3 100644 --- a/Core/Resgrid.Services/OfflineMapPackService.cs +++ b/Core/Resgrid.Services/OfflineMapPackService.cs @@ -781,7 +781,7 @@ private async Task LayersAsync(int departmentId) properties["layerId"] = layer.GetId(); properties["layerName"] = layer.Name; properties["layerColor"] = layer.Color; - properties["onByDefault"] = layer.IsOnByDefault; + properties["onByDefault"] = MapLayerEvents.IsShownByDefault(layer, DateTime.UtcNow); feature["properties"] = properties; features.Add(feature); } diff --git a/Core/Resgrid.Services/PoiImportFileReader.cs b/Core/Resgrid.Services/PoiImportFileReader.cs index 9cb99ab25..85d5bc27b 100644 --- a/Core/Resgrid.Services/PoiImportFileReader.cs +++ b/Core/Resgrid.Services/PoiImportFileReader.cs @@ -20,7 +20,10 @@ public static class PoiImportFileReader { public const int MaximumRows = 10000; - private enum Field { Name, Latitude, Longitude, Street, FullAddress, City, State, PostalCode, Country, Note } + private enum Field { Name, Latitude, Longitude, Street, FullAddress, City, State, PostalCode, Country, Note, Capabilities } + + /// Separators between the names in one Capabilities cell ("Cath Lab; Stroke Center", "Cath Lab | Burns"). + private static readonly char[] CapabilitySeparators = { ';', ',', '|' }; // Compared after lower-casing and removing everything but letters and digits ("Street Address 1" -> "streetaddress1"). // "Region" is deliberately absent: spreadsheets use it for sales or response regions as often as for a state. @@ -40,7 +43,11 @@ private enum Field { Name, Latitude, Longitude, Street, FullAddress, City, State ["zipcode"] = Field.PostalCode, ["country"] = Field.Country, ["note"] = Field.Note, ["notes"] = Field.Note, ["comment"] = Field.Note, ["comments"] = Field.Note, ["remarks"] = Field.Note, - ["description"] = Field.Note + ["description"] = Field.Note, + // What a destination offers (Cath Lab, Stroke Center, Trauma Center): the department's POI capabilities. + ["capabilities"] = Field.Capabilities, ["capability"] = Field.Capabilities, ["specialties"] = Field.Capabilities, + ["specialities"] = Field.Capabilities, ["specialty"] = Field.Capabilities, ["speciality"] = Field.Capabilities, + ["services"] = Field.Capabilities }; // Spreadsheet tools that treat a ZIP code as a number write "60611.0". @@ -71,7 +78,7 @@ public static PoiImportRow FromFields(IEnumerable> string Value(Field field) => values.TryGetValue(field, out var value) ? value : null; - var row = new PoiImportRow { Name = Value(Field.Name), Note = Value(Field.Note), Address = AddressOf(Value) }; + var row = new PoiImportRow { Name = Value(Field.Name), Note = Value(Field.Note), Address = AddressOf(Value), Capabilities = CapabilityNames(Value(Field.Capabilities)) }; var latitude = Coordinate(Value(Field.Latitude), decimalSeparatorHint); var longitude = Coordinate(Value(Field.Longitude), decimalSeparatorHint); @@ -227,6 +234,34 @@ private static string AddressOf(Func value) return string.Join(", ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); } + /// + /// The capability names in one cell, split on ';', ',' or '|', trimmed, cut to and + /// de-duplicated (ignoring case) in the order given; null when the cell names none. + /// + public static List CapabilityNames(string cell) + { + if (string.IsNullOrWhiteSpace(cell)) + return null; + + var names = cell.Split(CapabilitySeparators) + .Select(CapabilityName) + .Where(name => name != null) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return names.Count > 0 ? names : null; + } + + /// A capability name as the department stores it: trimmed and cut to ; null when blank. + public static string CapabilityName(string name) + { + var trimmed = name?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + return null; + + return trimmed.Length > PoiCapability.MaxNameLength ? trimmed.Substring(0, PoiCapability.MaxNameLength).TrimEnd() : trimmed; + } + private static double? Coordinate(string value, char decimalSeparatorHint) { if (string.IsNullOrWhiteSpace(value)) diff --git a/Core/Resgrid.Services/PoiImportService.cs b/Core/Resgrid.Services/PoiImportService.cs index 2c18edfae..304d102d6 100644 --- a/Core/Resgrid.Services/PoiImportService.cs +++ b/Core/Resgrid.Services/PoiImportService.cs @@ -103,7 +103,14 @@ public PoiImportFile ReadFile(Stream input, string fileName) return result; } - result.Rows.AddRange(kml.Coordinates.Select(c => new PoiImportRow { Name = c.Name, Latitude = c.Latitude, Longitude = c.Longitude })); + // A placemark's ExtendedData can name its capabilities ("Capabilities" column of a My Maps or QGIS export). + result.Rows.AddRange(kml.Coordinates.Select(c => new PoiImportRow + { + Name = c.Name, + Latitude = c.Latitude, + Longitude = c.Longitude, + Capabilities = c is KmlPositionedPlacemark placemark ? PoiImportFileReader.FromFields(placemark.Fields).Capabilities : null + })); result.SkippedCount = kml.SkippedCount; foreach (var placemark in kml.AddressOnly) @@ -275,13 +282,29 @@ async Task CheckpointAsync() // it saved. Counting the saved POIs instead would go wrong once one of them is deleted while the import runs. var positioned = rows.Where(r => r.HasPosition).ToList(); var lastSaved = await _poiImportsRepository.GetLastSavedRowAsync(import.PoiImportId, CancellationToken.None); + var firstToSave = (lastSaved ?? -1) + 1; + + // A run that stopped between a POI and its capabilities resumes after that POI, so the row saved last gets its + // capabilities set again (a replace: the same result however often it runs). + var resumedRow = lastSaved.HasValue && lastSaved.Value >= 0 && lastSaved.Value < positioned.Count ? lastSaved : null; + + // Capability names (a "Capabilities" column) are matched to the department's capabilities, adding the missing ones, + // once per run rather than per row. A file without the column (or queued before it was read) makes no query at all. + var capabilityIds = await GetCapabilityIdsAsync(import.DepartmentId, positioned.Skip(resumedRow ?? firstToSave)); + + if (resumedRow.HasValue && HasCapabilities(positioned[resumedRow.Value])) + { + var resumedPoi = type.Pois?.FirstOrDefault(p => p.PoiImportId == import.PoiImportId && p.PoiImportRow == resumedRow.Value); + if (resumedPoi != null) + await SetCapabilitiesAsync(import.DepartmentId, resumedPoi.PoiId, positioned[resumedRow.Value], capabilityIds); + } - for (var i = (lastSaved ?? -1) + 1; i < positioned.Count; i++) + for (var i = firstToSave; i < positioned.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); var row = positioned[i]; - await _mappingService.SavePOIAsync(new Poi + var saved = await _mappingService.SavePOIAsync(new Poi { PoiTypeId = import.PoiTypeId, // Pois.Name is nvarchar(250) and Pois.Address nvarchar(500); a longer value would fail the import. @@ -294,6 +317,9 @@ await _mappingService.SavePOIAsync(new Poi PoiImportRow = i }, CancellationToken.None); + if (saved != null && saved.PoiId > 0 && HasCapabilities(row)) + await SetCapabilitiesAsync(import.DepartmentId, saved.PoiId, row, capabilityIds); + await BeatAsync(); } @@ -342,6 +368,45 @@ await _mappingService.SavePOIAsync(new Poi } } + private static bool HasCapabilities(PoiImportRow row) => row?.Capabilities != null && row.Capabilities.Count > 0; + + /// + /// The department's capability ids by name (ignoring case) for every capability the rows name, adding the names the + /// department does not have yet. Empty, without a query, when no row names any. + /// + private async Task> GetCapabilityIdsAsync(int departmentId, IEnumerable rows) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var names = rows.Where(HasCapabilities).SelectMany(r => r.Capabilities).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (names.Count == 0) + return result; + + // A write (it may add capabilities), so it finishes even when the worker is stopping, like the POI saves. + var capabilities = await _mappingService.GetOrAddPoiCapabilitiesByNameAsync(departmentId, names, CancellationToken.None); + + foreach (var capability in capabilities ?? new List()) + { + var name = PoiImportFileReader.CapabilityName(capability?.Name); + if (name != null && !string.IsNullOrWhiteSpace(capability.PoiCapabilityId) && !result.ContainsKey(name)) + result[name] = capability.PoiCapabilityId; + } + + return result; + } + + /// Makes the row's named capabilities the saved POI's. + private async Task SetCapabilitiesAsync(int departmentId, int poiId, PoiImportRow row, Dictionary capabilityIds) + { + var ids = row.Capabilities + .Select(name => capabilityIds.TryGetValue(PoiImportFileReader.CapabilityName(name) ?? string.Empty, out var id) ? id : null) + .Where(id => id != null) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (ids.Count > 0) + await _mappingService.SetPoiCapabilitiesAsync(departmentId, poiId, ids, CancellationToken.None); + } + /// A row whose lookup no geocoder answered and that has retries left. private static bool IsRetryable(PoiImportRow row) => !row.HasPosition && row.Lookup == (int)PoiImportRowLookup.Unavailable && row.LookupAttempts < MaximumLookupAttempts; diff --git a/Core/Resgrid.Services/Records/HydrantImportParser.cs b/Core/Resgrid.Services/Records/HydrantImportParser.cs index 9875f635b..d99d40b25 100644 --- a/Core/Resgrid.Services/Records/HydrantImportParser.cs +++ b/Core/Resgrid.Services/Records/HydrantImportParser.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using Microsoft.VisualBasic.FileIO; using Newtonsoft.Json; using Resgrid.Framework; @@ -20,6 +21,10 @@ public static class HydrantImportParser public const int MaximumRows = 20000; public const string JsonExample = "[\n {\n \"number\": \"H-101\",\n \"latitude\": 45.5,\n \"longitude\": -122.6,\n \"type\": \"DryBarrel\",\n \"address\": \"1 River Rd\",\n \"main_size\": 8,\n \"flow_gpm\": 1100,\n \"owner\": \"City\"\n }\n]"; public const string CsvExample = "number,latitude,longitude,type,address,main_size,flow_gpm,owner\nH-101,45.5,-122.6,DryBarrel,1 River Rd,8,1100,City\n"; + /// The CSV columns that map to hydrant fields (after ); the default order for a file without a header. + private static readonly string[] CsvColumns = { "number", "latitude", "longitude", "type", "address", "main_size", "flow_gpm", "owner" }; + /// Every column the CSV reads: plus flow_class, which only another system's flow colour column maps to. + private static readonly string[] KnownCsvColumns = CsvColumns.Concat(new[] { "flow_class", "in_service" }).ToArray(); public sealed class Input { @@ -36,7 +41,16 @@ public sealed class Input public sealed class Batch { public HydrantImportResult Result { get; } = new HydrantImportResult(); - public List<(HydrantImportRow Row, RmsHydrant Hydrant)> Rows { get; } = new List<(HydrantImportRow, RmsHydrant)>(); + /// InService: the service state another system's status column gives (null = none, leave the hydrant's state as it is). + public List<(HydrantImportRow Row, RmsHydrant Hydrant, bool? InService)> Rows { get; } = new List<(HydrantImportRow, RmsHydrant, bool?)>(); + /// Values another system's column held that could not be read, keyed by column and value (case-insensitive). + internal Dictionary Unmapped { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + internal void AddUnmapped(string column, string value) + { + if (Unmapped.TryGetValue(column + "\n" + value, out var entry)) entry.Rows++; + else Unmapped[column + "\n" + value] = new HydrantImportUnmappedValue { Column = column, Value = value, Rows = 1 }; + } } public static Batch Parse(string content, string format) @@ -50,6 +64,7 @@ public static Batch Parse(string content, string format) else throw new ArgumentException("Select JSON or CSV as the import format."); if (batch.Result.RowsRead == 0) throw new ArgumentException("The import has no hydrants. Add at least one row using the example."); batch.Result.ValidationFailed = batch.Result.Rejected.Count > 0; + batch.Result.UnmappedValues.AddRange(batch.Unmapped.Values.OrderByDescending(u => u.Rows).ThenBy(u => u.Column).ThenBy(u => u.Value, StringComparer.OrdinalIgnoreCase)); return batch; } @@ -82,8 +97,10 @@ private static void ParseCsv(string content, Batch batch, HashSet number { using var parser = new TextFieldParser(new StringReader(content)) { TextFieldType = FieldType.Delimited, HasFieldsEnclosedInQuotes = true, TrimWhiteSpace = true }; parser.SetDelimiters(","); - var header = new[] { "number", "latitude", "longitude", "type", "address", "main_size", "flow_gpm", "owner" }; + var header = CsvColumns; var first = true; + // Header text of a type/flow column that came in under another system's name (hydrant_type, flow_rate...); null = absent or our own name. + string foreignType = null, foreignFlow = null, flowClassColumn = null, statusColumn = null; try { while (!parser.EndOfData) @@ -93,13 +110,27 @@ private static void ParseCsv(string content, Batch batch, HashSet number if (first) { first = false; - var candidate = cells.Select(CanonicalHeader).ToArray(); + var named = cells.Select(NormalizeHeader).ToArray(); + var candidate = named.Select(CanonicalHeader).ToArray(); if (candidate.Contains("number") || candidate.Contains("latitude") || candidate.Contains("longitude")) { - if (candidate.Distinct().Count() != candidate.Length) throw new ArgumentException("The CSV contains duplicate column names. Keep one column for each field."); + var duplicate = candidate.Where(KnownCsvColumns.Contains).GroupBy(c => c).FirstOrDefault(g => g.Count() > 1); + if (duplicate != null) + throw new ArgumentException($"The CSV has more than one {duplicate.Key} column ({string.Join(", ", cells.Where((_, i) => candidate[i] == duplicate.Key).Select(c => c.Trim()))}). Keep one column for each field."); if (!new[] { "number", "latitude", "longitude" }.All(candidate.Contains)) throw new ArgumentException("The CSV header must include number, latitude and longitude."); - if (candidate.Except(header).Any()) throw new ArgumentException("Unrecognized CSV column: " + string.Join(", ", candidate.Except(header)) + ". Use the column names in the example."); + // Exports from other systems carry extra columns (status, zone, inspection dates...). They are skipped and reported + // rather than failing the file; they stay in the header so every row's cell count still lines up. + batch.Result.IgnoredColumns.AddRange(cells.Where((c, i) => !KnownCsvColumns.Contains(candidate[i]) && !string.IsNullOrWhiteSpace(c)) + .Select(c => c.Trim()).Distinct(StringComparer.OrdinalIgnoreCase)); header = candidate; + // A column under another system's name holds that system's vocabulary, so a value we cannot read is imported as a + // default and reported instead of rejecting the file. Columns under our documented names stay strict. + foreignType = ForeignHeader(cells, named, candidate, "type"); + foreignFlow = ForeignHeader(cells, named, candidate, "flow_gpm"); + var flowClassIndex = Array.IndexOf(candidate, "flow_class"); + flowClassColumn = flowClassIndex >= 0 ? cells[flowClassIndex].Trim() : null; + var statusIndex = Array.IndexOf(candidate, "in_service"); + statusColumn = statusIndex >= 0 ? cells[statusIndex].Trim() : null; continue; } } @@ -119,13 +150,19 @@ private static void ParseCsv(string content, Batch batch, HashSet number row.HydrantNumber = Cell("number"); var flow = Cell("flow_gpm"); int? flowGpm = null; - if (!string.IsNullOrWhiteSpace(flow)) + if (foreignFlow != null) flowGpm = ReadForeignFlow(flow, foreignFlow, batch); + else if (!string.IsNullOrWhiteSpace(flow)) { if (!int.TryParse(flow, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) throw new ArgumentException("flow_gpm: enter a whole number between 0 and 2147483647, without decimals."); flowGpm = value; } + var flowClass = flowClassColumn != null ? ReadForeignFlowClass(Cell("flow_class"), flowClassColumn, batch) : RmsHydrantFlowClass.Unknown; + var inService = statusColumn != null ? ReadForeignStatus(Cell("in_service"), statusColumn, batch) : null; + // The status as the other system wrote it is the out-of-service reason, so the hydrant shows why it was taken out. + var outOfServiceReason = inService == false ? statusColumn + ": " + Cell("in_service").Trim() : null; Add(new Input { Number = row.HydrantNumber, Latitude = Number("latitude", true).Value, Longitude = Number("longitude", true).Value, - Type = ParseType(Cell("type")), Address = Cell("address"), MainSize = Number("main_size"), FlowGpm = flowGpm, Owner = Cell("owner") }, row, batch, numbers); + Type = foreignType != null ? ReadForeignType(Cell("type"), foreignType, batch) : ParseType(Cell("type")), Address = Cell("address"), MainSize = Number("main_size"), FlowGpm = flowGpm, Owner = Cell("owner") }, + row, batch, numbers, flowClass, inService, outOfServiceReason); } catch (ArgumentException ex) { Reject(row, ex.Message, batch); } } @@ -133,22 +170,177 @@ private static void ParseCsv(string content, Batch batch, HashSet number catch (MalformedLineException) { throw new ArgumentException($"CSV line {parser.ErrorLineNumber} has unmatched quotes. Enclose each quoted value in double quotes. No hydrants were saved."); } } - private static string CanonicalHeader(string value) + private static string NormalizeHeader(string value) => value.Trim().ToLowerInvariant().Replace(" ", "_"); + + private static string CanonicalHeader(string name) { - var name = value.Trim().ToLowerInvariant().Replace(" ", "_"); - return name switch { "hydrant_number" or "id" => "number", "lat" => "latitude", "lon" or "lng" => "longitude", "main_size_inches" or "main" => "main_size", "flow" => "flow_gpm", "owner_name" => "owner", _ => name }; + return name switch { "hydrant_number" or "id" => "number", "lat" => "latitude", "lon" or "lng" => "longitude", "location_address" => "address", "hydrant_type" => "type", + "main_size_inches" or "main" => "main_size", "flow" or "flow_rate" => "flow_gpm", "owner_name" => "owner", + "flow_class" or "flow_rating_color" or "flow_rating_colour" or "flow_color" or "flow_colour" or "cap_color" or "cap_colour" or "bonnet_color" or "bonnet_colour" => "flow_class", + "in_service" or "status" or "hydrant_status" or "service_status" or "operational_status" => "in_service", + _ => name }; + } + + /// The header as written when came in under an alias rather than its documented name; otherwise null. + private static string ForeignHeader(string[] cells, string[] named, string[] candidate, string field) + { + var i = Array.IndexOf(candidate, field); + return i >= 0 && named[i] != field ? cells[i].Trim() : null; + } + + /// Words other systems use for "no value"; a foreign column holding one is read as blank. + private static readonly HashSet NoValue = new HashSet(StringComparer.OrdinalIgnoreCase) { "-", "--", "n/a", "na", "none", "null", "unknown" }; + + /// Type names other systems use (letters only, lower case). "Dry hydrant" is the fire-service term for an unpressurized drafting pipe. + private static readonly Dictionary TypeSynonyms = new Dictionary(StringComparer.Ordinal) + { + ["dry"] = RmsHydrantType.DryBarrel, ["wet"] = RmsHydrantType.WetBarrel, + ["tank"] = RmsHydrantType.Cistern, ["watertank"] = RmsHydrantType.Cistern, ["storagetank"] = RmsHydrantType.Cistern, ["undergroundtank"] = RmsHydrantType.Cistern, + ["dryhydrant"] = RmsHydrantType.DraftingSite, ["draft"] = RmsHydrantType.DraftingSite, ["drafting"] = RmsHydrantType.DraftingSite, ["drafthydrant"] = RmsHydrantType.DraftingSite, + ["draftsite"] = RmsHydrantType.DraftingSite, ["draftpoint"] = RmsHydrantType.DraftingSite, ["draftingpoint"] = RmsHydrantType.DraftingSite, + ["staticsource"] = RmsHydrantType.DraftingSite, ["staticwater"] = RmsHydrantType.DraftingSite, ["staticwatersource"] = RmsHydrantType.DraftingSite, + ["pond"] = RmsHydrantType.DraftingSite, ["lake"] = RmsHydrantType.DraftingSite, ["river"] = RmsHydrantType.DraftingSite + }; + + private static bool TryParseType(string text, out RmsHydrantType type) + { + var value = new string(text.Trim().ToLowerInvariant().Where(c => c != ' ' && c != '-' && c != '_' && c != '.').ToArray()); + // "Dry Barrel Hydrant", "Wet Hydrant": a trailing "hydrant" adds nothing once the explicit names ("dry hydrant") have been tried. + foreach (var name in new[] { value, value.EndsWith("hydrant", StringComparison.Ordinal) ? value.Substring(0, value.Length - 7) : null }) + { + if (string.IsNullOrEmpty(name)) continue; + if (TypeSynonyms.TryGetValue(name, out type)) return true; + // Letters/digits only, so "DryBarrel,WetBarrel" is not read as the flags combination 1|2 = Standpipe. + if (name.All(char.IsLetterOrDigit) && Enum.TryParse(name, true, out type) && Enum.IsDefined(type)) return true; + } + type = default; + return false; } private static RmsHydrantType ParseType(string text) { if (string.IsNullOrWhiteSpace(text)) return RmsHydrantType.DryBarrel; - var value = text.Trim().Replace(" ", "").Replace("-", "").Replace("_", ""); - value = value.ToLowerInvariant() switch { "dry" => "DryBarrel", "wet" => "WetBarrel", "tank" => "Cistern", "draft" or "pond" or "lake" or "river" => "DraftingSite", _ => value }; - if (Enum.TryParse(value, true, out var type) && Enum.IsDefined(type)) return type; + if (TryParseType(text, out var type)) return type; throw new ArgumentException("type: use DryBarrel, WetBarrel, Standpipe, Cistern, DraftingSite or Other (or 1–6)."); } - private static void Add(Input input, HydrantImportRow row, Batch batch, HashSet numbers) + /// A type from another system's column: blank is the default type, an unknown name is imported as Other and reported. + private static RmsHydrantType ReadForeignType(string text, string column, Batch batch) + { + if (string.IsNullOrWhiteSpace(text) || NoValue.Contains(text.Trim())) return RmsHydrantType.DryBarrel; + if (TryParseType(text, out var type)) return type; + batch.AddUnmapped(column, text.Trim()); + return RmsHydrantType.Other; + } + + private const decimal LitresPerUsGallon = 3.785411784m; + + /// Unit suffixes a flow value may carry (spaces removed, lower case) and how many of the unit make one US gallon. Longest first. + private static readonly (string Suffix, decimal PerGallon)[] FlowUnits = + { + ("usgal/min", 1m), ("gal/min", 1m), ("usgpm", 1m), ("gpm", 1m), ("l/min", LitresPerUsGallon), ("lpm", LitresPerUsGallon), ("l/m", LitresPerUsGallon) + }; + + private static readonly Regex ThousandsSeparated = new Regex(@"^\d{1,3}(,\d{3})+(\.\d+)?$", RegexOptions.CultureInvariant); + + /// + /// A flow from another system's column: gallons per minute unless the value names litres per minute; decimals are rounded and + /// "1,250" thousands separators accepted. A value that is not a nonnegative flow is imported as blank and reported. + /// + private static int? ReadForeignFlow(string text, string column, Batch batch) + { + if (string.IsNullOrWhiteSpace(text) || NoValue.Contains(text.Trim())) return null; + var value = text.Trim().ToLowerInvariant().Replace(" ", ""); + var perGallon = 1m; + foreach (var (suffix, units) in FlowUnits) + if (value.EndsWith(suffix, StringComparison.Ordinal)) { value = value.Substring(0, value.Length - suffix.Length); perGallon = units; break; } + if (ThousandsSeparated.IsMatch(value)) value = value.Replace(",", ""); + if (decimal.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var number)) + { + var gpm = decimal.Round(number / perGallon, 0, MidpointRounding.AwayFromZero); + if (gpm <= int.MaxValue) return (int)gpm; + } + batch.AddUnmapped(column, text.Trim()); + return null; + } + + /// NFPA 291 bonnet/cap colour names and class letters (letters only, lower case). + private static readonly Dictionary FlowClassNames = new Dictionary(StringComparer.Ordinal) + { + ["lightblue"] = RmsHydrantFlowClass.AA, ["ltblue"] = RmsHydrantFlowClass.AA, ["skyblue"] = RmsHydrantFlowClass.AA, ["blue"] = RmsHydrantFlowClass.AA, + ["aa"] = RmsHydrantFlowClass.AA, ["classaa"] = RmsHydrantFlowClass.AA, + ["green"] = RmsHydrantFlowClass.A, ["a"] = RmsHydrantFlowClass.A, ["classa"] = RmsHydrantFlowClass.A, + ["orange"] = RmsHydrantFlowClass.B, ["b"] = RmsHydrantFlowClass.B, ["classb"] = RmsHydrantFlowClass.B, + ["red"] = RmsHydrantFlowClass.C, ["c"] = RmsHydrantFlowClass.C, ["classc"] = RmsHydrantFlowClass.C + }; + + private static readonly Regex HexColour = new Regex(@"^#([0-9a-f]{3}|[0-9a-f]{6})$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + /// + /// A flow class from another system's colour column: an NFPA 291 colour name, class letter or #hex colour. Blank is no class; anything + /// else (black, yellow...) is no class and reported. It only applies on import while the hydrant has no flow to classify. + /// + private static RmsHydrantFlowClass ReadForeignFlowClass(string text, string column, Batch batch) + { + if (string.IsNullOrWhiteSpace(text) || NoValue.Contains(text.Trim())) return RmsHydrantFlowClass.Unknown; + var name = new string(text.Trim().ToLowerInvariant().Where(c => c != ' ' && c != '-' && c != '_' && c != '.').ToArray()); + if (FlowClassNames.TryGetValue(name, out var flowClass)) return flowClass; + if (TryClassifyColour(text.Trim(), out flowClass)) return flowClass; + batch.AddUnmapped(column, text.Trim()); + return RmsHydrantFlowClass.Unknown; + } + + /// Classifies a #hex colour by hue: red, orange, green and blue are the NFPA 291 cap colours. Greys, yellows and purples are not. + private static bool TryClassifyColour(string text, out RmsHydrantFlowClass flowClass) + { + flowClass = RmsHydrantFlowClass.Unknown; + if (!HexColour.IsMatch(text)) return false; + var hex = text.Substring(1); + if (hex.Length == 3) hex = string.Concat(hex.Select(c => new string(c, 2))); + var rgb = Enumerable.Range(0, 3).Select(i => int.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) / 255.0).ToArray(); + double max = rgb.Max(), min = rgb.Min(), delta = max - min; + if (delta < 0.15 || max < 0.25) return false; + var hue = max == rgb[0] ? 60 * (((rgb[1] - rgb[2]) / delta + 6) % 6) + : max == rgb[1] ? 60 * ((rgb[2] - rgb[0]) / delta + 2) + : 60 * ((rgb[0] - rgb[1]) / delta + 4); + // Green stops at 175 so the map's own class A green (#1ab394, ~168) and class AA light blue (#23c6c8, ~181) stay apart. + flowClass = hue < 15 || hue >= 330 ? RmsHydrantFlowClass.C + : hue < 50 ? RmsHydrantFlowClass.B + : hue >= 75 && hue < 175 ? RmsHydrantFlowClass.A + : hue >= 175 && hue < 260 ? RmsHydrantFlowClass.AA + : RmsHydrantFlowClass.Unknown; + return flowClass != RmsHydrantFlowClass.Unknown; + } + + /// Service-state words other systems use (letters and digits only, lower case): true = in service, false = out of service. + private static readonly Dictionary StatusWords = new Dictionary(StringComparer.Ordinal) + { + ["inservice"] = true, ["active"] = true, ["operational"] = true, ["available"] = true, ["working"] = true, ["functional"] = true, + ["ok"] = true, ["okay"] = true, ["good"] = true, ["normal"] = true, ["online"] = true, ["enabled"] = true, + ["true"] = true, ["yes"] = true, ["y"] = true, ["t"] = true, ["1"] = true, + ["outofservice"] = false, ["oos"] = false, ["notinservice"] = false, ["outoforder"] = false, ["inactive"] = false, ["unavailable"] = false, + ["broken"] = false, ["damaged"] = false, ["defective"] = false, ["inoperable"] = false, ["inoperative"] = false, ["down"] = false, + ["repair"] = false, ["needsrepair"] = false, ["repairneeded"] = false, ["underrepair"] = false, ["offline"] = false, ["disabled"] = false, + ["removed"] = false, ["abandoned"] = false, ["retired"] = false, ["decommissioned"] = false, + ["false"] = false, ["no"] = false, ["n"] = false, ["f"] = false, ["0"] = false + }; + + /// + /// A service state from another system's status column: blank gives none (the hydrant keeps its state; a new one is in service). + /// "Temporarily out of service" and the like count as out of service. Anything else (passed, due...) gives none and is reported. + /// + private static bool? ReadForeignStatus(string text, string column, Batch batch) + { + if (string.IsNullOrWhiteSpace(text) || NoValue.Contains(text.Trim())) return null; + var word = new string(text.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); + if (StatusWords.TryGetValue(word, out var inService)) return inService; + if (word.Contains("outofservice") || word.Contains("outoforder")) return false; + batch.AddUnmapped(column, text.Trim()); + return null; + } + + private static void Add(Input input, HydrantImportRow row, Batch batch, HashSet numbers, RmsHydrantFlowClass flowClass = RmsHydrantFlowClass.Unknown, + bool? inService = null, string outOfServiceReason = null) { row.HydrantNumber = input.Number?.Trim(); var errors = new List(); @@ -162,8 +354,9 @@ private static void Add(Input input, HydrantImportRow row, Batch batch, HashSet< if (input.Owner?.Length > 200) errors.Add("owner: shorten to 200 characters or fewer."); if (errors.Count > 0) throw new ArgumentException(string.Join(" ", errors)); batch.Rows.Add((row, new RmsHydrant { HydrantNumber = row.HydrantNumber, Latitude = input.Latitude, Longitude = input.Longitude, - Type = (int)(input.Type ?? RmsHydrantType.DryBarrel), AddressText = input.Address, MainSizeInches = input.MainSize, FlowGpm = input.FlowGpm, - OwnerName = input.Owner, OwnerKind = (int)(string.IsNullOrWhiteSpace(input.Owner) ? RmsHydrantOwnerKind.Municipal : RmsHydrantOwnerKind.Other) })); + Type = (int)(input.Type ?? RmsHydrantType.DryBarrel), AddressText = input.Address, MainSizeInches = input.MainSize, FlowGpm = input.FlowGpm, FlowClass = (int)flowClass, + OwnerName = input.Owner, OwnerKind = (int)(string.IsNullOrWhiteSpace(input.Owner) ? RmsHydrantOwnerKind.Municipal : RmsHydrantOwnerKind.Other), + InService = inService ?? true, OutOfServiceReason = outOfServiceReason }, inService)); } private static void Reject(HydrantImportRow row, string error, Batch batch) { row.Error = error; batch.Result.Rejected.Add(row); } diff --git a/Core/Resgrid.Services/Records/RecordsHydrantsService.cs b/Core/Resgrid.Services/Records/RecordsHydrantsService.cs index a67bd1aac..2e166fa57 100644 --- a/Core/Resgrid.Services/Records/RecordsHydrantsService.cs +++ b/Core/Resgrid.Services/Records/RecordsHydrantsService.cs @@ -64,9 +64,20 @@ public async Task SaveAsync(int departmentId, string userId, RmsHydr return entity; } + /// Service-state changes an import made; merged into the result only once the writes that made them have succeeded. + private sealed class ServiceTally + { + public int TakenOut; + public int Returned; + public void AddTo(HydrantImportResult result) { result.TakenOutOfService += TakenOut; result.ReturnedToService += Returned; } + } + /// Import batches pass the batch lookup (, keyed by the /// normalized number); null falls back to the single-row lookup. - private async Task UpsertAsync(int departmentId, string userId, RmsHydrant input, string source, CancellationToken cancellationToken, IReadOnlyDictionary byNumber = null) + /// Import only: the state another system's status column gives (null = leave as it is), with + /// .OutOfServiceReason as the reason when false. + private async Task UpsertAsync(int departmentId, string userId, RmsHydrant input, string source, CancellationToken cancellationToken, IReadOnlyDictionary byNumber = null, + bool? serviceState = null, ServiceTally tally = null) { var now = DateTime.UtcNow; var number = RecordsPreventionGate.Require(input.HydrantNumber, 64, "A hydrant needs a number."); @@ -85,8 +96,25 @@ private async Task UpsertAsync(int departmentId, string userId, RmsH entity.AddressText = RecordsPreventionGate.Trim(input.AddressText, 500); entity.OwnerKind = input.OwnerKind == 0 ? (int)RmsHydrantOwnerKind.Municipal : input.OwnerKind; entity.OwnerName = RecordsPreventionGate.Trim(input.OwnerName, 200); entity.MainSizeInches = input.MainSizeInches; if (source == "manual") { entity.Notes = RecordsPreventionGate.Trim(input.Notes, 4000); entity.PoiId = input.PoiId; } if (input.FlowGpm.HasValue) { entity.FlowGpm = input.FlowGpm; entity.FlowClass = (int)HydrantFlowCalculator.Classify(input.FlowGpm); } + // An import's flow class (another system's cap colour) applies only while there is no flow to classify: a known flow decides the class. + if (source != "manual" && Enum.IsDefined(typeof(RmsHydrantFlowClass), input.FlowClass) && input.FlowClass != (int)RmsHydrantFlowClass.Unknown + && HydrantFlowCalculator.Classify(entity.FlowGpm) == RmsHydrantFlowClass.Unknown) + entity.FlowClass = input.FlowClass; if (input.StaticPressurePsi.HasValue) entity.StaticPressurePsi = input.StaticPressurePsi; if (input.ResidualPressurePsi.HasValue) entity.ResidualPressurePsi = input.ResidualPressurePsi; + // A change of state is applied as SetServiceStateAsync would; a hydrant already out of service keeps its own reason and date. + // Deliberately both ways (product decision 2026-11-13): the file is the source of truth for the state it carries, so an older + // export can return a hydrant to service. The result's TakenOutOfService/ReturnedToService counts and the audit make that visible. + if (serviceState == true && !entity.InService) + { + entity.InService = true; entity.OutOfServiceReason = null; entity.OutOfServiceSince = null; + if (tally != null) tally.Returned++; + } + else if (serviceState == false && entity.InService) + { + entity.InService = false; entity.OutOfServiceReason = RecordsPreventionGate.Trim(input.OutOfServiceReason, 500); entity.OutOfServiceSince = now; + if (tally != null) tally.TakenOut++; + } entity.ModifiedOn = now; entity.RowVersion++; if (existing == null) await _hydrants.InsertAsync(entity, cancellationToken, true); else await _hydrants.UpdateAsync(entity, cancellationToken, true); return entity; @@ -222,10 +250,12 @@ public async Task ImportAsync(int departmentId, string user // counts and the failure message are exactly those of a row-by-row import. foreach (var chunk in batch.Rows.Chunk(ImportBatchSize)) { - if (await TryImportBatchAsync(departmentId, userId, chunk, source, cancellationToken)) + var batchTally = new ServiceTally(); + if (await TryImportBatchAsync(departmentId, userId, chunk, source, batchTally, cancellationToken)) { foreach (var item in chunk) if (existingNumbers.Add(item.Hydrant.HydrantNumber)) result.Created++; else result.Updated++; + batchTally.AddTo(result); continue; } var stopped = false; @@ -234,8 +264,10 @@ public async Task ImportAsync(int departmentId, string user cancellationToken.ThrowIfCancellationRequested(); try { - await UpsertAsync(departmentId, userId, item.Hydrant, source, cancellationToken); + var rowTally = new ServiceTally(); + await UpsertAsync(departmentId, userId, item.Hydrant, source, cancellationToken, null, item.InService, rowTally); if (existingNumbers.Add(item.Hydrant.HydrantNumber)) result.Created++; else result.Updated++; + rowTally.AddTo(result); } catch (OperationCanceledException) { throw; } catch (Exception ex) @@ -250,7 +282,7 @@ public async Task ImportAsync(int departmentId, string user } try { - await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Hydrants imported", departmentId.ToString(), new { result.RowsRead, result.Created, result.Updated, result.FailureMessage }, cancellationToken: cancellationToken); + await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Admin, "Hydrants imported", departmentId.ToString(), new { result.RowsRead, result.Created, result.Updated, result.TakenOutOfService, result.ReturnedToService, result.FailureMessage }, cancellationToken: cancellationToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) @@ -262,7 +294,7 @@ public async Task ImportAsync(int departmentId, string user } /// Writes one import batch in a single transaction. False (rolled back) when any row fails; cancellation rolls back and rethrows. - private async Task TryImportBatchAsync(int departmentId, string userId, (HydrantImportRow Row, RmsHydrant Hydrant)[] chunk, string source, CancellationToken cancellationToken) + private async Task TryImportBatchAsync(int departmentId, string userId, (HydrantImportRow Row, RmsHydrant Hydrant, bool? InService)[] chunk, string source, ServiceTally tally, CancellationToken cancellationToken) { // Inside an ambient transaction a failed batch could not be rolled back on its own to replay; use the row-by-row path. if (_unitOfWork == null || _unitOfWork.Transaction != null) return false; @@ -276,7 +308,7 @@ private async Task TryImportBatchAsync(int departmentId, string userId, (H foreach (var item in chunk) { cancellationToken.ThrowIfCancellationRequested(); - await UpsertAsync(departmentId, userId, item.Hydrant, source, cancellationToken, byNumber); + await UpsertAsync(departmentId, userId, item.Hydrant, source, cancellationToken, byNumber, item.InService, tally); } _unitOfWork.CommitChanges(); return true; diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 776d17a42..97453c576 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -416,6 +416,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // Singleton: it keeps each department's reduced visibility matrix in memory between location pings. diff --git a/Core/Resgrid.Services/TransportDestinationCheckService.cs b/Core/Resgrid.Services/TransportDestinationCheckService.cs new file mode 100644 index 000000000..9282ae68d --- /dev/null +++ b/Core/Resgrid.Services/TransportDestinationCheckService.cs @@ -0,0 +1,122 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Records transport destination deviations as call notes (). Called through a + /// from UnitsService: it needs the calls service, which sits above the units service. + /// + public class TransportDestinationCheckService : ITransportDestinationCheckService + { + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly ICallStatusAttributionService _callStatusAttributionService; + private readonly ICallsService _callsService; + private readonly IMappingService _mappingService; + private readonly IUnitsService _unitsService; + private readonly ICustomStateService _customStateService; + + public TransportDestinationCheckService(IDepartmentSettingsService departmentSettingsService, ICallStatusAttributionService callStatusAttributionService, + ICallsService callsService, IMappingService mappingService, IUnitsService unitsService, ICustomStateService customStateService) + { + _departmentSettingsService = departmentSettingsService; + _callStatusAttributionService = callStatusAttributionService; + _callsService = callsService; + _mappingService = mappingService; + _unitsService = unitsService; + _customStateService = customStateService; + } + + public async Task OnUnitStateSavedAsync(int departmentId, UnitState state, UnitState previousState, CancellationToken cancellationToken = default(CancellationToken)) + { + // Cheap checks first: almost every status has no POI destination. + if (state == null || state.UnitStateId <= 0 || state.DestinationType != (int)DestinationEntityTypes.Poi || !(state.DestinationId > 0)) + return null; + + if (TransportDestinationRules.RepeatsPreviousDestination(state, previousState)) + return null; + + try + { + if (!await _departmentSettingsService.GetTransportDestinationChecksAsync(departmentId)) + return null; + + var customStates = await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId); + var statuses = CallStatusLinkage.BuildStatusLookup(_customStateService.GetDefaultUnitStatuses(), customStates, CustomStateTypes.Unit); + statuses.TryGetValue(state.State, out var status); + + if (status == null || !TransportDestinationRules.IsTransportBaseType(status.BaseType)) + return null; + + // The call the unit was working before it headed off: its previous status's call, else its one open dispatch. + var callId = await _callStatusAttributionService.GetWorkingCallIdForUnitAsync(departmentId, state.UnitId, previousState ?? state); + if (!callId.HasValue) + return null; + + var call = await _callsService.GetCallByIdAsync(callId.Value); + if (call == null || call.DepartmentId != departmentId || call.IsDeleted || call.State != (int)CallStates.Active) + return null; + + var assignedId = TransportDestinationRules.AssignedDestinationPoiId(call); + if (!TransportDestinationRules.IsDeviation(state.DestinationId, state.DestinationType, assignedId)) + return null; + + var unit = await _unitsService.GetUnitByIdAsync(state.UnitId); + var chosen = await _mappingService.GetDestinationPOIByIdAsync(departmentId, state.DestinationId.Value); + var assigned = await _mappingService.GetDestinationPOIByIdAsync(departmentId, assignedId.Value); + + var note = new CallNote + { + CallId = call.CallId, + UserId = !string.IsNullOrWhiteSpace(state.SetByUserId) ? state.SetByUserId : call.ReportingUserId, + Note = BuildNoteText(unit?.Name, status.ButtonText, PoiName(chosen, state.DestinationId.Value), PoiName(assigned, assignedId.Value), ReasonFor(state)), + Source = (int)CallNoteSources.System, + Timestamp = DateTime.UtcNow + }; + + return await _callsService.SaveCallNoteAsync(note, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Recording the transport destination change of unit {state.UnitId} (status {state.UnitStateId}) failed; the status is saved."); + return null; + } + } + + /// + /// The call note text. English, like the other notes Resgrid writes on a call (outbound text is not localized yet). + /// + public static string BuildNoteText(string unitName, string statusName, string chosenName, string assignedName, string reason) + { + var unit = string.IsNullOrWhiteSpace(unitName) ? "A unit" : unitName.Trim(); + var statusPart = string.IsNullOrWhiteSpace(statusName) ? "a transport status" : statusName.Trim(); + var reasonPart = string.IsNullOrWhiteSpace(reason) ? "No reason given." : $"Reason: {reason.Trim()}"; + + return $"{unit} set {statusPart} to {chosenName}, not the call's assigned destination {assignedName}. {reasonPart}"; + } + + private static string PoiName(Poi poi, int poiId) + { + if (poi == null) + return $"POI {poiId}"; + + if (!string.IsNullOrWhiteSpace(poi.Name)) + return poi.Name.Trim(); + + return !string.IsNullOrWhiteSpace(poi.Address) ? poi.Address.Trim() : $"POI {poiId}"; + } + + // A protected status keeps its note sealed; the call note then points at it rather than copying it out. + private static string ReasonFor(UnitState state) + { + if (state.IsProtected) + return "recorded on the unit's status (protected)."; + + return state.Note; + } + } +} diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index c2f7b1464..2026caef9 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -55,6 +55,9 @@ public class UnitsService : IUnitsService private readonly Lazy _outstandingRequirementsService; private readonly IUnitNavigationStateService _unitNavigationStateService; + // Lazy: recording a transport destination change on the call needs the calls service, which depends on this one. + private readonly Lazy _transportDestinationCheckService; + // A unit type rename and the move of its units commit as one transaction. private readonly IUnitOfWork _unitOfWork; @@ -70,8 +73,10 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit Lazy callAutoCloseService = null, INovuProvider novuProvider = null, Lazy departmentsService = null, ITypeIconsRepository typeIconsRepository = null, Lazy outstandingRequirementsService = null, - IUnitNavigationStateService unitNavigationStateService = null, IUnitOfWork unitOfWork = null) + IUnitNavigationStateService unitNavigationStateService = null, IUnitOfWork unitOfWork = null, + Lazy transportDestinationCheckService = null) { + _transportDestinationCheckService = transportDestinationCheckService; _unitOfWork = unitOfWork; _unitNavigationStateService = unitNavigationStateService; _outstandingRequirementsService = outstandingRequirementsService; @@ -692,9 +697,32 @@ private async Task MoveUnitsToRenamedTypeAsync(int departmentId, int unitTy // Released from the call (an automatic status) ends a call navigation too. await EndNavigationForStatusAsync(departmentId, saved); + // Statuses Resgrid sets itself never carry a destination the crew chose. + if (!autoGenerated) + await RecordTransportDestinationChangeAsync(departmentId, saved, previousState, cancellationToken); + return saved; } + /// + /// A transport status to a destination other than the call's assigned one is recorded on the call when the department + /// turned the checks on (). It never throws. + /// + private async Task RecordTransportDestinationChangeAsync(int departmentId, UnitState saved, UnitState previousState, CancellationToken cancellationToken) + { + if (_transportDestinationCheckService == null || saved?.DestinationType != (int)DestinationEntityTypes.Poi) + return; + + try + { + await _transportDestinationCheckService.Value.OnUnitStateSavedAsync(departmentId, saved, previousState, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Recording the transport destination change of unit {saved.UnitId} failed; the status is saved."); + } + } + /// /// A status whose base type ends the response (On Scene, Available, In Quarters, Out of Service, Released, /// Returning) clears the unit's live call ETA (maplibre-mapping-routing-navigation-plan.md §9.4). Never throws. @@ -986,6 +1014,8 @@ public async Task> GetAllRolesForDepartmentAsync(int departmentId await _outstandingRequirementsService.Value.OnUnitStateSavedAsync(departmentId, state, previousState, cancellationToken); await EndNavigationForStatusAsync(departmentId, state); + + await RecordTransportDestinationChangeAsync(departmentId, state, previousState, cancellationToken); } public async Task> GetRolesForUnitStateAsync(int unitStateId) diff --git a/Docker/qa-departments/catalog/baseline.json b/Docker/qa-departments/catalog/baseline.json index 932c4544f..818769f8d 100644 --- a/Docker/qa-departments/catalog/baseline.json +++ b/Docker/qa-departments/catalog/baseline.json @@ -151,6 +151,7 @@ { "key": "utility", "company": "City Water Utility (QA)", "email": "ops@water.qa.example.com", "office": "2175550160", "description": "24-hour water emergency line" }, { "key": "resident", "first": "Pat", "last": "Neighbor", "cell": "2175550188", "email": "pat.neighbor@qa.example.com", "description": "Frequent caller, hard of hearing" } ], + "poiCapabilities": ["Trauma Center", "Cath Lab", "Stroke Center"], "poiTypes": [ { "name": "Hydrant", "color": "#ef4444", "pois": [ { "name": "H-101", "location": [39.8035, -89.6505], "note": "1500 GPM" }, @@ -158,7 +159,9 @@ { "name": "H-201", "location": [39.7705, -89.6630], "note": "Out of service" } ] }, { "name": "Hospital", "color": "#2563eb", "destination": true, "pois": [ - { "name": "Springfield Memorial (QA)", "location": [39.8095, -89.6525], "address": "800 N Rutledge St, Springfield, IL 62702" } + { "name": "Springfield Memorial (QA)", "location": [39.8095, -89.6525], "address": "800 N Rutledge St, Springfield, IL 62702", "capabilities": ["Trauma Center", "Cath Lab"] }, + { "name": "St. John's (QA)", "location": [39.8115, -89.6435], "address": "800 E Carpenter St, Springfield, IL 62769", "capabilities": ["Cath Lab", "Stroke Center"], + "availability": { "status": 2, "note": "ED capacity overload (QA)", "unavailable": ["Cath Lab"] } } ] }, { "name": "Staging Area", "color": "#16a34a", "pois": [{ "name": "Fairgrounds Lot B", "location": [39.8350, -89.6430] }] } ], diff --git a/Docker/qa-departments/seeder/build.py b/Docker/qa-departments/seeder/build.py index 095604b3e..69cb20a8d 100644 --- a/Docker/qa-departments/seeder/build.py +++ b/Docker/qa-departments/seeder/build.py @@ -654,12 +654,34 @@ def _shifts(self) -> None: label=f'shift signup {name}/{signup["person"]}') def _pois_protocols_templates(self) -> None: + # Destination capabilities (M0315): the department's list, then each POI's tags and its availability report. + capabilities = self.s.get('poiCapabilities', []) + for index, capability in enumerate(capabilities): + self.add('PoiCapabilities', {'PoiCapabilityId': self.guid('poicapability', capability), 'DepartmentId': self.department, 'Name': capability, 'Sort': index + 1}, + label=f'POI capability {capability}') for poi_type in self.s.get('poiTypes', []): self.add('PoiTypes', {'DepartmentId': self.department, 'Name': poi_type['name'], 'Color': poi_type.get('color'), 'IsDestination': bool(poi_type.get('destination'))}, key=('poitype', poi_type['name']), returns='PoiTypeId', label=f'POI type {poi_type["name"]}') for poi in poi_type.get('pois', []): - self.add('Pois', {'PoiTypeId': self.key('poitype', poi_type['name']), 'Latitude': poi['location'][0], 'Longitude': poi['location'][1], 'Name': poi.get('name'), - 'Address': poi.get('address'), 'Note': poi.get('note')}, label=f'POI {poi.get("name")}') + name = poi.get('name') + self.add('Pois', {'PoiTypeId': self.key('poitype', poi_type['name']), 'Latitude': poi['location'][0], 'Longitude': poi['location'][1], 'Name': name, + 'Address': poi.get('address'), 'Note': poi.get('note')}, key=('poi', name), returns='PoiId', label=f'POI {name}') + for capability in poi.get('capabilities', []): + if capability not in capabilities: + raise SeedError(f'{self.sid}: POI "{name}" offers unknown capability "{capability}" (add it to poiCapabilities)') + self.add('PoiCapabilityAssignments', {'PoiCapabilityAssignmentId': self.guid('poicapabilityassignment', name, capability), 'DepartmentId': self.department, + 'PoiId': self.key('poi', name), 'PoiCapabilityId': self.guid('poicapability', capability)}, + label=f'POI {name} offers {capability}') + availability = poi.get('availability') + if availability: + down = availability.get('unavailable', []) + unknown = [capability for capability in down if capability not in poi.get('capabilities', [])] + if unknown: + raise SeedError(f'{self.sid}: POI "{name}" reports capabilities it does not offer unavailable: {unknown}') + self.add('PoiAvailabilities', {'PoiAvailabilityId': self.guid('poiavailability', name), 'DepartmentId': self.department, 'PoiId': self.key('poi', name), + 'Status': availability['status'], 'UnavailableCapabilityIds': ','.join(self.guid('poicapability', c) for c in down) or None, + 'Note': availability.get('note'), 'SetByUserId': self._admin_id(), 'SetOn': self.at('-30m'), 'ExpiresOn': None}, + label=f'POI {name} availability') trigger_types = {'priority': 0, 'callType': 1, 'priorityAndType': 2} for protocol in self.s.get('protocols', []): code = protocol['code'] diff --git a/Docker/qa-departments/seeder/catalog.py b/Docker/qa-departments/seeder/catalog.py index 2a7d9fbd3..34c573bd6 100644 --- a/Docker/qa-departments/seeder/catalog.py +++ b/Docker/qa-departments/seeder/catalog.py @@ -12,7 +12,7 @@ CATALOG = Path(__file__).resolve().parent.parent / 'catalog' # Sections a scenario may replace or extend. SECTIONS = ('department', 'settings', 'statuses', 'groups', 'roles', 'ranks', 'people', 'unitTypes', 'units', 'callPriorities', 'callTypes', 'calls', 'messages', 'notes', - 'calendar', 'shifts', 'contacts', 'poiTypes', 'protocols', 'callTemplates', 'callNoteTemplates', 'checkInTimers', 'trainings', 'documents', 'permissions', + 'calendar', 'shifts', 'contacts', 'poiCapabilities', 'poiTypes', 'protocols', 'callTemplates', 'callNoteTemplates', 'checkInTimers', 'trainings', 'documents', 'permissions', 'security', 'incidentCommand', 'flags') SCENARIO_FIELDS = {'id', 'name', 'purpose', 'covers', 'baseline', 'extra', 'generate', 'personas', '$comment'} | set(SECTIONS) _ID = re.compile(r'^[a-z][a-z0-9-]{1,30}$') diff --git a/Docker/qa-departments/seeder/cli.py b/Docker/qa-departments/seeder/cli.py index 8cb874a6e..a8e10c170 100644 --- a/Docker/qa-departments/seeder/cli.py +++ b/Docker/qa-departments/seeder/cli.py @@ -253,7 +253,7 @@ def ids(kind: str) -> dict[str, int]: 'alsoMemberOf': p.spec.get('alsoMemberOf', []), } for p in department.people if p.persona], 'otherPeople': len([p for p in department.people if not p.persona]), - 'groups': ids('group'), 'units': ids('unit'), 'calls': {k: {'id': v, 'name': next(c['name'] for c in s.get('calls', []) if c['key'] == k), + 'groups': ids('group'), 'units': ids('unit'), 'pois': ids('poi'), 'calls': {k: {'id': v, 'name': next(c['name'] for c in s.get('calls', []) if c['key'] == k), 'state': next(c.get('state', 'active') for c in s.get('calls', []) if c['key'] == k)} for k, v in ids('call').items()}, 'flags': {'profile': s.get('flags', {}).get('profile', 'server'), 'overrides': {f.key: department.flag_overrides[f.id] for f in flags if f.id in department.flag_overrides}, 'expected': {key: {'enabled': p.enabled, 'source': p.source} for key, p in department.flag_predictions.items()}}, diff --git a/Docker/qa-departments/seeder/verify.py b/Docker/qa-departments/seeder/verify.py index e34d84838..fd3f563d1 100644 --- a/Docker/qa-departments/seeder/verify.py +++ b/Docker/qa-departments/seeder/verify.py @@ -25,7 +25,7 @@ 'Calls/GetActiveCalls', 'Calls/GetPendingCalls', 'Calls/GetAllPendingScheduledCalls', 'CallPriorities/GetAllCallPriorites', 'CallTypes/GetAllCallTypes', 'Units/GetAllUnitsInfos', 'UnitStatus/GetAllUnitStatuses', 'UnitRoles/GetAllUnitRolesAndAssignmentsForDepartment', 'Personnel/GetAllPersonnelInfos', 'Statuses/GetAllStatusesForPersonnel', 'Statuses/GetAllStaffingsForPersonnel', 'Statuses/GetAllUnitStatuses', 'Groups/GetAllGroups', - 'Messages/GetInboxMessages', 'Contacts/GetAllContacts', 'Mapping/GetPoiTypes', 'Mapping/GetMapDataAndMarkers', 'Protocols/GetAllProtocols', + 'Messages/GetInboxMessages', 'Contacts/GetAllContacts', 'Mapping/GetPoiTypes', 'Mapping/GetPoiCapabilities', 'Mapping/GetMapDataAndMarkers', 'Protocols/GetAllProtocols', 'Notes/GetAllNotes', 'Calendar/GetDepartmentCalendarItems', 'Shifts/GetShifts', 'Shifts/GetTodaysShifts', ] diff --git a/Providers/Resgrid.Providers.Geo/KmlProvider.cs b/Providers/Resgrid.Providers.Geo/KmlProvider.cs index 23702b27c..acac7a09c 100644 --- a/Providers/Resgrid.Providers.Geo/KmlProvider.cs +++ b/Providers/Resgrid.Providers.Geo/KmlProvider.cs @@ -113,7 +113,11 @@ private static void ExtractCoordinates(Element root, KmlImportResult result) continue; } - result.Coordinates.Add(new Coordinates { Name = NameOf(placemark), Latitude = center.Latitude, Longitude = center.Longitude }); + // The ExtendedData goes with it only when there is some (a POI import reads a "Capabilities" column from it). + var extendedData = ExtendedDataOf(placemark); + result.Coordinates.Add(extendedData.Count > 0 + ? new KmlPositionedPlacemark { Name = NameOf(placemark), Latitude = center.Latitude, Longitude = center.Longitude, Fields = extendedData } + : new Coordinates { Name = NameOf(placemark), Latitude = center.Latitude, Longitude = center.Longitude }); } } diff --git a/Providers/Resgrid.Providers.Geo/MapLayerFileReader.cs b/Providers/Resgrid.Providers.Geo/MapLayerFileReader.cs new file mode 100644 index 000000000..fc5876921 --- /dev/null +++ b/Providers/Resgrid.Providers.Geo/MapLayerFileReader.cs @@ -0,0 +1,777 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using GeoJSON.Net.Geometry; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using SharpKml.Base; +using SharpKml.Dom; +using SharpKml.Dom.GX; +using SharpKml.Engine; +using GeoFeature = GeoJSON.Net.Feature.Feature; +using FeatureCollection = GeoJSON.Net.Feature.FeatureCollection; + +namespace Resgrid.Providers.GeoLocationProvider +{ + /// + /// Reads an uploaded KML, KMZ or GeoJSON file into the shapes of a map layer (event layers: a marathon route, a festival + /// site, the vendors along a parade). Every placemark or feature becomes plain points, lines and polygons -- multi + /// geometries and GPS tracks are split or flattened, polygon holes are kept -- with the name, a plain-text description + /// (HTML stripped, ExtendedData/extra properties appended), the KML style or GeoJSON stroke/fill color, and the KML folder + /// as "category". Coordinates are rounded to 7 decimals (about 1 cm). + /// + /// + /// The layer is stored as one document and every map loads all of it, so the file, shape and coordinate counts are capped + /// (). Descriptions are stored as plain text: maps render them as text, never as HTML. + /// + public static class MapLayerFileReader + { + private const int MaxNameLength = 250; + private const int MaxDescriptionLength = 2000; + private const int MaxCategoryLength = 100; + private const int MaxExtraFields = 20; + + private static readonly Regex HexColor = new Regex("^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$", RegexOptions.Compiled); + private static readonly Regex LineBreakTags = new Regex(@"<\s*(br|/p|/div|/li|/tr|/h[1-6])\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex Tags = new Regex("<[^>]*>", RegexOptions.Compiled); + private static readonly Regex Spaces = new Regex(@"[ \t ]+", RegexOptions.Compiled); + + /// GeoJSON properties that are styling or already mapped, so they are not repeated in the description. + private static readonly HashSet GeoJsonReservedProperties = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "name", "title", "label", "description", "desc", "notes", "color", "colour", "stroke", "fill", "marker-color", + "stroke-width", "stroke-opacity", "fill-opacity", "marker-size", "marker-symbol", "category", "folder", + "shape", "radius", "text", "styleUrl", "styleHash", "styleMapHash", "icon", "id" + }; + + /// Reads a file by its name and content. KMZ is recognised by content, whatever the file is called. + public static MapLayerFileReadResult Read(Stream input, string fileName) + { + if (input == null) + return new MapLayerFileReadResult { Error = MapLayerFileReadErrors.Unreadable }; + + byte[] content; + using (var buffer = new MemoryStream()) + { + var block = new byte[81920]; + int read; + while ((read = input.Read(block, 0, block.Length)) > 0) + { + buffer.Write(block, 0, read); + if (buffer.Length > MapLayerFileLimits.MaxFileBytes) + return new MapLayerFileReadResult { Error = MapLayerFileReadErrors.TooLarge }; + } + + content = buffer.ToArray(); + } + + return Read(content, fileName); + } + + /// + public static MapLayerFileReadResult Read(byte[] content, string fileName) + { + if (content == null || content.Length == 0) + return new MapLayerFileReadResult { Error = MapLayerFileReadErrors.Unreadable }; + + if (content.LongLength > MapLayerFileLimits.MaxFileBytes) + return new MapLayerFileReadResult { Error = MapLayerFileReadErrors.TooLarge }; + + var extension = (Path.GetExtension(fileName ?? string.Empty) ?? string.Empty).ToLowerInvariant(); + var isZip = content.Length > 3 && content[0] == 0x50 && content[1] == 0x4B && content[2] == 0x03 && content[3] == 0x04; + + if (isZip || extension == ".kml" || extension == ".kmz") + return isZip && !KmzFitsLimit(content) ? new MapLayerFileReadResult { Error = MapLayerFileReadErrors.TooLarge } : ReadKml(content); + + if (extension == ".geojson" || extension == ".json") + return ReadGeoJson(content); + + // No telling extension: sniff the first character. + var first = Encoding.UTF8.GetString(content, 0, Math.Min(content.Length, 512)).TrimStart('', ' ', '\t', '\r', '\n'); + if (first.StartsWith("{") || first.StartsWith("[")) + return ReadGeoJson(content); + if (first.StartsWith("<")) + return ReadKml(content); + + return new MapLayerFileReadResult { Error = MapLayerFileReadErrors.UnsupportedType }; + } + + /// The KML inside a KMZ must fit the same limit as a plain file (a small zip can inflate a long way). + private static bool KmzFitsLimit(byte[] content) + { + try + { + using (var archive = new ZipArchive(new MemoryStream(content), ZipArchiveMode.Read)) + return archive.Entries.Sum(e => e.Length) <= MapLayerFileLimits.MaxFileBytes * 4; + } + catch (InvalidDataException) + { + return true; // Damaged: the KML read reports it. + } + } + + #region KML + + private static MapLayerFileReadResult ReadKml(byte[] content) + { + Element root; + try + { + root = KmlDocumentReader.ReadRoot(content); + } + catch (Exception ex) when (ex is XmlException || ex is InvalidDataException || ex is FileNotFoundException || ex is DecoderFallbackException || ex is ArgumentException) + { + return new MapLayerFileReadResult { Error = MapLayerFileReadErrors.Unreadable }; + } + + if (root == null) + return new MapLayerFileReadResult { Error = MapLayerFileReadErrors.Unreadable }; + + var builder = new Builder(); + var elements = root.Flatten().ToList(); + var styles = new Dictionary(StringComparer.Ordinal); + var styleMaps = new Dictionary(StringComparer.Ordinal); + + foreach (var style in elements.OfType + + normal#route-normalhighlight#route-highlight + + + + Course + + Full course + #route + 4.3517,50.8466,0 4.3600,50.8500,0 4.3700,50.8550,0 + + + Recorded lead bike + 4.35 50.84 04.36 50.85 04.37 50.86 0 + + + + Aid stations + + Aid 1 & water + Water and first aid
Open 08:00–14:00]]>
+ #aid + 4 medics + 4.3550,50.8480,0 +
+ + Twin posts + + 4.3610,50.8510 + 4.3620,50.8520 + + +
+ + Start village + #zone + + 4.34,50.84 4.35,50.84 4.35,50.85 4.34,50.85 4.34,50.84 + 4.342,50.842 4.344,50.842 4.344,50.844 4.342,50.842 + + + + Sponsor tent (by address only) +
Grand-Place 1, Brussels
+
+ + Broken + 200,95 + + +"; + + private static Feature Named(MapLayerFileReadResult result, string name) => + result.Features.Features.First(f => (string)f.Properties["name"] == name); + + [Test] + public void A_kml_event_file_keeps_names_colors_folders_and_descriptions() + { + var result = MapLayerFileReader.Read(Encoding.UTF8.GetBytes(EventKml), "marathon.kml"); + + result.Success.Should().BeTrue(); + result.Points.Should().Be(3); // Aid 1 + the two halves of the MultiGeometry + result.Lines.Should().Be(2); // course + gx:Track + result.Polygons.Should().Be(1); + result.SkippedWithoutGeometry.Should().Be(1); + result.SkippedInvalid.Should().Be(1); + + var course = Named(result, "Full course"); + course.Properties["color"].Should().Be("#ff0000", "a StyleMap resolves to its normal style; KML colors are aabbggrr"); + course.Properties["category"].Should().Be("Course"); + ((LineString)course.Geometry).Coordinates.Should().HaveCount(3); + + var aid = Named(result, "Aid 1 & water"); + aid.Properties["color"].Should().Be("#00a000"); + aid.Properties["category"].Should().Be("Aid stations"); + aid.Properties["description"].Should().Be("Water and first aid\nOpen 08:00–14:00\nStaff: 4 medics", "HTML is stripped and ExtendedData appended"); + + result.Features.Features.Count(f => (string)f.Properties["name"] == "Twin posts").Should().Be(2); + + var village = Named(result, "Start village"); + village.Properties["color"].Should().Be("#0000ff", "a polygon takes its outline color"); + ((Polygon)village.Geometry).Coordinates.Should().HaveCount(2, "the hole is kept"); + } + + [Test] + public void The_shapes_survive_the_layer_document() + { + var result = MapLayerFileReader.Read(Encoding.UTF8.GetBytes(EventKml), "marathon.kml"); + var data = new MapLayerData(); + data.Hydrate(result.Features); + + var back = data.Convert(); + + back.Features.Should().HaveCount(result.ShapeCount); + var aid = back.Features.First(f => (string)f.Properties["name"] == "Aid 1 & water"); + aid.Properties["description"].Should().Be("Water and first aid\nOpen 08:00–14:00\nStaff: 4 medics"); + aid.Properties["color"].Should().Be("#00a000"); + aid.Properties["category"].Should().Be("Aid stations"); + ((Polygon)back.Features.First(f => (string)f.Properties["name"] == "Start village").Geometry).Coordinates.Should().HaveCount(2); + } + + [Test] + public void A_kmz_is_read_whatever_it_is_called() + { + byte[] kmz; + using (var buffer = new MemoryStream()) + { + using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, true)) + using (var writer = new StreamWriter(zip.CreateEntry("doc.kml").Open())) + writer.Write(EventKml); + kmz = buffer.ToArray(); + } + + MapLayerFileReader.Read(kmz, "route.kmz").ShapeCount.Should().Be(6); + MapLayerFileReader.Read(kmz, "route.kml").ShapeCount.Should().Be(6); + } + + [Test] + public void A_geojson_file_is_split_into_simple_shapes_with_its_stroke_color_and_extra_properties() + { + const string geoJson = @"{""type"":""FeatureCollection"",""features"":[ + {""type"":""Feature"",""properties"":{""name"":""Parade route"",""stroke"":""#123abc"",""stroke-width"":3}, + ""geometry"":{""type"":""MultiLineString"",""coordinates"":[[[4.1,50.1],[4.2,50.2]],[[4.3,50.3],[4.4,50.4,12.5]]]}}, + {""type"":""Feature"",""properties"":{""title"":""Food court"",""Vendor"":""Frites Nord"",""Stand"":12}, + ""geometry"":{""type"":""MultiPolygon"",""coordinates"":[[[[4,50],[4.01,50],[4.01,50.01],[4,50]]],[[[4.1,50],[4.11,50],[4.11,50.01],[4.1,50]]]]}}, + {""type"":""Feature"",""properties"":{""name"":""No geometry""},""geometry"":null}, + {""type"":""Feature"",""properties"":{""name"":""Bad""},""geometry"":{""type"":""Point"",""coordinates"":[""x"",1]}} +]}"; + var result = MapLayerFileReader.Read(Encoding.UTF8.GetBytes(geoJson), "parade.geojson"); + + result.Success.Should().BeTrue(); + result.Lines.Should().Be(2); + result.Polygons.Should().Be(2); + result.SkippedWithoutGeometry.Should().Be(1); + result.SkippedInvalid.Should().Be(1); + Named(result, "Parade route").Properties["color"].Should().Be("#123abc"); + Named(result, "Food court").Properties["description"].Should().Be("Vendor: Frites Nord\nStand: 12"); + } + + [Test] + public void A_resgrid_layer_export_keeps_its_circles_and_text_labels() + { + const string geoJson = @"{""type"":""FeatureCollection"",""features"":[ + {""type"":""Feature"",""properties"":{""name"":""Staging"",""shape"":""Circle"",""radius"":150},""geometry"":{""type"":""Point"",""coordinates"":[4.1,50.1]}}, + {""type"":""Feature"",""properties"":{""name"":""Label"",""shape"":""Text"",""text"":""Gate B""},""geometry"":{""type"":""Point"",""coordinates"":[4.2,50.2]}}]}"; + + var result = MapLayerFileReader.Read(Encoding.UTF8.GetBytes(geoJson), "export.json"); + + Named(result, "Staging").Properties["radius"].Should().Be(150d); + Named(result, "Label").Properties["text"].Should().Be("Gate B"); + } + + [Test] + public void A_file_with_only_addresses_says_so() + { + const string kml = @"Shop
Main St 1
"; + + var result = MapLayerFileReader.Read(Encoding.UTF8.GetBytes(kml), "stores.kml"); + + result.Error.Should().Be(MapLayerFileReadErrors.NoShapes); + result.SkippedWithoutGeometry.Should().Be(1); + } + + [Test] + public void Too_many_shapes_or_points_is_refused_rather_than_cut_short() + { + var points = string.Join(",", Enumerable.Range(0, MapLayerFileLimits.MaxFeatures + 1) + .Select(i => $@"{{""type"":""Feature"",""properties"":{{}},""geometry"":{{""type"":""Point"",""coordinates"":[{4 + i * 0.00001:0.00000},50]}}}}")); + var many = MapLayerFileReader.Read(Encoding.UTF8.GetBytes(@"{""type"":""FeatureCollection"",""features"":[" + points + "]}"), "many.geojson"); + many.Error.Should().Be(MapLayerFileReadErrors.TooManyShapes); + many.Features.Features.Should().BeEmpty(); + + var line = string.Join(",", Enumerable.Range(0, MapLayerFileLimits.MaxPositions + 1).Select(i => $"[{4 + i * 0.000001:0.000000},50]")); + var long_ = MapLayerFileReader.Read(Encoding.UTF8.GetBytes(@"{""type"":""LineString"",""coordinates"":[" + line + "]}"), "long.geojson"); + long_.Error.Should().Be(MapLayerFileReadErrors.TooManyPoints); + } + + [TestCase("not xml at all <", "x.kml", MapLayerFileReadErrors.Unreadable)] + [TestCase("{ broken json", "x.geojson", MapLayerFileReadErrors.Unreadable)] + [TestCase("plain text", "x.txt", MapLayerFileReadErrors.UnsupportedType)] + [TestCase("", "x.kml", MapLayerFileReadErrors.NoShapes)] + public void Unusable_files_report_why(string content, string name, MapLayerFileReadErrors error) + { + MapLayerFileReader.Read(Encoding.UTF8.GetBytes(content), name).Error.Should().Be(error); + } + + [Test] + public void Descriptions_are_plain_text() + { + MapLayerFileReader.PlainText("

First

Second & third

") + .Should().Be("alert(1) First\nSecond & third"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/HydrantImportTests.cs b/Tests/Resgrid.Tests/Rms/HydrantImportTests.cs index cf2486613..4e595fd2b 100644 --- a/Tests/Resgrid.Tests/Rms/HydrantImportTests.cs +++ b/Tests/Resgrid.Tests/Rms/HydrantImportTests.cs @@ -95,7 +95,303 @@ public void Csv_handles_bom_quoted_commas_newlines_and_aliases() parsed.Rows.Single().Hydrant.Type.Should().Be((int)RmsHydrantType.WetBarrel); } + [Test] + public async Task Csv_from_another_system_imports_known_columns_and_reports_the_rest() + { + // Header shape of a third-party export a customer uploaded: 15 extra columns used to reject the whole file. + const string csv = "number,status,exclude_from_auto_routing,flow_rate,flow_rating_color,hydrant_type,inspection_responsible_shift_id,inspection_responsible_station_id," + + "last_inspection_date,latitude,location_address,longitude,next_inspection_date,notes,offline_edited_at,route_access_latitude,route_access_longitude,zone,\n" + + "H-1,Active,false,1250,green,Dry Hydrant,,7,2026-05-01,45.5,\"1 River Rd, Rear\",-122.6,2027-05-01,\"Note, with comma\",,45.49,-122.59,North,\n" + + "H-2,Out of Service,true,,,Wet,,,,45.6,,-122.7,,,,,,South,\n"; + var h = new RmsPreventionHarness(); + + var result = await h.HydrantsService.ImportCsvAsync(Dept, Admin, csv); + + result.ValidationFailed.Should().BeFalse(); + result.Rejected.Should().BeEmpty(); + result.Created.Should().Be(2); + result.TakenOutOfService.Should().Be(1); + result.ReturnedToService.Should().Be(0); + result.IgnoredColumns.Should().Equal("exclude_from_auto_routing", "inspection_responsible_shift_id", + "inspection_responsible_station_id", "last_inspection_date", "next_inspection_date", "notes", "offline_edited_at", "route_access_latitude", "route_access_longitude", "zone"); + result.UnmappedValues.Should().BeEmpty(); + var first = h.Hydrants.Rows.Single(x => x.HydrantNumber == "H-1"); + first.Latitude.Should().Be(45.5m); + first.Longitude.Should().Be(-122.6m); + first.AddressText.Should().Be("1 River Rd, Rear", "location_address is an alias of address"); + first.Type.Should().Be((int)RmsHydrantType.DraftingSite, "a dry hydrant is an unpressurized drafting pipe"); + first.FlowGpm.Should().Be(1250, "flow_rate is an alias of flow_gpm"); + first.FlowClass.Should().Be((int)RmsHydrantFlowClass.A); + first.Notes.Should().BeNull(); + var second = h.Hydrants.Rows.Single(x => x.HydrantNumber == "H-2"); + second.Type.Should().Be((int)RmsHydrantType.WetBarrel); + second.FlowGpm.Should().BeNull(); + second.FlowClass.Should().Be((int)RmsHydrantFlowClass.Unknown); + first.InService.Should().BeTrue(); + second.InService.Should().BeFalse(); + second.OutOfServiceReason.Should().Be("status: Out of Service"); + second.OutOfServiceSince.Should().NotBeNull(); + } + + [TestCase("Active", true)] + [TestCase("In Service", true)] + [TestCase("in-service", true)] + [TestCase("Operational", true)] + [TestCase("Yes", true)] + [TestCase("1", true)] + [TestCase("Out of Service", false)] + [TestCase("OOS", false)] + [TestCase("Temporarily Out of Service", false)] + [TestCase("Not In Service", false)] + [TestCase("Inactive", false)] + [TestCase("Broken", false)] + [TestCase("Needs Repair", false)] + [TestCase("FALSE", false)] + [TestCase("", null)] + [TestCase("N/A", null)] + public void Foreign_status_values_map_to_service_state(string value, bool? expected) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,status\nH,45,-122,{value}", "csv"); + batch.Result.Rejected.Should().BeEmpty(); + batch.Result.UnmappedValues.Should().BeEmpty(); + batch.Result.IgnoredColumns.Should().BeEmpty(); + var item = batch.Rows.Single(); + item.InService.Should().Be(expected); + item.Hydrant.OutOfServiceReason.Should().Be(expected == false ? "status: " + value : null); + } + + [TestCase("Passed")] + [TestCase("Due")] + [TestCase("In Service - needs paint")] + public void Unrecognized_status_leaves_service_state_alone_and_is_reported(string value) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,Hydrant Status\nH,45,-122,{value}", "csv"); + batch.Result.ValidationFailed.Should().BeFalse(); + batch.Rows.Single().InService.Should().BeNull(); + batch.Result.UnmappedValues.Should().ContainSingle().Which.Should().BeEquivalentTo(new HydrantImportUnmappedValue { Column = "Hydrant Status", Value = value, Rows = 1 }); + } + + [Test] + public async Task Imported_status_changes_service_state_and_counts_the_changes() + { + var h = new RmsPreventionHarness(); + await h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "Working", Latitude = 1, Longitude = 2 }); + await h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "Untouched", Latitude = 1, Longitude = 2 }); + var repaired = await h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "Repaired", Latitude = 1, Longitude = 2 }); + await h.HydrantsService.SetServiceStateAsync(Dept, Admin, repaired.RmsHydrantId, false, "Valve stuck"); + var stillDown = await h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "StillDown", Latitude = 1, Longitude = 2 }); + await h.HydrantsService.SetServiceStateAsync(Dept, Admin, stillDown.RmsHydrantId, false, "Frozen"); + var since = Hydrant("StillDown").OutOfServiceSince; + + var result = await h.HydrantsService.ImportCsvAsync(Dept, Admin, + "number,latitude,longitude,Status\nWorking,1,2,Broken\nUntouched,1,2,\nRepaired,1,2,Active\nStillDown,1,2,Out of service\nNew,1,2,Inactive\nNewActive,1,2,Active\n"); + + result.FailureMessage.Should().BeNull(); + result.TakenOutOfService.Should().Be(2, "Working was taken out and New was created out of service"); + result.ReturnedToService.Should().Be(1); + Hydrant("Working").InService.Should().BeFalse(); + Hydrant("Working").OutOfServiceReason.Should().Be("Status: Broken"); + Hydrant("Working").OutOfServiceSince.Should().NotBeNull(); + Hydrant("Untouched").InService.Should().BeTrue(); + Hydrant("Repaired").InService.Should().BeTrue(); + Hydrant("Repaired").OutOfServiceReason.Should().BeNull(); + Hydrant("Repaired").OutOfServiceSince.Should().BeNull(); + Hydrant("StillDown").OutOfServiceReason.Should().Be("Frozen", "a hydrant already out of service keeps its own reason"); + Hydrant("StillDown").OutOfServiceSince.Should().Be(since); + Hydrant("New").InService.Should().BeFalse(); + Hydrant("New").OutOfServiceReason.Should().Be("Status: Inactive"); + Hydrant("NewActive").InService.Should().BeTrue(); + + RmsHydrant Hydrant(string number) => h.Hydrants.Rows.Single(x => x.HydrantNumber == number); + } + + [Test] + public async Task Rolled_back_batch_does_not_count_its_service_changes_twice() + { + var h = new RmsPreventionHarness(); + var store = new List { new RmsHydrant { RmsHydrantId = "existing", DepartmentId = Dept, HydrantNumber = "H1", Latitude = 1m, Longitude = 2m, InService = true } }; + List snapshot = null; + h.UnitOfWork.Setup(u => u.CreateOrGetConnectionAsync(It.IsAny())).Callback(() => snapshot = store.Select(x => x.CloneJson()).ToList()).ReturnsAsync((System.Data.Common.DbConnection)null); + h.UnitOfWork.Setup(u => u.DiscardChanges()).Callback(() => { store.Clear(); store.AddRange(snapshot); snapshot = null; }); + var repository = new Mock(); + repository.Setup(x => x.GetAllLiveAsync(Dept)).ReturnsAsync(() => store.ToList()); + repository.Setup(x => x.GetByNumberAsync(Dept, It.IsAny())).ReturnsAsync((int _, string n) => store.FirstOrDefault(x => x.HydrantNumber == n)); + repository.Setup(x => x.GetByNumbersAsync(Dept, It.IsAny>())) + .ReturnsAsync((int _, IEnumerable numbers) => (IReadOnlyDictionary)numbers.Select(n => store.FirstOrDefault(x => x.HydrantNumber == n)).Where(x => x != null).ToDictionary(x => x.HydrantNumber)); + repository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), true)).ReturnsAsync((RmsHydrant e, CancellationToken _, bool __) => { store.Add(e); return e; }); + repository.Setup(x => x.InsertAsync(It.Is(x => x.HydrantNumber == "H3"), It.IsAny(), true)).ThrowsAsync(new Exception("secret database details")); + repository.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), true)).ReturnsAsync((RmsHydrant e, CancellationToken _, bool __) => { store.RemoveAll(x => x.RmsHydrantId == e.RmsHydrantId); store.Add(e); return e; }); + var service = new RecordsHydrantsService(h.Gate, repository.Object, h.FlowTests, h.Maintenance, h.Attachments, h.UnitOfWork.Object); + + var result = await service.ImportCsvAsync(Dept, Admin, "number,latitude,longitude,status\nH1,1,2,broken\nH2,2,3,inactive\nH3,3,4,broken"); + + result.FailureMessage.Should().Contain("row 4"); + result.TakenOutOfService.Should().Be(2, "only the replayed H1 and H2 count, not the rolled-back batch attempt as well"); + store.Single(x => x.HydrantNumber == "H1").InService.Should().BeFalse(); + } + + [TestCase("Light Blue", RmsHydrantFlowClass.AA)] + [TestCase("BLUE", RmsHydrantFlowClass.AA)] + [TestCase("Class AA", RmsHydrantFlowClass.AA)] + [TestCase("green", RmsHydrantFlowClass.A)] + [TestCase("A", RmsHydrantFlowClass.A)] + [TestCase("Orange", RmsHydrantFlowClass.B)] + [TestCase("class-b", RmsHydrantFlowClass.B)] + [TestCase("Red", RmsHydrantFlowClass.C)] + [TestCase("#23c6c8", RmsHydrantFlowClass.AA)] + [TestCase("#ADD8E6", RmsHydrantFlowClass.AA)] + [TestCase("#1ab394", RmsHydrantFlowClass.A)] + [TestCase("#008000", RmsHydrantFlowClass.A)] + [TestCase("#FFA500", RmsHydrantFlowClass.B)] + [TestCase("#f8ac59", RmsHydrantFlowClass.B)] + [TestCase("#f00", RmsHydrantFlowClass.C)] + [TestCase("#ed5565", RmsHydrantFlowClass.C)] + [TestCase("", RmsHydrantFlowClass.Unknown)] + [TestCase("N/A", RmsHydrantFlowClass.Unknown)] + public void Foreign_flow_colours_map_to_flow_classes(string value, RmsHydrantFlowClass expected) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,flow_rating_color\nH,45,-122,{value}", "csv"); + batch.Result.Rejected.Should().BeEmpty(); + batch.Result.UnmappedValues.Should().BeEmpty(); + batch.Result.IgnoredColumns.Should().BeEmpty(); + batch.Rows.Single().Hydrant.FlowClass.Should().Be((int)expected); + } + + [TestCase("Black")] + [TestCase("yellow")] + [TestCase("#FFFF00")] + [TestCase("#808080")] + [TestCase("#000")] + [TestCase("#800080")] + [TestCase("Class AA (1500+ GPM)")] + public void Unreadable_flow_colours_get_no_class_and_are_reported(string value) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,Cap Color\nH,45,-122,\"{value}\"", "csv"); + batch.Result.ValidationFailed.Should().BeFalse(); + batch.Rows.Single().Hydrant.FlowClass.Should().Be((int)RmsHydrantFlowClass.Unknown); + batch.Result.UnmappedValues.Should().ContainSingle().Which.Should().BeEquivalentTo(new HydrantImportUnmappedValue { Column = "Cap Color", Value = value, Rows = 1 }); + } + + [Test] + public async Task Imported_flow_colour_sets_the_class_only_where_no_flow_decides_it() + { + var h = new RmsPreventionHarness(); + await h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "Tested", Latitude = 1, Longitude = 2, FlowGpm = 1200 }); + await h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "Untested", Latitude = 1, Longitude = 2 }); + + var result = await h.HydrantsService.ImportCsvAsync(Dept, Admin, + "number,latitude,longitude,flow_rate,flow_rating_color\nTested,1,2,,red\nUntested,1,2,,orange\nNew,1,2,,light blue\nMeasured,1,2,1600,red\nZero,1,2,0,green\n"); + + result.FailureMessage.Should().BeNull(); + FlowClassOf("Tested").Should().Be(RmsHydrantFlowClass.A, "the flow on record still decides the class"); + h.Hydrants.Rows.Single(x => x.HydrantNumber == "Tested").FlowGpm.Should().Be(1200); + FlowClassOf("Untested").Should().Be(RmsHydrantFlowClass.B); + FlowClassOf("New").Should().Be(RmsHydrantFlowClass.AA); + FlowClassOf("Measured").Should().Be(RmsHydrantFlowClass.AA, "an imported flow decides the class over the colour"); + FlowClassOf("Zero").Should().Be(RmsHydrantFlowClass.A, "a zero flow cannot be classified, so the colour is used"); + + RmsHydrantFlowClass FlowClassOf(string number) => (RmsHydrantFlowClass)h.Hydrants.Rows.Single(x => x.HydrantNumber == number).FlowClass; + } + + [Test] + public async Task Manual_save_does_not_take_a_flow_class_without_a_flow() + { + var h = new RmsPreventionHarness(); + var saved = await h.HydrantsService.SaveAsync(Dept, Admin, new RmsHydrant { HydrantNumber = "H", Latitude = 1, Longitude = 2, FlowClass = (int)RmsHydrantFlowClass.C }); + saved.FlowClass.Should().Be((int)RmsHydrantFlowClass.Unknown); + } + + [TestCase("Dry Barrel", RmsHydrantType.DryBarrel)] + [TestCase("dry-barrel hydrant", RmsHydrantType.DryBarrel)] + [TestCase("WET_BARREL", RmsHydrantType.WetBarrel)] + [TestCase("Wet Hydrant", RmsHydrantType.WetBarrel)] + [TestCase("Standpipe", RmsHydrantType.Standpipe)] + [TestCase("Underground Tank", RmsHydrantType.Cistern)] + [TestCase("Dry Hydrant", RmsHydrantType.DraftingSite)] + [TestCase("Drafting Point", RmsHydrantType.DraftingSite)] + [TestCase("Static Water Source", RmsHydrantType.DraftingSite)] + [TestCase("4", RmsHydrantType.Cistern)] + [TestCase("", RmsHydrantType.DryBarrel)] + [TestCase("N/A", RmsHydrantType.DryBarrel)] + [TestCase("Unknown", RmsHydrantType.DryBarrel)] + public void Foreign_hydrant_type_values_map_to_types(string value, RmsHydrantType expected) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,hydrant_type\nH,45,-122,{value}", "csv"); + batch.Result.Rejected.Should().BeEmpty(); + batch.Result.UnmappedValues.Should().BeEmpty(); + batch.Rows.Single().Hydrant.Type.Should().Be((int)expected); + } + + [Test] + public void Unrecognized_foreign_types_import_as_other_and_are_counted() + { + var batch = HydrantImportParser.Parse("number,latitude,longitude,Hydrant Type\nH1,45,-122,Pillar\nH2,45,-122,pillar\nH3,45,-122,Flush\nH4,45,-122,Dry\nH5,45,-122,\"DryBarrel,WetBarrel\"", "csv"); + + batch.Result.ValidationFailed.Should().BeFalse(); + batch.Rows.Should().HaveCount(5); + batch.Rows.Where(r => r.Row.HydrantNumber != "H4").Should().OnlyContain(r => r.Hydrant.Type == (int)RmsHydrantType.Other, "a combined value is not read as a flags combination"); + batch.Result.UnmappedValues.Select(u => (u.Column, u.Value, u.Rows)).Should().Equal(("Hydrant Type", "Pillar", 2), ("Hydrant Type", "DryBarrel,WetBarrel", 1), ("Hydrant Type", "Flush", 1)); + } + + [TestCase("1250", 1250)] + [TestCase("1250.6", 1251)] + [TestCase("1,250", 1250)] + [TestCase("1,250.4 GPM", 1250)] + [TestCase("1000 gal/min", 1000)] + [TestCase("3785 L/min", 1000)] + [TestCase("3785lpm", 1000)] + [TestCase("0", 0)] + [TestCase("", null)] + [TestCase("n/a", null)] + public void Foreign_flow_rate_values_are_read_as_gallons_per_minute(string value, int? expected) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,flow_rate\nH,45,-122,\"{value}\"", "csv"); + batch.Result.Rejected.Should().BeEmpty(); + batch.Result.UnmappedValues.Should().BeEmpty(); + batch.Rows.Single().Hydrant.FlowGpm.Should().Be(expected); + } + + [TestCase("-5")] + [TestCase("lots")] + [TestCase("1250 m3/h")] + [TestCase("1250,5")] + [TestCase("99999999999999")] + public void Unreadable_foreign_flow_is_left_blank_and_reported(string value) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,flow_rate\nH,45,-122,\"{value}\"", "csv"); + batch.Result.ValidationFailed.Should().BeFalse(); + batch.Rows.Single().Hydrant.FlowGpm.Should().BeNull(); + batch.Result.UnmappedValues.Should().ContainSingle().Which.Should().BeEquivalentTo(new HydrantImportUnmappedValue { Column = "flow_rate", Value = value, Rows = 1 }); + } + + [TestCase("type", "Pillar", "type")] + [TestCase("flow_gpm", "1,250", "flow_gpm")] + public void Documented_columns_stay_strict(string column, string value, string error) + { + var batch = HydrantImportParser.Parse($"number,latitude,longitude,{column}\nH,45,-122,\"{value}\"", "csv"); + batch.Result.Rejected.Should().ContainSingle().Which.Error.Should().Contain(error); + batch.Result.UnmappedValues.Should().BeEmpty(); + } + + [Test] + public void Csv_with_only_known_columns_reports_nothing_ignored() + { + HydrantImportParser.Parse(HydrantImportParser.CsvExample, "csv").Result.IgnoredColumns.Should().BeEmpty(); + var headerless = HydrantImportParser.Parse("H,45,-122,DryBarrel,1 River Rd,8,1100,City", "csv"); + headerless.Result.Rejected.Should().BeEmpty("a file without a header uses the eight documented columns"); + headerless.Result.IgnoredColumns.Should().BeEmpty(); + } + + [Test] + public void Two_columns_for_one_field_name_both_columns() + { + Action parse = () => HydrantImportParser.Parse("id,hydrant_number,latitude,longitude\nabc,H,1,2", "csv"); + parse.Should().Throw().WithMessage("*more than one number column (id, hydrant_number)*"); + } + [TestCase("number,latitude,longitude,latitude\nH,1,2,3")] + [TestCase("number,address,location_address,latitude,longitude\nH,a,b,1,2")] + [TestCase("number,latitude,longitude,flow_rating_color,cap_color\nH,1,2,red,red")] [TestCase("number,latitude\nH,1")] [TestCase("number,latitude,longitude\n\"H,1,2")] public void Invalid_csv_structure_is_rejected(string csv) diff --git a/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs b/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs index 6faec90b5..babcba3e0 100644 --- a/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs @@ -597,5 +597,54 @@ public async Task a_status_pointing_at_a_closed_call_falls_back_to_the_dispatch( working.Should().BeEquivalentTo(new Dictionary { { 1, 40 } }); } + + // Transport destination checks: the call a unit was working when it set a transport status. + [Test] + public async Task one_units_working_call_is_its_previous_status_call_even_with_two_open_dispatches() + { + Call(40); + Call(41); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(UnitDispatches(40, 41)); + + var working = await _service.GetWorkingCallIdForUnitAsync(DepartmentId, UnitId, + new UnitState { UnitStateId = 10, UnitId = UnitId, State = (int)UnitStateTypes.OnScene, DestinationId = 41, DestinationType = (int)DestinationEntityTypes.Call }); + + working.Should().Be(41); + } + + [Test] + public async Task one_units_working_call_falls_back_to_its_one_dispatch_when_its_status_points_at_a_poi() + { + Call(40); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(UnitDispatches(40)); + + var working = await _service.GetWorkingCallIdForUnitAsync(DepartmentId, UnitId, + new UnitState { UnitStateId = 10, UnitId = UnitId, State = (int)UnitStateTypes.Committed, DestinationId = 9, DestinationType = (int)DestinationEntityTypes.Poi }); + + working.Should().Be(40); + } + + [Test] + public async Task one_unit_with_no_open_dispatch_or_open_status_call_has_no_working_call() + { + Call(39, CallStates.Closed); + + var working = await _service.GetWorkingCallIdForUnitAsync(DepartmentId, UnitId, + new UnitState { UnitStateId = 10, UnitId = UnitId, State = (int)UnitStateTypes.OnScene, DestinationId = 39, DestinationType = (int)DestinationEntityTypes.Call }); + + working.Should().BeNull(); + } + + [Test] + public async Task one_units_working_call_ignores_a_status_of_another_unit() + { + Call(41); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new List()); + + var working = await _service.GetWorkingCallIdForUnitAsync(DepartmentId, UnitId, + new UnitState { UnitStateId = 10, UnitId = UnitId + 1, State = (int)UnitStateTypes.OnScene, DestinationId = 41, DestinationType = (int)DestinationEntityTypes.Call }); + + working.Should().BeNull(); + } } } diff --git a/Tests/Resgrid.Tests/Services/DispatchRecommendationConditionalTests.cs b/Tests/Resgrid.Tests/Services/DispatchRecommendationConditionalTests.cs index 1ec12b6b4..87e7b5782 100644 --- a/Tests/Resgrid.Tests/Services/DispatchRecommendationConditionalTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchRecommendationConditionalTests.cs @@ -185,6 +185,50 @@ public async Task conditional_rows_rank_by_distance_when_the_department_does() nearest.Units.Should().ContainSingle(u => u.UnitId == 1); } + [Test] + public async Task conditional_rows_rank_to_the_second_and_ties_go_nearest_first() + { + var card = BuildCard(); + card.AlarmLevels.First().ResponsePackageId = EnginePackageId; + EnableConditions(Package(EnginePackageId, "Engine", PackageRow(9002, "Engine", 1, UnitTypeTarget(EngineTypeId)))); + _config.UseRoutedEta = true; + + // Engine 1 (~12.6 km) routes in 5:01, the nearer Engine 2 (~10 km) in 5:29: the quicker one goes. + PlaceUnits((1, 39.8633, -104.95), (2, 39.84, -104.95), (3, 40.5, -105.5)); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.8633,-104.95", It.IsAny())).ReturnsAsync(301); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.84,-104.95", It.IsAny())).ReturnsAsync(329); + + var quicker = await _service.GetRecommendationAsync(BuildRequest()); + quicker.Units.Should().ContainSingle().Which.UnitId.Should().Be(1); + + // Both show 5:01: level on the second, so the nearer one goes. + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.84,-104.95", It.IsAny())).ReturnsAsync(301.3); + + var level = await _service.GetRecommendationAsync(BuildRequest()); + level.Units.Should().ContainSingle().Which.UnitId.Should().Be(2); + } + + [Test] + public async Task conditional_first_arriving_level_on_the_second_is_the_nearer_unit() + { + var card = BuildCard(); + card.AlarmLevels.First().ResponsePackageId = LadderPackageId; + var first = new ResponsePackageRow { ResponsePackageRowId = 9010, Label = "Closest resource", FillMode = (int)RequirementFillModes.EnsureFirstArriving, Targets = new List { AnyUnitTarget() } }; + EnableConditions(Package(LadderPackageId, "Ladder", PackageRow(9001, "Ladder", 1, UnitTypeTarget(LadderTypeId)), first)); + _config.UseRoutedEta = true; + + // The ladder is far off; Engine 1 (~12.6 km) and Engine 2 (~10 km) both show 5:01, the farther a fraction quicker. + PlaceUnits((1, 39.8633, -104.95), (2, 39.84, -104.95), (3, 40.5, -105.5)); + _geoService.Setup(x => x.GetEtaInSecondsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(-1); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.8633,-104.95", It.IsAny())).ReturnsAsync(300.8); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.84,-104.95", It.IsAny())).ReturnsAsync(301.3); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Should().Contain(u => u.UnitId == 2 && u.SelectionReason == RecommendationSelectionReasons.FirstArriving); + result.Decision.FirstArriving.Should().ContainSingle(f => !f.AlreadyIncluded && f.ResourceName == "Engine 2"); + } + [Test] public async Task conditional_station_based_rows_keep_the_response_order_when_the_department_ranks_by_distance() { diff --git a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs index caf187940..99ff89845 100644 --- a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs @@ -630,6 +630,90 @@ public async Task closest_unit_adds_the_turnout_to_routed_etas() engine.ResponseSeconds.Should().Be(130); } + [TestCase(359, Description = "5:01 beats a nearer 5:59")] + [TestCase(329, Description = "5:01 beats a nearer 5:29, though both read 5 min in whole minutes")] + public async Task closest_unit_ranks_routed_times_to_the_second(double nearerSeconds) + { + // Belgian EMS, 2026-11-12: whole minutes on screen hid why a unit showing 17 min ranked behind others showing + // 17 min. Ranking is to the second and the screens show m:ss; there is no minute cut-off. + BuildCard(engineCount: 2); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + _config.UseRoutedEta = true; + + // Engine 1 is ~12.6 km out and routes in 5:01; Engine 2 is nearer (~10 km) but slower by road. + PlaceUnits((1, 39.8633, -104.95), (2, 39.84, -104.95)); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.8633,-104.95", It.IsAny())).ReturnsAsync(301); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.84,-104.95", It.IsAny())).ReturnsAsync(nearerSeconds); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Select(u => u.UnitId).Should().Equal(1, 2); + result.Units.Should().OnlyContain(u => u.SelectionReason == RecommendationSelectionReasons.ClosestByEta); + result.Units[0].ResponseSeconds.Should().Be(301); + } + + [Test] + public async Task closest_unit_level_on_the_second_goes_nearest_first() + { + BuildCard(engineCount: 2); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + _config.UseRoutedEta = true; + + // Both show 17:10. The router has the farther Engine 1 a fraction of a second quicker, which is no reason to send it. + PlaceUnits((1, 39.8633, -104.95), (2, 39.84, -104.95)); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.8633,-104.95", It.IsAny())).ReturnsAsync(1029.6); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.84,-104.95", It.IsAny())).ReturnsAsync(1030.2); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Units.Select(u => u.UnitId).Should().Equal(2, 1); + } + + [Test] + public async Task closest_role_ranks_routed_times_to_the_second_and_ties_go_nearest_first() + { + BuildCard(roleCount: 1); + _departmentSettingsService.Setup(x => x.GetDispatchRecommendationModeAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(DispatchRecommendationModes.ClosestUnit); + _config.UseRoutedEta = true; + + 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.8633, Longitude = -104.95, Timestamp = now } }, + { "user-2", new ResolvedPersonnelLocation { UserId = "user-2", Latitude = 39.84, Longitude = -104.95, Timestamp = now } } + }); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.8633,-104.95", It.IsAny())).ReturnsAsync(301); + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.84,-104.95", It.IsAny())).ReturnsAsync(329); + + var faster = await _service.GetRecommendationAsync(BuildRequest()); + faster.Personnel.Should().ContainSingle().Which.UserId.Should().Be("user-1", "5:01 beats the nearer 5:29"); + + _geoService.Setup(x => x.GetEtaInSecondsAsync("39.84,-104.95", It.IsAny())).ReturnsAsync(301.3); + + var level = await _service.GetRecommendationAsync(BuildRequest()); + level.Personnel.Should().ContainSingle().Which.UserId.Should().Be("user-2", "both show 5:01, so the nearer goes"); + } + + [TestCase(0, 0)] + [TestCase(301, 301)] + [TestCase(301.4, 301)] + [TestCase(301.5, 302, Description = "half a second rounds up, as the screens' Math.round does")] + [TestCase(1029.6, 1030)] + public void ranking_seconds_round_as_the_screens_show_them(double seconds, double expected) + { + UnitResponseOrigin.RankingSeconds(seconds).Should().Be(expected); + } + + [Test] + public void ranking_seconds_put_no_time_last() + { + UnitResponseOrigin.RankingSeconds((double?)null).Should().Be(double.MaxValue); + } + [Test] public async Task closest_unit_ranked_by_distance_picks_the_nearest_unit_whatever_its_turnout() { diff --git a/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs index 99966a280..0461adffb 100644 --- a/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs @@ -647,6 +647,45 @@ public async Task a_types_looked_up_units_rank_ahead_of_its_estimates_as_a_run_c board.Units.Select(u => u.UnitId).Should().Equal(IndividualUnit, TeamUnit, FarTeamUnit, ApparatusUnit); } + [Test] + public async Task units_rank_to_the_second_and_units_level_on_the_second_go_nearest_first() + { + // Belgian EMS, 2026-11-12: an ambulance showing 17 min at 10.0 km was listed after two showing 17 min at 12.6 km, + // which the router had at 16:35 against its 17:10; whole minutes on screen hid why. The board ranks to the second + // (and shows m:ss), and only units level on the second go nearest first. + var now = DateTime.UtcNow; + _unitsService.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(new List + { + new Unit { UnitId = 11, DepartmentId = DepartmentId, Name = "Medic 1", Type = "Ambulance" }, + new Unit { UnitId = 14, DepartmentId = DepartmentId, Name = "Medic 4", Type = "Ambulance" }, + new Unit { UnitId = 15, DepartmentId = DepartmentId, Name = "Medic 5", Type = "Ambulance" }, + new Unit { UnitId = 17, DepartmentId = DepartmentId, Name = "Medic 7", Type = "Ambulance" }, + new Unit { UnitId = 18, DepartmentId = DepartmentId, Name = "Medic 8", Type = "Ambulance" } + }); + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)) + .ReturnsAsync(new[] { 11, 14, 15, 17, 18 }.Select(id => new UnitState { UnitId = id, State = (int)UnitStateTypes.Available, Timestamp = now }).ToList()); + _unitsService.Setup(x => x.GetLatestUnitLocationsAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitsLocation { UnitId = 11, Latitude = 34.084m, Longitude = -118.25m, Timestamp = now }, // ~3.8 km, 9 min + new UnitsLocation { UnitId = 14, Latitude = 34.1633m, Longitude = -118.25m, Timestamp = now }, // ~12.6 km, 16:35 + new UnitsLocation { UnitId = 15, Latitude = 34.1633m, Longitude = -118.25m, Timestamp = now }, // same post + new UnitsLocation { UnitId = 17, Latitude = 34.14m, Longitude = -118.25m, Timestamp = now }, // ~10.0 km, 17:10 + new UnitsLocation { UnitId = 18, Latitude = 34.12m, Longitude = -118.25m, Timestamp = now } // ~7.8 km, 17:10 too + }); + _geoService.Setup(x => x.GetEtaInSecondsAsync("34.084,-118.25", "34.05,-118.25")).ReturnsAsync(540); + _geoService.Setup(x => x.GetEtaInSecondsAsync("34.1633,-118.25", "34.05,-118.25")).ReturnsAsync(995); + _geoService.Setup(x => x.GetEtaInSecondsAsync("34.14,-118.25", "34.05,-118.25")).ReturnsAsync(1029.8); + _geoService.Setup(x => x.GetEtaInSecondsAsync("34.12,-118.25", "34.05,-118.25")).ReturnsAsync(1030.3); + + var board = await GetBoardAsync(useRoadEta: true); + + // 9:00, then the 16:35 pair (same post, by name), then the two at 17:10 nearest first, though the router had the + // farther one half a second quicker. + board.Units.Select(u => u.UnitId).Should().Equal(11, 14, 15, 18, 17); + board.Units.Should().OnlyContain(u => u.EtaSource == EtaSources.Road); + UnitRow(board, 17).ResponseSeconds.Should().Be(1029.8, "the row carries the unrounded routed seconds"); + } + [Test] public async Task units_at_the_same_position_share_one_drive_time_lookup() { diff --git a/Tests/Resgrid.Tests/Services/PoiCapabilitiesAndAvailabilityTests.cs b/Tests/Resgrid.Tests/Services/PoiCapabilitiesAndAvailabilityTests.cs new file mode 100644 index 000000000..dbeb1650b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PoiCapabilitiesAndAvailabilityTests.cs @@ -0,0 +1,403 @@ +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.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Destination capabilities and availability (Belgian EMS, 2026-11-13): a department tags its POIs with capabilities + /// ("Cath Lab"), and dispatchers record when a destination is limited, on diversion or closed. + /// + [TestFixture] + public class PoiCapabilitiesAndAvailabilityTests + { + private const int DeptId = 7; + private const int HospitalType = 3; + private const int ShelterType = 4; + private const int Gent = 901; + private const int Zottegem = 900; + private const int Shelter = 950; + + private Mock _poiTypes; + private Mock _pois; + private Mock _capabilities; + private Mock _availabilities; + private MappingService _service; + + private List _storedCapabilities; + private List _storedAssignments; + private List _storedAvailabilities; + + [SetUp] + public void SetUp() + { + _poiTypes = new Mock(); + _pois = new Mock(); + _capabilities = new Mock(); + _availabilities = new Mock(); + + _storedCapabilities = new List + { + new PoiCapability { PoiCapabilityId = "stroke", DepartmentId = DeptId, Name = "Stroke Center", Sort = 2 }, + new PoiCapability { PoiCapabilityId = "cath", DepartmentId = DeptId, Name = "Cath Lab", Sort = 1 }, + new PoiCapability { PoiCapabilityId = "trauma", DepartmentId = DeptId, Name = "Trauma Center", Sort = 3 } + }; + _storedAssignments = new List + { + new PoiCapabilityAssignment { DepartmentId = DeptId, PoiId = Gent, PoiCapabilityId = "trauma" }, + new PoiCapabilityAssignment { DepartmentId = DeptId, PoiId = Gent, PoiCapabilityId = "CATH" }, + new PoiCapabilityAssignment { DepartmentId = DeptId, PoiId = Zottegem, PoiCapabilityId = "stroke" }, + new PoiCapabilityAssignment { DepartmentId = DeptId, PoiId = Zottegem, PoiCapabilityId = "deleted-elsewhere" } + }; + _storedAvailabilities = new List(); + + _poiTypes.Setup(x => x.GetPoiTypesByDepartmentIdAsync(DeptId)).ReturnsAsync(() => new List + { + new PoiType + { + PoiTypeId = HospitalType, DepartmentId = DeptId, Name = "Hospital", IsDestination = true, + Pois = new List + { + new Poi { PoiId = Zottegem, PoiTypeId = HospitalType, Name = "AZ Sint-Elisabeth Zottegem", Latitude = 50.87, Longitude = 3.81 }, + new Poi { PoiId = Gent, PoiTypeId = HospitalType, Name = "UZ Gent", Latitude = 51.02, Longitude = 3.73 } + } + }, + new PoiType + { + PoiTypeId = ShelterType, DepartmentId = DeptId, Name = "Shelter", IsDestination = false, + Pois = new List { new Poi { PoiId = Shelter, PoiTypeId = ShelterType, Name = "Gym" } } + } + }); + _capabilities.Setup(x => x.GetAllByDepartmentIdAsync(DeptId)).ReturnsAsync(() => _storedCapabilities.ToList()); + _capabilities.Setup(x => x.GetAssignmentsByDepartmentIdAsync(DeptId)).ReturnsAsync(() => _storedAssignments.ToList()); + _capabilities.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((PoiCapability c, CancellationToken _, bool __) => + { + c.PoiCapabilityId ??= Guid.NewGuid().ToString(); + return c; + }); + _availabilities.Setup(x => x.GetAllByDepartmentIdAsync(DeptId)).ReturnsAsync(() => _storedAvailabilities.ToList()); + _availabilities.Setup(x => x.GetByPoiIdAsync(DeptId, It.IsAny())).ReturnsAsync((int _, int poiId) => _storedAvailabilities.FirstOrDefault(a => a.PoiId == poiId)); + _availabilities.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((PoiAvailability a, CancellationToken _, bool __) => a); + + _service = new MappingService(_poiTypes.Object, _pois.Object, null, null, null, null, _capabilities.Object, _availabilities.Object); + } + + [Test] + public async Task capabilities_are_listed_in_the_departments_order() + { + var list = await _service.GetPoiCapabilitiesForDepartmentAsync(DeptId); + + list.Select(x => x.Name).Should().Equal("Cath Lab", "Stroke Center", "Trauma Center"); + } + + [Test] + public async Task a_new_capability_is_trimmed_and_sorted_last() + { + var saved = await _service.SavePoiCapabilityAsync(new PoiCapability { DepartmentId = DeptId, Name = " Burn Center " }); + + saved.Name.Should().Be("Burn Center"); + saved.Sort.Should().Be(4); + } + + [TestCase("cath lab")] + [TestCase(" ")] + public void a_duplicate_or_blank_name_is_refused(string name) + { + Func act = () => _service.SavePoiCapabilityAsync(new PoiCapability { DepartmentId = DeptId, Name = name }); + + act.Should().ThrowAsync(); + } + + [Test] + public async Task renaming_a_capability_to_its_own_name_in_another_case_is_allowed() + { + var saved = await _service.SavePoiCapabilityAsync(new PoiCapability { PoiCapabilityId = "cath", DepartmentId = DeptId, Name = "CATH LAB", Sort = 1 }); + + saved.Name.Should().Be("CATH LAB"); + } + + [Test] + public void another_departments_capability_cannot_be_saved() + { + Func act = () => _service.SavePoiCapabilityAsync(new PoiCapability { PoiCapabilityId = "theirs", DepartmentId = DeptId, Name = "Helipad" }); + + act.Should().ThrowAsync(); + } + + [Test] + public async Task import_names_reuse_existing_capabilities_and_add_the_missing_ones() + { + var result = await _service.GetOrAddPoiCapabilitiesByNameAsync(DeptId, new[] { "cath lab", "Neurosurgery", " ", "NEUROSURGERY" }); + + result.Select(x => x.Name).Should().Equal("Cath Lab", "Neurosurgery"); + _capabilities.Verify(x => x.SaveOrUpdateAsync(It.Is(c => c.Name == "Neurosurgery" && c.Sort == 4), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task a_poi_only_gets_the_departments_own_capabilities() + { + await _service.SetPoiCapabilitiesAsync(DeptId, Gent, new[] { "CATH", "cath", "theirs", null, "stroke" }); + + _capabilities.Verify(x => x.ReplacePoiAssignmentsAsync(DeptId, Gent, It.Is>(ids => ids.SequenceEqual(new[] { "cath", "stroke" })), It.IsAny()), Times.Once); + } + + [Test] + public async Task details_fill_capabilities_in_department_order_and_only_current_availability() + { + _storedAvailabilities.Add(new PoiAvailability { PoiAvailabilityId = "a", DepartmentId = DeptId, PoiId = Gent, Status = (int)PoiAvailabilityStatuses.Diversion, SetOn = DateTime.UtcNow }); + _storedAvailabilities.Add(new PoiAvailability { PoiAvailabilityId = "b", DepartmentId = DeptId, PoiId = Zottegem, Status = (int)PoiAvailabilityStatuses.Closed, SetOn = DateTime.UtcNow.AddHours(-3), ExpiresOn = DateTime.UtcNow.AddHours(-1) }); + var pois = await _service.GetDestinationPOIsForDepartmentAsync(DeptId); + + await _service.PopulatePoiDetailsAsync(DeptId, pois); + + var gent = pois.Single(x => x.PoiId == Gent); + gent.CapabilityIds.Should().Equal("cath", "trauma"); + gent.Capabilities.Should().Equal("Cath Lab", "Trauma Center"); + gent.Availability.Status.Should().Be((int)PoiAvailabilityStatuses.Diversion); + + var zottegem = pois.Single(x => x.PoiId == Zottegem); + zottegem.Capabilities.Should().Equal("Stroke Center"); + zottegem.Availability.Should().BeNull("an expired report reads as open"); + } + + [Test] + public async Task a_department_without_capabilities_never_reads_assignments() + { + _storedCapabilities.Clear(); + var pois = await _service.GetDestinationPOIsForDepartmentAsync(DeptId); + + await _service.PopulatePoiDetailsAsync(DeptId, pois); + + pois.Should().OnlyContain(x => x.CapabilityIds.Count == 0 && x.Capabilities.Count == 0); + _capabilities.Verify(x => x.GetAssignmentsByDepartmentIdAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task setting_a_diversion_keeps_only_known_capabilities_and_caps_the_expiry() + { + var report = await _service.SetPoiAvailabilityAsync(DeptId, Gent, PoiAvailabilityStatuses.Limited, new[] { "CATH", "theirs" }, " Cath lab team out ", + DateTime.UtcNow.AddDays(30), "dispatcher"); + + report.Status.Should().Be((int)PoiAvailabilityStatuses.Limited); + report.GetUnavailableCapabilityIds().Should().Equal("cath"); + report.Note.Should().Be("Cath lab team out"); + report.SetByUserId.Should().Be("dispatcher"); + report.ExpiresOn.Should().BeCloseTo(DateTime.UtcNow.AddDays(7), TimeSpan.FromMinutes(1)); + } + + [Test] + public async Task reopening_a_destination_deletes_its_report() + { + _storedAvailabilities.Add(new PoiAvailability { PoiAvailabilityId = "a", DepartmentId = DeptId, PoiId = Gent, Status = (int)PoiAvailabilityStatuses.Closed, SetOn = DateTime.UtcNow }); + + var report = await _service.SetPoiAvailabilityAsync(DeptId, Gent, PoiAvailabilityStatuses.Open, null, null, null, "dispatcher"); + + report.Should().BeNull(); + _availabilities.Verify(x => x.DeleteForPoisAsync(DeptId, It.Is>(ids => ids.Single() == Gent), It.IsAny()), Times.Once); + } + + [Test] + public void availability_is_only_for_the_departments_destinations() + { + Func act = () => _service.SetPoiAvailabilityAsync(DeptId, Shelter, PoiAvailabilityStatuses.Closed, null, null, null, "dispatcher"); + + act.Should().ThrowAsync(); + } + + [Test] + public async Task deleting_a_capability_takes_it_off_pois_and_reports() + { + (await _service.DeletePoiCapabilityAsync(DeptId, "CATH")).Should().BeFalse("the mock delete returns false, but the cleanup still runs"); + + _capabilities.Verify(x => x.DeleteAssignmentsForCapabilityAsync(DeptId, "cath", It.IsAny()), Times.Once); + _availabilities.Verify(x => x.RemoveCapabilityAsync(DeptId, "cath", It.IsAny()), Times.Once); + } + + [Test] + public async Task deleting_a_poi_removes_its_capabilities_and_report() + { + _pois.Setup(x => x.GetByIdAsync(Gent)).ReturnsAsync(new Poi { PoiId = Gent, PoiTypeId = HospitalType }); + _poiTypes.Setup(x => x.GetPoiTypeByTypeIdAsync(HospitalType)).ReturnsAsync(new PoiType { PoiTypeId = HospitalType, DepartmentId = DeptId }); + _pois.Setup(x => x.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + (await _service.DeletePOIAsync(Gent)).Should().BeTrue(); + + _capabilities.Verify(x => x.DeleteAssignmentsForPoisAsync(DeptId, It.Is>(ids => ids.Single() == Gent), It.IsAny()), Times.Once); + _availabilities.Verify(x => x.DeleteForPoisAsync(DeptId, It.Is>(ids => ids.Single() == Gent), It.IsAny()), Times.Once); + } + + [Test] + public void rules_read_what_a_destination_can_still_take() + { + var now = DateTime.UtcNow; + var limited = new PoiAvailability { PoiId = Gent, Status = (int)PoiAvailabilityStatuses.Limited, UnavailableCapabilityIds = "cath, ,CATH,trauma" }; + var closed = new PoiAvailability { PoiId = Gent, Status = (int)PoiAvailabilityStatuses.Closed }; + var diversion = new PoiAvailability { PoiId = Gent, Status = (int)PoiAvailabilityStatuses.Diversion }; + + limited.GetUnavailableCapabilityIds().Should().Equal("cath", "trauma"); + PoiAvailabilityRules.CanProvide(limited, "Cath", now).Should().BeFalse(); + PoiAvailabilityRules.CanProvide(limited, "stroke", now).Should().BeTrue(); + PoiAvailabilityRules.CanProvide(limited, null, now).Should().BeTrue(); + PoiAvailabilityRules.CanProvide(closed, null, now).Should().BeFalse(); + PoiAvailabilityRules.CanProvide(diversion, "cath", now).Should().BeTrue(); + PoiAvailabilityRules.CanProvide(null, "cath", now).Should().BeTrue(); + PoiAvailabilityRules.StatusOf(new PoiAvailability { Status = 99 }, now).Should().Be(PoiAvailabilityStatuses.Open); + PoiAvailabilityRules.StatusOf(new PoiAvailability { Status = 3, ExpiresOn = now.AddMinutes(-1) }, now).Should().Be(PoiAvailabilityStatuses.Open); + } + } + + /// "We request transport to the nearest cath lab": destinations ranked from a point. + [TestFixture] + public class NearestDestinationServiceTests + { + private const int DeptId = 7; + + private Mock _mapping; + private Mock _geo; + private Mock _units; + private NearestDestinationService _service; + private List _pois; + + // Origin near Zottegem; distances grow with the latitude offset. + private const double OriginLat = 50.80; + private const double OriginLon = 3.80; + + [SetUp] + public void SetUp() + { + _mapping = new Mock(); + _geo = new Mock(); + _units = new Mock(); + var type = new PoiType { PoiTypeId = 3, DepartmentId = DeptId, Name = "Hospital", IsDestination = true }; + + _pois = new List + { + Hospital(1, "Near, stroke only", 0.01, type, "stroke"), + Hospital(2, "Second, cath", 0.02, type, "cath"), + Hospital(3, "Third, cath on diversion", 0.03, type, "cath"), + Hospital(4, "Fourth, cath closed", 0.04, type, "cath"), + Hospital(5, "Far, cath", 0.50, type, "cath"), + Hospital(6, "No position", 0, type, "cath") + }; + _pois[5].Latitude = 0; + _pois[5].Longitude = 0; + + _mapping.Setup(x => x.GetDestinationPOIsForDepartmentAsync(DeptId)).ReturnsAsync(() => _pois); + _mapping.Setup(x => x.PopulatePoiDetailsAsync(DeptId, It.IsAny>())).Returns(Task.CompletedTask); + // Routed seconds = 1000 * latitude offset, except hospital 2, which is slow by road. + _geo.Setup(x => x.GetTravelEstimateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string _, string destination, TravelLookup __, CancellationToken ___) => + { + var lat = double.Parse(destination.Split(',')[0], System.Globalization.CultureInfo.InvariantCulture); + var seconds = Math.Round((lat - OriginLat) * 100000); + return new TravelEstimate { Seconds = Math.Abs(lat - (OriginLat + 0.02)) < 0.0001 ? 9000 : seconds, Source = EtaSources.Road }; + }); + + _service = new NearestDestinationService(_mapping.Object, _geo.Object, _units.Object); + } + + private static Poi Hospital(int id, string name, double latitudeOffset, PoiType type, params string[] capabilities) => new Poi + { + PoiId = id, + PoiTypeId = type.PoiTypeId, + Type = type, + Name = name, + Latitude = OriginLat + latitudeOffset, + Longitude = OriginLon, + CapabilityIds = capabilities.ToList(), + Capabilities = capabilities.ToList() + }; + + [Test] + public async Task the_nearest_cath_labs_are_ranked_by_road_with_diversion_and_closed_after_them() + { + _pois[2].Availability = new PoiAvailability { PoiId = 3, Status = (int)PoiAvailabilityStatuses.Diversion, SetOn = DateTime.UtcNow }; + _pois[3].Availability = new PoiAvailability { PoiId = 4, Status = (int)PoiAvailabilityStatuses.Closed, SetOn = DateTime.UtcNow }; + + var ranked = await _service.GetNearestDestinationsAsync(DeptId, OriginLat, OriginLon, "CATH", null, 10); + + // Open first by road time (2 at 9000 s before 5 at 50000 s), then diversion (3), then closed (4). + ranked.Select(x => x.Poi.PoiId).Should().Equal(2, 5, 3, 4); + ranked[0].TravelSeconds.Should().Be(9000); + ranked.Single(x => x.Poi.PoiId == 3).Status.Should().Be(PoiAvailabilityStatuses.Diversion); + ranked.Single(x => x.Poi.PoiId == 4).CanProvide.Should().BeFalse(); + } + + [Test] + public async Task a_capability_reported_down_ranks_with_the_closed_ones() + { + _pois[1].Availability = new PoiAvailability { PoiId = 2, Status = (int)PoiAvailabilityStatuses.Limited, UnavailableCapabilityIds = "cath", SetOn = DateTime.UtcNow }; + + var ranked = await _service.GetNearestDestinationsAsync(DeptId, OriginLat, OriginLon, "cath", null, 10); + + ranked.Last().Poi.PoiId.Should().Be(2); + ranked.Last().CanProvide.Should().BeFalse(); + } + + [Test] + public async Task without_a_capability_every_destination_with_a_position_is_ranked() + { + var ranked = await _service.GetNearestDestinationsAsync(DeptId, OriginLat, OriginLon, null, null, 10); + + ranked.Select(x => x.Poi.PoiId).Should().BeEquivalentTo(new[] { 1, 2, 3, 4, 5 }); + ranked.First().Poi.PoiId.Should().Be(1); + } + + [Test] + public async Task only_the_shortlist_is_routed_and_the_rest_rank_by_distance() + { + for (var i = 0; i < 10; i++) + _pois.Add(Hospital(100 + i, "Extra " + i, 1 + i * 0.1, _pois[0].Type, "cath")); + + var ranked = await _service.GetNearestDestinationsAsync(DeptId, OriginLat, OriginLon, "cath", null, 50); + + _geo.Verify(x => x.GetTravelEstimateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Exactly(INearestDestinationService.DefaultShortlistSize)); + ranked.Where(x => x.TravelSeconds < 0).Select(x => x.DistanceMeters).Should().BeInAscendingOrder(); + } + + [Test] + public async Task a_failed_route_falls_back_to_distance() + { + _geo.Setup(x => x.GetTravelEstimateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("router down")); + + var ranked = await _service.GetNearestDestinationsAsync(DeptId, OriginLat, OriginLon, "cath", null, 3); + + ranked.Select(x => x.Poi.PoiId).Should().Equal(2, 3, 4); + ranked.Should().OnlyContain(x => x.TravelSeconds < 0); + } + + [TestCase(0, 0)] + [TestCase(91, 3)] + public async Task an_unusable_origin_returns_nothing(double latitude, double longitude) + { + (await _service.GetNearestDestinationsAsync(DeptId, latitude, longitude, null, null, 10)).Should().BeEmpty(); + } + + [Test] + public async Task the_units_own_travel_is_used_for_routing() + { + var unit = new Unit { UnitId = 12, DepartmentId = DeptId }; + var travel = new UnitTravelProfile(UnitTravelModes.Walking, 5); + _units.Setup(x => x.GetUnitByIdAsync(12)).ReturnsAsync(unit); + _units.Setup(x => x.GetTravelProfilesForUnitsAsync(DeptId, It.IsAny>())).ReturnsAsync(new Dictionary { { 12, travel } }); + + await _service.GetNearestDestinationsAsync(DeptId, OriginLat, OriginLon, "cath", 12, 3); + + _geo.Verify(x => x.GetTravelEstimateAsync(It.IsAny(), It.IsAny(), It.Is(l => l.Travel == travel && l.DepartmentId == DeptId), It.IsAny()), Times.AtLeastOnce); + _units.Verify(x => x.GetVehicleProfilesForUnitsAsync(It.IsAny(), It.IsAny>()), Times.Never, "only driving units carry vehicle limits"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/PoiImportServiceTests.cs b/Tests/Resgrid.Tests/Services/PoiImportServiceTests.cs index 215a0c6f5..dba59bd4c 100644 --- a/Tests/Resgrid.Tests/Services/PoiImportServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/PoiImportServiceTests.cs @@ -555,5 +555,158 @@ public async Task A_stopping_worker_releases_the_import_so_the_next_one_resumes_ } #endregion Worker + + #region Capabilities + + // The Belgian EMS sheet of hospitals: what each one offers in a "Capabilities" column, separated by ; , or |. + [Test] + public void Reads_a_capabilities_column_split_on_semicolons_commas_and_bars() + { + var csv = + "Name,Latitude,Longitude,Capabilities\r\n" + + "UZ Gent,51.0234,3.7262,\"Cath Lab; Stroke Center, Trauma Center | cath lab\"\r\n" + + "AZ Alma,51.1854,3.5634,\r\n" + + "OLV Aalst,50.9364,4.0390,\" ; | \"\r\n"; + + var file = Service().ReadFile(Text(csv), "hospitals.csv"); + + file.Error.Should().BeNull(); + file.Rows[0].Capabilities.Should().Equal(new[] { "Cath Lab", "Stroke Center", "Trauma Center" }, "names are trimmed and a repeat (in any case) is dropped"); + file.Rows[1].Capabilities.Should().BeNull("a blank cell names none"); + file.Rows[2].Capabilities.Should().BeNull("separators alone name none"); + } + + [TestCase("Capability")] + [TestCase("Specialties")] + [TestCase("Specialities")] + [TestCase("Services")] + [TestCase("capabilities")] + public void Recognizes_the_capabilities_column_by_its_other_names(string header) + { + var file = Service().ReadFile(Text("Name;Lat;Lng;" + header + "\nUZ Gent;51,0234;3,7262;Cath Lab|Burns\n"), "hospitals.csv"); + + file.Rows.Single().Capabilities.Should().Equal("Cath Lab", "Burns"); + } + + [Test] + public void Cuts_a_capability_name_to_the_column_length() + { + var file = Service().ReadFile(Text("Name,Lat,Lng,Capabilities\nSite,41.5,-93.6," + new string('x', PoiCapability.MaxNameLength + 20) + "\n"), "sites.csv"); + + file.Rows.Single().Capabilities.Single().Should().HaveLength(PoiCapability.MaxNameLength); + } + + [Test] + public async Task Rows_without_the_column_are_queued_as_before() + { + var file = Service().ReadFile(Text("Name,Lat,Lng\nTank 7,41.59,-93.62\n"), "tanks.csv"); + + await Service().QueueImportAsync(DepartmentId, PoiTypeId, "Tanks", "user-1", "tanks.csv", file); + + file.Rows.Single().Capabilities.Should().BeNull(); + _writes.Single().RowsJson.Should().NotContain("Capabilities", "a file without the column stores the rows it always did"); + } + + [Test] + public void Reads_capabilities_from_kml_extended_data_with_or_without_a_point() + { + var kml = + "" + + "UZ GentCath Lab; Stroke Center" + + "3.7262,51.0234" + + "AZ Alma3.5634,51.1854" + + "OLV AalstMoorselbaan 164" + + "AalstTrauma Center" + + ""; + + var file = Service().ReadFile(Text(kml), "hospitals.kml"); + + file.Error.Should().BeNull(); + file.Rows.Single(r => r.Name == "UZ Gent").Capabilities.Should().Equal("Cath Lab", "Stroke Center"); + file.Rows.Single(r => r.Name == "AZ Alma").Capabilities.Should().BeNull(); + var aalst = file.Rows.Single(r => r.Name == "OLV Aalst"); + aalst.HasPosition.Should().BeFalse(); + aalst.Capabilities.Should().Equal("Trauma Center"); + } + + /// Saved POIs get ids (100 + their row), as the database would give them. + private void SavedPoisGetIds() + { + _mapping.Setup(x => x.SavePOIAsync(It.IsAny(), It.IsAny())) + .Callback((Poi p, CancellationToken _) => _saved.Add(p)) + .ReturnsAsync((Poi p, CancellationToken _) => { p.PoiId = 100 + (p.PoiImportRow ?? 0); return p; }); + } + + /// The department already has "Cath Lab"; any other name asked for is added with a new id. + private List> CapabilitiesAddedOrFound() + { + var asked = new List>(); + _mapping.Setup(x => x.GetOrAddPoiCapabilitiesByNameAsync(DepartmentId, It.IsAny>(), It.IsAny())) + .ReturnsAsync((int _, IEnumerable names, CancellationToken __) => + { + asked.Add(names.ToList()); + return names.Select(n => string.Equals(n, "cath lab", StringComparison.OrdinalIgnoreCase) + ? new PoiCapability { PoiCapabilityId = "cath", DepartmentId = DepartmentId, Name = "Cath Lab" } + : new PoiCapability { PoiCapabilityId = "new-" + n.ToLowerInvariant().Replace(' ', '-'), DepartmentId = DepartmentId, Name = n }).ToList(); + }); + return asked; + } + + [Test] + public async Task Saving_tags_each_poi_with_its_capabilities_adding_the_missing_ones_once_per_run() + { + SavedPoisGetIds(); + var asked = CapabilitiesAddedOrFound(); + Runnable(PoiImportStatus.Saving, 1, + new PoiImportRow { Name = "UZ Gent", Latitude = 51.0234, Longitude = 3.7262, Capabilities = new List { "CATH LAB", "Stroke Center" } }, + new PoiImportRow { Name = "AZ Alma", Latitude = 51.1854, Longitude = 3.5634 }, + new PoiImportRow { Name = "OLV Aalst", Latitude = 50.9364, Longitude = 4.0390, Capabilities = new List { "Stroke Center", "Trauma Center" } }); + + await Service().ProcessNextAsync(); + + Final.Status.Should().Be((int)PoiImportStatus.Complete); + asked.Should().ContainSingle("the names are matched once per run, not per row"); + asked[0].Should().BeEquivalentTo(new[] { "CATH LAB", "Stroke Center", "Trauma Center" }); + _mapping.Verify(x => x.SetPoiCapabilitiesAsync(DepartmentId, 100, It.Is>(ids => ids.SequenceEqual(new[] { "cath", "new-stroke-center" })), It.IsAny()), Times.Once); + _mapping.Verify(x => x.SetPoiCapabilitiesAsync(DepartmentId, 102, It.Is>(ids => ids.SequenceEqual(new[] { "new-stroke-center", "new-trauma-center" })), It.IsAny()), Times.Once); + _mapping.Verify(x => x.SetPoiCapabilitiesAsync(It.IsAny(), 101, It.IsAny>(), It.IsAny()), Times.Never, "a row without capabilities gets none written"); + } + + [Test] + public async Task An_import_without_capabilities_never_touches_them() + { + SavedPoisGetIds(); + Runnable(PoiImportStatus.Saving, 1, + new PoiImportRow { Name = "Tank 7", Latitude = 41.59, Longitude = -93.62 }, + new PoiImportRow { Name = "Tank 8", Latitude = 41.6, Longitude = -93.6 }); + + await Service().ProcessNextAsync(); + + _saved.Should().HaveCount(2); + _mapping.Verify(x => x.GetOrAddPoiCapabilitiesByNameAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + _mapping.Verify(x => x.SetPoiCapabilitiesAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task A_resumed_import_sets_the_capabilities_of_the_poi_it_saved_last_again() + { + // The worker stopped after saving row 0's POI (id 100), before its capabilities were written. + SavedPoisGetIds(); + CapabilitiesAddedOrFound(); + _saved.Add(new Poi { PoiId = 100, Name = "UZ Gent", PoiImportId = ImportId, PoiImportRow = 0 }); + _mapping.Setup(x => x.GetTypeByIdAsync(PoiTypeId)).ReturnsAsync(new PoiType { PoiTypeId = PoiTypeId, DepartmentId = DepartmentId, Pois = _saved.ToList() }); + Runnable(PoiImportStatus.Saving, 2, + new PoiImportRow { Name = "UZ Gent", Latitude = 51.0234, Longitude = 3.7262, Capabilities = new List { "Cath Lab" } }, + new PoiImportRow { Name = "OLV Aalst", Latitude = 50.9364, Longitude = 4.0390, Capabilities = new List { "Trauma Center" } }); + + await Service().ProcessNextAsync(); + + _saved.Select(p => p.Name).Should().Equal(new[] { "UZ Gent", "OLV Aalst" }, "row 0 is not saved a second time"); + _mapping.Verify(x => x.SetPoiCapabilitiesAsync(DepartmentId, 100, It.Is>(ids => ids.SequenceEqual(new[] { "cath" })), It.IsAny()), Times.Once); + _mapping.Verify(x => x.SetPoiCapabilitiesAsync(DepartmentId, 101, It.Is>(ids => ids.SequenceEqual(new[] { "new-trauma-center" })), It.IsAny()), Times.Once); + Final.Status.Should().Be((int)PoiImportStatus.Complete); + } + + #endregion Capabilities } } diff --git a/Tests/Resgrid.Tests/Services/TransportDestinationCheckServiceTests.cs b/Tests/Resgrid.Tests/Services/TransportDestinationCheckServiceTests.cs new file mode 100644 index 000000000..3781100eb --- /dev/null +++ b/Tests/Resgrid.Tests/Services/TransportDestinationCheckServiceTests.cs @@ -0,0 +1,416 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Belgian EMS, 2026-11-13: a call's assigned destination (the hospital) is pre-selected for transport statuses, and a + /// unit heading somewhere else is recorded on the call when the department turned transport destination checks on + /// (DepartmentSettingTypes.TransportDestinationChecks). + /// + [TestFixture] + public class TransportDestinationCheckServiceTests + { + private const int DeptId = 7; + private const int UnitId = 5; + private const int CallId = 40; + private const int AssignedPoi = 900; + private const int OtherPoi = 901; + + private const int Departing = 100; // Transporting + private const int AtHospital = 101; // At Hospital + private const int OnScene = 102; // On Scene + private const int NoMeaning = 103; // no base type + + private Mock _settings; + private Mock _attribution; + private Mock _calls; + private Mock _mapping; + private Mock _units; + private Mock _customStates; + private TransportDestinationCheckService _service; + + private Call _call; + private CallNote _savedNote; + + [SetUp] + public void SetUp() + { + _settings = new Mock(); + _attribution = new Mock(); + _calls = new Mock(); + _mapping = new Mock(); + _units = new Mock(); + _customStates = new Mock(); + _savedNote = null; + + _call = new Call { CallId = CallId, DepartmentId = DeptId, State = (int)CallStates.Active, DestinationPoiId = AssignedPoi, ReportingUserId = "reporter" }; + + _settings.Setup(x => x.GetTransportDestinationChecksAsync(DeptId, It.IsAny())).ReturnsAsync(true); + _customStates.Setup(x => x.GetDefaultUnitStatuses()).Returns(new CustomStateService(null, null, null, null).GetDefaultUnitStatuses()); + _customStates.Setup(x => x.GetAllCustomStatesForDepartmentAsync(DeptId)).ReturnsAsync(new List + { + new CustomState + { + CustomStateId = 1, + DepartmentId = DeptId, + Type = (int)CustomStateTypes.Unit, + Details = new List + { + new CustomStateDetail { CustomStateDetailId = Departing, ButtonText = "Vertrek ZH", BaseType = (int)ActionBaseTypes.Transporting, DetailType = (int)CustomStateDetailTypes.CallsAndPois }, + new CustomStateDetail { CustomStateDetailId = AtHospital, ButtonText = "Aankomst ZH", BaseType = (int)ActionBaseTypes.AtHospital, DetailType = (int)CustomStateDetailTypes.Pois }, + new CustomStateDetail { CustomStateDetailId = OnScene, ButtonText = "Ter plaatse", BaseType = (int)ActionBaseTypes.OnScene, DetailType = (int)CustomStateDetailTypes.CallsAndPois }, + new CustomStateDetail { CustomStateDetailId = NoMeaning, ButtonText = "Elders", BaseType = (int)ActionBaseTypes.None, DetailType = (int)CustomStateDetailTypes.Pois } + } + } + }); + _attribution.Setup(x => x.GetWorkingCallIdForUnitAsync(DeptId, UnitId, It.IsAny())).ReturnsAsync(CallId); + _calls.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(() => _call); + _calls.Setup(x => x.SaveCallNoteAsync(It.IsAny(), It.IsAny())) + .Callback((CallNote note, CancellationToken _) => _savedNote = note) + .ReturnsAsync((CallNote note, CancellationToken _) => note); + _mapping.Setup(x => x.GetDestinationPOIByIdAsync(DeptId, AssignedPoi)).ReturnsAsync(new Poi { PoiId = AssignedPoi, Name = "AZ Sint-Elisabeth Zottegem" }); + _mapping.Setup(x => x.GetDestinationPOIByIdAsync(DeptId, OtherPoi)).ReturnsAsync(new Poi { PoiId = OtherPoi, Name = "UZ Gent" }); + _units.Setup(x => x.GetUnitByIdAsync(UnitId)).ReturnsAsync(new Unit { UnitId = UnitId, DepartmentId = DeptId, Name = "ZW Herzele" }); + + _service = new TransportDestinationCheckService(_settings.Object, _attribution.Object, _calls.Object, _mapping.Object, _units.Object, _customStates.Object); + } + + private static UnitState State(int status, int? poiId, string note = "STEMI, cath lab", int? destinationType = (int)DestinationEntityTypes.Poi) => new UnitState + { + UnitStateId = 300, + UnitId = UnitId, + State = status, + DestinationId = poiId, + DestinationType = poiId.HasValue ? destinationType : null, + Note = note, + SetByUserId = "dispatcher", + Timestamp = DateTime.UtcNow + }; + + private static UnitState OnSceneAtCall() => new UnitState + { + UnitStateId = 299, + UnitId = UnitId, + State = OnScene, + DestinationId = CallId, + DestinationType = (int)DestinationEntityTypes.Call + }; + + [Test] + public async Task a_transport_status_to_another_hospital_is_noted_on_the_call_with_both_destinations_and_the_reason() + { + var note = await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), OnSceneAtCall()); + + note.Should().NotBeNull(); + _savedNote.CallId.Should().Be(CallId); + _savedNote.UserId.Should().Be("dispatcher"); + _savedNote.Source.Should().Be((int)CallNoteSources.System); + _savedNote.Note.Should().Be("ZW Herzele set Vertrek ZH to UZ Gent, not the call's assigned destination AZ Sint-Elisabeth Zottegem. Reason: STEMI, cath lab"); + _attribution.Verify(x => x.GetWorkingCallIdForUnitAsync(DeptId, UnitId, It.Is(s => s.UnitStateId == 299)), Times.Once); + } + + [Test] + public async Task at_hospital_counts_as_a_transport_status() + { + await _service.OnUnitStateSavedAsync(DeptId, State(AtHospital, OtherPoi), OnSceneAtCall()); + + _savedNote.Should().NotBeNull(); + _savedNote.Note.Should().StartWith("ZW Herzele set Aankomst ZH to UZ Gent"); + } + + [Test] + public async Task a_deviation_without_a_reason_says_so() + { + await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi, note: " "), OnSceneAtCall()); + + _savedNote.Note.Should().EndWith("No reason given."); + } + + [Test] + public async Task a_protected_status_note_is_not_copied_to_the_call() + { + var state = State(Departing, OtherPoi); + state.IsProtected = true; + state.Note = "rgdp:envelope"; + + await _service.OnUnitStateSavedAsync(DeptId, state, OnSceneAtCall()); + + _savedNote.Note.Should().NotContain("rgdp"); + _savedNote.Note.Should().Contain("recorded on the unit's status"); + } + + [Test] + public async Task the_reporting_user_signs_the_note_when_the_status_has_no_submitter() + { + var state = State(Departing, OtherPoi); + state.SetByUserId = null; + + await _service.OnUnitStateSavedAsync(DeptId, state, OnSceneAtCall()); + + _savedNote.UserId.Should().Be("reporter"); + } + + [Test] + public async Task the_assigned_hospital_is_not_a_deviation() + { + (await _service.OnUnitStateSavedAsync(DeptId, State(Departing, AssignedPoi), OnSceneAtCall())).Should().BeNull(); + + _calls.Verify(x => x.SaveCallNoteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task nothing_is_recorded_when_the_department_has_the_checks_off() + { + _settings.Setup(x => x.GetTransportDestinationChecksAsync(DeptId, It.IsAny())).ReturnsAsync(false); + + (await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), OnSceneAtCall())).Should().BeNull(); + + _calls.Verify(x => x.SaveCallNoteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task a_call_without_an_assigned_destination_has_nothing_to_deviate_from() + { + _call.DestinationPoiId = null; + + (await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), OnSceneAtCall())).Should().BeNull(); + } + + [TestCase(OnScene)] + [TestCase(NoMeaning)] + [TestCase((int)UnitStateTypes.Responding)] + public async Task only_transporting_and_at_hospital_base_types_are_checked(int status) + { + (await _service.OnUnitStateSavedAsync(DeptId, State(status, OtherPoi), OnSceneAtCall())).Should().BeNull(); + + _calls.Verify(x => x.SaveCallNoteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestCase((int)DestinationEntityTypes.Station)] + [TestCase((int)DestinationEntityTypes.Call)] + public async Task a_station_or_call_destination_is_never_checked(int destinationType) + { + (await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi, destinationType: destinationType), OnSceneAtCall())).Should().BeNull(); + + _settings.Verify(x => x.GetTransportDestinationChecksAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task arriving_at_the_hospital_the_unit_already_headed_to_is_not_noted_twice() + { + var departing = State(Departing, OtherPoi); + departing.UnitStateId = 299; + + (await _service.OnUnitStateSavedAsync(DeptId, State(AtHospital, OtherPoi), departing)).Should().BeNull(); + + _settings.Verify(x => x.GetTransportDestinationChecksAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task changing_hospital_mid_transport_is_noted_again() + { + var departing = State(Departing, OtherPoi); + departing.UnitStateId = 299; + _mapping.Setup(x => x.GetDestinationPOIByIdAsync(DeptId, 902)).ReturnsAsync(new Poi { PoiId = 902, Name = "UZ Brussel" }); + + await _service.OnUnitStateSavedAsync(DeptId, State(Departing, 902, note: "Burn centre"), departing); + + _savedNote.Note.Should().Contain("to UZ Brussel").And.EndWith("Reason: Burn centre"); + } + + [Test] + public async Task a_unit_with_no_working_call_is_not_checked() + { + _attribution.Setup(x => x.GetWorkingCallIdForUnitAsync(DeptId, UnitId, It.IsAny())).ReturnsAsync((int?)null); + + (await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), null)).Should().BeNull(); + } + + [Test] + public async Task without_a_previous_status_the_working_call_is_resolved_from_the_new_one() + { + await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), null); + + _attribution.Verify(x => x.GetWorkingCallIdForUnitAsync(DeptId, UnitId, It.Is(s => s.UnitStateId == 300)), Times.Once); + _savedNote.Should().NotBeNull(); + } + + [TestCase(CallStates.Closed, DeptId)] + [TestCase(CallStates.Active, 99)] + public async Task a_closed_call_or_another_departments_call_is_not_noted(CallStates state, int departmentId) + { + _call.State = (int)state; + _call.DepartmentId = departmentId; + + (await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), OnSceneAtCall())).Should().BeNull(); + } + + [Test] + public async Task a_destination_no_longer_listed_is_named_by_its_id() + { + _mapping.Setup(x => x.GetDestinationPOIByIdAsync(DeptId, OtherPoi)).ReturnsAsync((Poi)null); + + await _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), OnSceneAtCall()); + + _savedNote.Note.Should().Contain($"to POI {OtherPoi},"); + } + + [Test] + public async Task a_failure_never_reaches_the_status_save() + { + _calls.Setup(x => x.SaveCallNoteAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("db down")); + + Func act = () => _service.OnUnitStateSavedAsync(DeptId, State(Departing, OtherPoi), OnSceneAtCall()); + + await act.Should().NotThrowAsync(); + } + + [Test] + public void note_text_falls_back_for_a_missing_unit_or_status_name() + { + TransportDestinationCheckService.BuildNoteText(null, " ", "UZ Gent", "AZ Zottegem", null) + .Should().Be("A unit set a transport status to UZ Gent, not the call's assigned destination AZ Zottegem. No reason given."); + } + } + + /// The rules every app shares (). + [TestFixture] + public class TransportDestinationRulesTests + { + [TestCase((int)ActionBaseTypes.Transporting, true)] + [TestCase((int)ActionBaseTypes.AtHospital, true)] + [TestCase((int)ActionBaseTypes.Delivering, false)] + [TestCase((int)ActionBaseTypes.OnScene, false)] + [TestCase((int)ActionBaseTypes.None, false)] + [TestCase(null, false)] + public void transport_base_types(int? baseType, bool expected) + { + TransportDestinationRules.IsTransportBaseType(baseType).Should().Be(expected); + } + + [TestCase(901, (int)DestinationEntityTypes.Poi, 900, true)] + [TestCase(900, (int)DestinationEntityTypes.Poi, 900, false)] + [TestCase(901, (int)DestinationEntityTypes.Station, 900, false)] + [TestCase(901, (int)DestinationEntityTypes.Call, 900, false)] + [TestCase(901, (int)DestinationEntityTypes.Poi, null, false)] + [TestCase(901, (int)DestinationEntityTypes.Poi, 0, false)] + [TestCase(null, (int)DestinationEntityTypes.Poi, 900, false)] + [TestCase(901, null, 900, false)] + public void deviation(int? destinationId, int? destinationType, int? assigned, bool expected) + { + TransportDestinationRules.IsDeviation(destinationId, destinationType, assigned).Should().Be(expected); + } + + [Test] + public void assigned_destination_ignores_zero_and_missing() + { + TransportDestinationRules.AssignedDestinationPoiId(new Call { DestinationPoiId = 0 }).Should().BeNull(); + TransportDestinationRules.AssignedDestinationPoiId(new Call()).Should().BeNull(); + TransportDestinationRules.AssignedDestinationPoiId(null).Should().BeNull(); + TransportDestinationRules.AssignedDestinationPoiId(new Call { DestinationPoiId = 12 }).Should().Be(12); + } + } + + /// UnitsService runs the transport destination check after a status a unit reported, never after Resgrid's own. + [TestFixture] + public class UnitsServiceTransportDestinationTests + { + private const int DeptId = 10; + + private Mock _unitStatesRepo; + private Mock _transport; + private UnitsService _service; + + [SetUp] + public void SetUp() + { + _unitStatesRepo = new Mock(); + _unitStatesRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((UnitState s, CancellationToken _, bool __) => s); + + var protectedWrite = new Mock(); + protectedWrite.Setup(x => x.PrepareUnitStateWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + _transport = new Mock(); + + _service = new UnitsService( + Mock.Of(), _unitStatesRepo.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), + new Lazy>(() => Mock.Of>()), + Mock.Of(), + new Lazy(() => Mock.Of()), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), + new Lazy(() => protectedWrite.Object), + new Lazy(() => Mock.Of()), + Mock.Of(), + transportDestinationCheckService: new Lazy(() => _transport.Object)); + } + + private static UnitState PoiState() => new UnitState + { + UnitStateId = 55, + UnitId = 7, + State = 100, + DestinationId = 901, + DestinationType = (int)DestinationEntityTypes.Poi, + Timestamp = DateTime.UtcNow + }; + + [Test] + public async Task a_reported_poi_status_is_checked() + { + var state = PoiState(); + + await _service.SetUnitStateAsync(state, DeptId); + + _transport.Verify(x => x.OnUnitStateSavedAsync(DeptId, state, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task an_automatic_status_is_never_checked() + { + await _service.SetUnitStateAsync(PoiState(), DeptId, autoGenerated: true); + + _transport.Verify(x => x.OnUnitStateSavedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task a_status_without_a_poi_skips_the_check() + { + var state = PoiState(); + state.DestinationType = (int)DestinationEntityTypes.Call; + + await _service.SetUnitStateAsync(state, DeptId); + + _transport.Verify(x => x.OnUnitStateSavedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task a_failing_check_never_fails_the_save() + { + _transport.Setup(x => x.OnUnitStateSavedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + var saved = await _service.SetUnitStateAsync(PoiState(), DeptId); + + saved.Should().NotBeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CallsControllerDispatchListTests.cs b/Tests/Resgrid.Tests/Web/Services/CallsControllerDispatchListTests.cs index ab3df91b8..4f060f962 100644 --- a/Tests/Resgrid.Tests/Web/Services/CallsControllerDispatchListTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/CallsControllerDispatchListTests.cs @@ -24,10 +24,10 @@ namespace Resgrid.Tests.Web.Services { /// - /// The three DispatchList forms on v4 SaveCall and EditCall. A blank list pages everyone on an immediate call (older - /// clients send nothing, so that stays) and "0" pages everyone, so a client whose dispatcher picks no one sends a list - /// with no entries ("|"): the call is created, or edited, with nobody dispatched. The apps (Omni's - /// DISPATCH_LIST_NOBODY) depend on this, so a refactor of the list parsing must keep it. + /// The DispatchList forms on v4 SaveCall and EditCall. Everyone ("All Call") is only ever explicit: "0". A blank list + /// (older apps send one when nothing was picked) or a list with no entries ("|") creates a call with nobody dispatched, + /// and a blank list on an edit keeps the call's recipients. The apps (Omni's DISPATCH_LIST_NOBODY) depend on this, so a + /// refactor of the list parsing must keep it. /// [TestFixture] [NonParallelizable] @@ -163,10 +163,9 @@ private CallsController Controller() private static int DispatchCount(Call call) => (call.Dispatches?.Count ?? 0) + (call.GroupDispatches?.Count ?? 0) + (call.RoleDispatches?.Count ?? 0) + (call.UnitDispatches?.Count ?? 0); - [TestCase(null)] - [TestCase("")] [TestCase("0")] - public async Task SaveCall_pages_everyone_for_a_blank_list_or_zero(string dispatchList) + [TestCase(" 0 ")] + public async Task SaveCall_pages_everyone_only_for_zero(string dispatchList) { await Controller().SaveCall(NewCall(dispatchList), CancellationToken.None); @@ -174,6 +173,68 @@ public async Task SaveCall_pages_everyone_for_a_blank_list_or_zero(string dispat _broadcasts.Should().Be(1); } + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public async Task SaveCall_with_a_blank_list_dispatches_nobody(string dispatchList) + { + // Older apps send a blank list when nothing was picked; it used to page the whole department. + var result = await Controller().SaveCall(NewCall(dispatchList), CancellationToken.None); + + // The broadcast is still queued, as for "|": with no dispatches on the call it pages no one. + result.Result.Should().BeOfType(); + DispatchCount(_saved.Should().ContainSingle().Subject).Should().Be(0); + } + + [Test] + public async Task EditCall_with_a_blank_list_keeps_the_recipients_and_pages_nobody_new() + { + _stored = new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + State = (int)CallStates.Active, + Name = "Fuel spill", + NatureOfCall = "Logged for the record", + HasBeenDispatched = true, + Dispatches = new List { new CallDispatch { CallId = CallId, UserId = "responder-1" } }, + GroupDispatches = new List(), + RoleDispatches = new List(), + UnitDispatches = new List { new CallDispatchUnit { CallId = CallId, UnitId = 7 } } + }; + + var result = await Controller().EditCall(new EditCallInput { Id = CallId.ToString(), Priority = 1, Name = "Fuel spill", Nature = "Logged for the record", DispatchList = "" }, + CancellationToken.None); + + result.Result.Should().BeOfType(); + _stored.Dispatches.Select(x => x.UserId).Should().Equal("responder-1"); + _stored.UnitDispatches.Select(x => x.UnitId).Should().Equal(7); + _broadcasts.Should().Be(0); + } + + [Test] + public async Task EditCall_with_zero_adds_everyone_without_duplicating_who_is_already_on_the_call() + { + _stored = new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + State = (int)CallStates.Active, + Name = "Fuel spill", + NatureOfCall = "Logged for the record", + HasBeenDispatched = true, + Dispatches = new List { new CallDispatch { CallId = CallId, UserId = "responder-1" } }, + GroupDispatches = new List(), + RoleDispatches = new List(), + UnitDispatches = new List() + }; + + await Controller().EditCall(new EditCallInput { Id = CallId.ToString(), Priority = 1, Name = "Fuel spill", Nature = "Logged for the record", DispatchList = "0" }, + CancellationToken.None); + + _stored.Dispatches.Select(x => x.UserId).Should().BeEquivalentTo("responder-1", "responder-2"); + } + [Test] public async Task SaveCall_with_a_list_that_has_no_entries_creates_the_call_with_nobody_dispatched() { @@ -254,5 +315,98 @@ public async Task EditCall_with_a_list_that_has_no_entries_removes_every_dispatc DispatchCount(_stored).Should().Be(0); _broadcasts.Should().Be(0); } + + private void HideDispatchLists(params string[] keys) => + _settings.Setup(x => x.GetNewCallFieldPolicyAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new NewCallFieldPolicy { Rules = keys.Select(k => new NewCallFieldRule { Key = k, Visible = false }).ToList() }); + + [Test] + public async Task EditCall_keeps_the_dispatches_of_a_list_the_department_hides() + { + // The client's edit form has no personnel list, so the list it sends names none: a run card's (or another + // surface's) personnel dispatch stays. A shown list is still replaced by what was sent. + HideDispatchLists(NewCallFieldKeys.DispatchPersonnel); + _stored = new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + State = (int)CallStates.Active, + Name = "Fuel spill", + NatureOfCall = "Logged for the record", + HasBeenDispatched = true, + Dispatches = new List { new CallDispatch { CallId = CallId, UserId = "responder-1" } }, + GroupDispatches = new List { new CallDispatchGroup { CallId = CallId, DepartmentGroupId = 3 } }, + RoleDispatches = new List(), + UnitDispatches = new List() + }; + + var result = await Controller().EditCall(new EditCallInput { Id = CallId.ToString(), Priority = 1, Name = "Fuel spill", Nature = "Logged for the record", DispatchList = "U:7" }, + CancellationToken.None); + + result.Result.Should().BeOfType(); + _stored.Dispatches.Select(x => x.UserId).Should().Equal("responder-1"); + _stored.GroupDispatches.Should().BeEmpty(); + _stored.UnitDispatches.Select(x => x.UnitId).Should().Equal(7); + } + + [Test] + public async Task EditCall_with_every_list_hidden_and_a_list_with_no_entries_keeps_every_dispatch() + { + HideDispatchLists(NewCallFieldKeys.DispatchPersonnel, NewCallFieldKeys.DispatchGroups, NewCallFieldKeys.DispatchRoles, NewCallFieldKeys.DispatchUnits); + _stored = new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + State = (int)CallStates.Active, + Name = "Fuel spill", + NatureOfCall = "Logged for the record", + HasBeenDispatched = true, + Dispatches = new List { new CallDispatch { CallId = CallId, UserId = "responder-1" } }, + GroupDispatches = new List(), + RoleDispatches = new List(), + UnitDispatches = new List { new CallDispatchUnit { CallId = CallId, UnitId = 7 } } + }; + + await Controller().EditCall(new EditCallInput { Id = CallId.ToString(), Priority = 1, Name = "Fuel spill", Nature = "Logged for the record", DispatchList = "|" }, + CancellationToken.None); + + DispatchCount(_stored).Should().Be(2); + } + + [Test] + public async Task SaveCall_holds_an_app_that_sends_a_map_page_to_a_required_one_but_not_an_app_that_has_no_input() + { + _settings.Setup(x => x.GetNewCallFieldPolicyAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new NewCallFieldPolicy { Rules = new List { new NewCallFieldRule { Key = NewCallFieldKeys.MapPage, Visible = true, Required = true } } }); + + var blank = NewCall("|"); + blank.MapPage = ""; + (await Controller().SaveCall(blank, CancellationToken.None)).Result.Should().BeOfType(); + + (await Controller().SaveCall(NewCall("|"), CancellationToken.None)).Result.Should().BeOfType(); + + var withPage = NewCall("|"); + withPage.MapPage = "12-C4"; + (await Controller().SaveCall(withPage, CancellationToken.None)).Result.Should().BeOfType(); + _saved.Last().MapPage.Should().Be("12-C4"); + } + + [Test] + public async Task GetNewCallFieldPolicy_reports_each_list_a_section_hidden_before_still_hides() + { + // Clients read keys as they are: a list with no rule of its own would read as shown. + _settings.Setup(x => x.GetNewCallFieldPolicyAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new NewCallFieldPolicy { Rules = new List { new NewCallFieldRule { Key = NewCallFieldKeys.DispatchList, Visible = false } } }); + + var result = (await Controller().GetNewCallFieldPolicy()).Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + + result.Data.Rules.Where(x => !x.Visible).Select(x => x.Key).Should().BeEquivalentTo( + NewCallFieldKeys.DispatchList, + NewCallFieldKeys.DispatchPersonnel, + NewCallFieldKeys.DispatchGroups, + NewCallFieldKeys.DispatchRoles, + NewCallFieldKeys.DispatchUnits); + } } } diff --git a/Tests/Resgrid.Tests/Web/User/PoiCapabilitiesMvcTests.cs b/Tests/Resgrid.Tests/Web/User/PoiCapabilitiesMvcTests.cs new file mode 100644 index 000000000..96d112ce1 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/PoiCapabilitiesMvcTests.cs @@ -0,0 +1,612 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Localization; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Calls; +using Resgrid.Web.Areas.User.Models.Mapping; +using Resgrid.Web.Helpers; +using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; + +namespace Resgrid.Tests.Web.User +{ + /// + /// Destination capabilities and availability on the web (Belgian EMS request): map administrators manage the department's + /// POI capabilities and tick them on each POI; dispatchers (and map administrators) record a destination's availability; + /// the call form's destination options carry both. Every id posted is checked against the caller's department. + /// + [TestFixture] + [NonParallelizable] + public class PoiCapabilitiesMvcTests + { + private const int DepartmentId = 12; + private const int OtherDepartmentId = 99; + private const string UserId = "dispatcher-1"; + private const int TypeId = 5; + private const int HospitalId = 31; + + private static readonly Claim MapAdmin = new Claim(ResgridClaimTypes.Resources.Department, ResgridClaimTypes.Actions.Update); + private static readonly Claim CallCreator = new Claim(ResgridClaimTypes.Resources.Call, ResgridClaimTypes.Actions.Create); + + private Dictionary _mocks; + + [SetUp] + public void SetUp() + { + _mocks = new Dictionary(); + + Localizer(); + Localizer(); + Localizer(); + + M().Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DepartmentId, Code = "ABCD" }); + M().Setup(x => x.GetDepartmentByUserIdAsync(UserId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DepartmentId, Code = "ABCD" }); + M().Setup(x => x.GetPoiCapabilitiesForDepartmentAsync(DepartmentId)).ReturnsAsync(() => Capabilities()); + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = null; + + private Mock M() where T : class + { + if (!_mocks.TryGetValue(typeof(T), out var mock)) + { + mock = new Mock(); + _mocks[typeof(T)] = mock; + } + + return (Mock)mock; + } + + private void Localizer() + { + M>().Setup(x => x[It.IsAny()]).Returns((string key) => new LocalizedString(key, key)); + } + + private Mock Mapping => M(); + + /// The department's capabilities (a fresh list each time, as the service returns). + private static List Capabilities() => new List + { + new PoiCapability { PoiCapabilityId = "cath", DepartmentId = DepartmentId, Name = "Cath Lab", Sort = 1 }, + new PoiCapability { PoiCapabilityId = "stroke", DepartmentId = DepartmentId, Name = "Stroke Center", Sort = 2 }, + new PoiCapability { PoiCapabilityId = "trauma", DepartmentId = DepartmentId, Name = "Trauma Center", Sort = 3 } + }; + + /// Builds a controller from loose mocks (the configured ones where a test set them up) and a signed-in member. + private T Build(params Claim[] claims) where T : Controller + { + var constructor = typeof(T).GetConstructors().OrderByDescending(c => c.GetParameters().Length).First(); + var arguments = constructor.GetParameters().Select(p => + { + if (!_mocks.TryGetValue(p.ParameterType, out var mock)) + { + mock = (Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(p.ParameterType)); + _mocks[p.ParameterType] = mock; + } + + return mock.Object; + }).ToArray(); + + var identity = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }.Concat(claims), "test"); + var http = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + + var controller = (T)constructor.Invoke(arguments); + controller.ControllerContext = new ControllerContext { HttpContext = http }; + controller.TempData = new TempDataDictionary(http, Mock.Of()); + return controller; + } + + private static void ShouldBeRefused(IActionResult result) => + result.Should().BeOfType().Which.Url.Should().Be("/Public/Unauthorized"); + + /// A destination POI of type 5 ("Hospitals"), as GetDestinationPOIByIdAsync returns it (department-scoped). + private Poi Hospital(PoiAvailability availability = null, params string[] capabilityIds) + { + var type = new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId, Name = "Hospitals", IsDestination = true }; + var poi = new Poi { PoiId = HospitalId, PoiTypeId = TypeId, Name = "UZ Gent", Address = "Corneel Heymanslaan 10", Type = type }; + Mapping.Setup(x => x.GetDestinationPOIByIdAsync(DepartmentId, HospitalId)).ReturnsAsync(poi); + Mapping.Setup(x => x.PopulatePoiDetailsAsync(DepartmentId, It.IsAny>())) + .Callback((int _, IEnumerable pois) => + { + foreach (var p in pois.Where(p => p.PoiId == HospitalId)) + { + p.CapabilityIds = capabilityIds.ToList(); + p.Capabilities = Capabilities().Where(c => capabilityIds.Contains(c.PoiCapabilityId)).Select(c => c.Name).ToList(); + p.Availability = availability; + } + }) + .Returns(Task.CompletedTask); + return poi; + } + + #region Anti-forgery + + [TestCase("SavePoiCapability")] + [TestCase("DeletePoiCapability")] + [TestCase("SetPoiAvailability")] + public void State_changing_actions_are_post_with_antiforgery(string action) + { + var posts = typeof(MappingController).GetMethods().Where(m => m.Name == action && m.GetCustomAttribute() != null).ToList(); + + posts.Should().NotBeEmpty(); + posts.Should().OnlyContain(m => m.GetCustomAttribute() != null); + if (action != "SetPoiAvailability") + typeof(MappingController).GetMethods().Where(m => m.Name == action).Should().OnlyContain(m => m.GetCustomAttribute() == null, "it changes state"); + } + + #endregion Anti-forgery + + #region Capabilities page + + [Test] + public async Task The_capabilities_page_is_for_map_administrators_only() + { + var dispatcher = Build(CallCreator); + + ShouldBeRefused(await dispatcher.PoiCapabilities()); + ShouldBeRefused(await dispatcher.SavePoiCapability(null, "Cath Lab", null, CancellationToken.None)); + ShouldBeRefused(await dispatcher.DeletePoiCapability("cath", CancellationToken.None)); + + Mapping.Verify(x => x.SavePoiCapabilityAsync(It.IsAny(), It.IsAny()), Times.Never); + Mapping.Verify(x => x.DeletePoiCapabilityAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task The_capabilities_page_lists_them_with_how_many_POIs_offer_each() + { + var pois = new List { new Poi { PoiId = 1 }, new Poi { PoiId = 2 }, new Poi { PoiId = 3 } }; + Mapping.Setup(x => x.GetPOIsForDepartmentAsync(DepartmentId)).ReturnsAsync(pois); + Mapping.Setup(x => x.PopulatePoiDetailsAsync(DepartmentId, pois)).Callback(() => + { + pois[0].CapabilityIds = new List { "cath", "trauma" }; + pois[1].CapabilityIds = new List { "cath" }; + pois[2].CapabilityIds = new List(); + }).Returns(Task.CompletedTask); + + var model = (await Build(MapAdmin).PoiCapabilities()).Should().BeOfType().Which.Model.Should().BeOfType().Which; + + model.Capabilities.Select(c => c.Name).Should().Equal("Cath Lab", "Stroke Center", "Trauma Center"); + model.PoiCounts["cath"].Should().Be(2); + model.PoiCounts["trauma"].Should().Be(1); + model.PoiCounts.ContainsKey("stroke").Should().BeFalse(); + } + + [Test] + public async Task Adding_a_capability_always_creates_one_of_the_callers_department() + { + var controller = Build(MapAdmin); + + var result = await controller.SavePoiCapability(null, " Burns Unit ", null, CancellationToken.None); + + result.Should().BeOfType().Which.ActionName.Should().Be("PoiCapabilities"); + Mapping.Verify(x => x.SavePoiCapabilityAsync(It.Is(c => c.PoiCapabilityId == null && c.DepartmentId == DepartmentId && c.Name == "Burns Unit" && c.Sort == 0), + It.IsAny()), Times.Once); + controller.TempData["PoiCapabilitiesMessage"].Should().Be("PoiCapabilitySaved"); + } + + [Test] + public async Task Renaming_and_resorting_changes_only_the_departments_capability() + { + await Build(MapAdmin).SavePoiCapability("cath", "Cardiac Cath Lab", 7, CancellationToken.None); + + Mapping.Verify(x => x.SavePoiCapabilityAsync(It.Is(c => c.PoiCapabilityId == "cath" && c.DepartmentId == DepartmentId && c.Name == "Cardiac Cath Lab" && c.Sort == 7), + It.IsAny()), Times.Once); + } + + [Test] + public async Task Another_departments_capability_cannot_be_renamed_or_deleted() + { + var controller = Build(MapAdmin); + + (await controller.SavePoiCapability("foreign-capability", "Mine now", 1, CancellationToken.None)).Should().BeOfType(); + (await controller.DeletePoiCapability("foreign-capability", CancellationToken.None)).Should().BeOfType(); + + Mapping.Verify(x => x.SavePoiCapabilityAsync(It.IsAny(), It.IsAny()), Times.Never); + Mapping.Verify(x => x.DeletePoiCapabilityAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestCase(" ", "PoiCapabilityNameRequired")] + [TestCase("cath lab", "PoiCapabilityNameExists")] + public async Task A_blank_or_duplicate_name_is_refused_with_a_message(string name, string error) + { + var controller = Build(MapAdmin); + + (await controller.SavePoiCapability(null, name, null, CancellationToken.None)).Should().BeOfType(); + + controller.TempData["PoiCapabilitiesError"].Should().Be(error); + Mapping.Verify(x => x.SavePoiCapabilityAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task A_name_longer_than_the_column_is_refused() + { + var controller = Build(MapAdmin); + + await controller.SavePoiCapability(null, new string('x', PoiCapability.MaxNameLength + 1), null, CancellationToken.None); + + controller.TempData["PoiCapabilitiesError"].Should().Be("PoiCapabilityNameRequired"); + Mapping.Verify(x => x.SavePoiCapabilityAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Keeping_a_capabilitys_own_name_while_resorting_is_not_a_duplicate() + { + var controller = Build(MapAdmin); + + await controller.SavePoiCapability("cath", "Cath Lab", 9, CancellationToken.None); + + Mapping.Verify(x => x.SavePoiCapabilityAsync(It.Is(c => c.PoiCapabilityId == "cath" && c.Sort == 9), It.IsAny()), Times.Once); + } + + [Test] + public async Task A_save_the_service_refuses_is_a_page_message() + { + Mapping.Setup(x => x.SavePoiCapabilityAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new ArgumentException("A capability named \"Burns\" already exists.")); + var controller = Build(MapAdmin); + + (await controller.SavePoiCapability(null, "Burns", null, CancellationToken.None)).Should().BeOfType(); + + controller.TempData["PoiCapabilitiesError"].Should().Be("PoiCapabilitySaveFailed"); + } + + [Test] + public async Task Deleting_a_capability_is_scoped_to_the_department() + { + Mapping.Setup(x => x.GetPoiCapabilityByIdAsync(DepartmentId, "cath")).ReturnsAsync(Capabilities()[0]); + var controller = Build(MapAdmin); + + (await controller.DeletePoiCapability("cath", CancellationToken.None)).Should().BeOfType().Which.ActionName.Should().Be("PoiCapabilities"); + + Mapping.Verify(x => x.DeletePoiCapabilityAsync(DepartmentId, "cath", It.IsAny()), Times.Once); + controller.TempData["PoiCapabilitiesMessage"].Should().Be("PoiCapabilityDeleted"); + } + + #endregion Capabilities page + + #region Add / edit POI + + [Test] + public async Task Add_POI_offers_the_departments_capabilities() + { + Mapping.Setup(x => x.GetTypeByIdAsync(TypeId)).ReturnsAsync(new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId }); + + var model = (await Build(MapAdmin).AddPOI(TypeId)).Should().BeOfType().Which.Model.Should().BeOfType().Which; + + model.Capabilities.Select(c => c.PoiCapabilityId).Should().Equal("cath", "stroke", "trauma"); + model.SelectedCapabilityIds.Should().BeEmpty(); + } + + [Test] + public async Task Adding_a_POI_tags_the_saved_POI_with_the_ticked_capabilities() + { + Mapping.Setup(x => x.GetTypeByIdAsync(TypeId)).ReturnsAsync(new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId }); + Mapping.Setup(x => x.SavePOIAsync(It.IsAny(), It.IsAny())).ReturnsAsync((Poi p, CancellationToken _) => { p.PoiId = 501; return p; }); + + await Build(MapAdmin).AddPOI(new AddPOIView { TypeId = TypeId, Poi = new Poi { Name = "AZ Sint-Lucas" }, SelectedCapabilityIds = new List { "cath", "trauma" } }, CancellationToken.None); + + Mapping.Verify(x => x.SetPoiCapabilitiesAsync(DepartmentId, 501, It.Is>(ids => ids.SequenceEqual(new[] { "cath", "trauma" })), It.IsAny()), Times.Once); + } + + [Test] + public async Task Adding_a_POI_without_ticked_capabilities_writes_none() + { + Mapping.Setup(x => x.GetTypeByIdAsync(TypeId)).ReturnsAsync(new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId }); + Mapping.Setup(x => x.SavePOIAsync(It.IsAny(), It.IsAny())).ReturnsAsync((Poi p, CancellationToken _) => { p.PoiId = 501; return p; }); + + await Build(MapAdmin).AddPOI(new AddPOIView { TypeId = TypeId, Poi = new Poi { Name = "Depot" } }, CancellationToken.None); + + Mapping.Verify(x => x.SetPoiCapabilitiesAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Edit_POI_ticks_the_capabilities_the_POI_offers() + { + Mapping.Setup(x => x.GetPOIByIdAsync(HospitalId)).ReturnsAsync(new Poi { PoiId = HospitalId, PoiTypeId = TypeId, Name = "UZ Gent" }); + Mapping.Setup(x => x.GetTypeByIdAsync(TypeId)).ReturnsAsync(new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId }); + Mapping.Setup(x => x.GetPoiCapabilityIdsForPoiAsync(DepartmentId, HospitalId)).ReturnsAsync(new List { "stroke" }); + + var model = (await Build(MapAdmin).EditPOI(HospitalId)).Should().BeOfType().Which.Model.Should().BeOfType().Which; + + model.Capabilities.Should().HaveCount(3); + model.SelectedCapabilityIds.Should().Equal("stroke"); + } + + [TestCase("cath,stroke")] + [TestCase("")] + public async Task Editing_a_POI_replaces_its_capabilities_with_the_ticked_ones(string tickedList) + { + var ticked = tickedList.Split(',', StringSplitOptions.RemoveEmptyEntries); + Mapping.Setup(x => x.GetPOIByIdAsync(HospitalId)).ReturnsAsync(new Poi { PoiId = HospitalId, PoiTypeId = TypeId, Name = "UZ Gent" }); + Mapping.Setup(x => x.GetTypeByIdAsync(TypeId)).ReturnsAsync(new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId }); + + var result = await Build(MapAdmin).EditPOI(new AddPOIView { Poi = new Poi { PoiId = HospitalId, Name = "UZ Gent" }, SelectedCapabilityIds = ticked.ToList() }, CancellationToken.None); + + result.Should().BeOfType(); + Mapping.Verify(x => x.SetPoiCapabilitiesAsync(DepartmentId, HospitalId, It.Is>(ids => ids.SequenceEqual(ticked)), It.IsAny()), Times.Once); + } + + [Test] + public async Task Editing_another_departments_POI_writes_no_capabilities() + { + Mapping.Setup(x => x.GetPOIByIdAsync(HospitalId)).ReturnsAsync(new Poi { PoiId = HospitalId, PoiTypeId = 6 }); + Mapping.Setup(x => x.GetTypeByIdAsync(6)).ReturnsAsync(new PoiType { PoiTypeId = 6, DepartmentId = OtherDepartmentId }); + + ShouldBeRefused(await Build(MapAdmin).EditPOI(new AddPOIView { Poi = new Poi { PoiId = HospitalId }, SelectedCapabilityIds = new List { "cath" } }, CancellationToken.None)); + + Mapping.Verify(x => x.SetPoiCapabilitiesAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + #endregion Add / edit POI + + #region POI list + + [Test] + public async Task A_destination_types_list_shows_capabilities_and_current_availability() + { + var expires = DateTime.UtcNow.AddHours(2); + var pois = new List { new Poi { PoiId = HospitalId, PoiTypeId = TypeId, Name = "UZ Gent" }, new Poi { PoiId = 32, PoiTypeId = TypeId, Name = "AZ Jan Palfijn" } }; + Mapping.Setup(x => x.GetTypeByIdAsync(TypeId)).ReturnsAsync(new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId, IsDestination = true, Name = "Hospitals", Pois = pois }); + Mapping.Setup(x => x.PopulatePoiDetailsAsync(DepartmentId, It.IsAny>())).Callback(() => + { + pois[0].Capabilities = new List { "Cath Lab", "Trauma Center" }; + pois[0].Availability = new PoiAvailability { PoiId = HospitalId, DepartmentId = DepartmentId, Status = (int)PoiAvailabilityStatuses.Diversion, UnavailableCapabilityIds = "cath", Note = "ED full", SetOn = DateTime.UtcNow, ExpiresOn = expires }; + pois[1].Capabilities = new List(); + }).Returns(Task.CompletedTask); + + var listed = (await Build(MapAdmin).GetPoisForType(TypeId)).Should().BeOfType().Which.Value.Should().BeOfType>().Which; + + listed[0].Capabilities.Should().Equal("Cath Lab", "Trauma Center"); + listed[0].IsDestination.Should().BeTrue(); + listed[0].AvailabilityStatus.Should().Be((int)PoiAvailabilityStatuses.Diversion); + listed[0].AvailabilityLabel.Should().Be("PoiAvailabilityDiversion"); + listed[0].UnavailableCapabilities.Should().Equal("Cath Lab"); + listed[0].AvailabilityNote.Should().Be("ED full"); + listed[0].AvailabilityExpires.Should().NotBeNullOrEmpty(); + listed[1].AvailabilityStatus.Should().Be((int)PoiAvailabilityStatuses.Open); + listed[1].AvailabilityLabel.Should().Be("PoiAvailabilityOpen"); + } + + [Test] + public async Task Other_types_list_capabilities_without_availability() + { + var pois = new List { new Poi { PoiId = 40, PoiTypeId = 6, Name = "Shelter" } }; + Mapping.Setup(x => x.GetTypeByIdAsync(6)).ReturnsAsync(new PoiType { PoiTypeId = 6, DepartmentId = DepartmentId, IsDestination = false, Pois = pois }); + Mapping.Setup(x => x.PopulatePoiDetailsAsync(DepartmentId, It.IsAny>())).Callback(() => pois[0].Capabilities = new List { "Pet friendly" }).Returns(Task.CompletedTask); + + var listed = (await Build(MapAdmin).GetPoisForType(6)).Should().BeOfType().Which.Value.Should().BeOfType>().Which; + + listed.Single().Capabilities.Should().Equal("Pet friendly"); + listed.Single().IsDestination.Should().BeFalse(); + listed.Single().AvailabilityLabel.Should().BeNull(); + } + + #endregion POI list + + #region Destination availability + + [Test] + public async Task Dispatchers_and_map_administrators_open_the_destinations_page_and_other_members_do_not() + { + Mapping.Setup(x => x.GetDestinationPOIsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + + (await Build(CallCreator).Destinations()).Should().BeOfType(); + (await Build(MapAdmin).Destinations()).Should().BeOfType(); + ShouldBeRefused(await Build().Destinations()); + ShouldBeRefused(await Build().SetPoiAvailability(HospitalId, null)); + ShouldBeRefused(await Build().SetPoiAvailability(new SetPoiAvailabilityView { PoiId = HospitalId, Status = 2 }, CancellationToken.None)); + + Mapping.Verify(x => x.SetPoiAvailabilityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task The_destinations_page_lists_what_each_destination_offers_and_its_availability() + { + var poi = Hospital(new PoiAvailability { PoiId = HospitalId, DepartmentId = DepartmentId, Status = (int)PoiAvailabilityStatuses.Limited, UnavailableCapabilityIds = "stroke", Note = "CT down", SetOn = DateTime.UtcNow }, + "cath", "stroke"); + Mapping.Setup(x => x.GetDestinationPOIsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List { poi }); + + var model = (await Build(CallCreator).Destinations()).Should().BeOfType().Which.Model.Should().BeOfType().Which; + + var row = model.Destinations.Single(); + row.Name.Should().Be("UZ Gent"); + row.TypeName.Should().Be("Hospitals"); + row.Capabilities.Should().Equal("Cath Lab", "Stroke Center"); + row.Status.Should().Be((int)PoiAvailabilityStatuses.Limited); + row.StatusLabel.Should().Be("PoiAvailabilityLimited"); + row.UnavailableCapabilities.Should().Equal("Stroke Center"); + row.Note.Should().Be("CT down"); + model.CanManagePois.Should().BeFalse(); + } + + [Test] + public async Task The_availability_form_offers_the_destinations_capabilities_and_keeps_a_current_expiry() + { + Hospital(new PoiAvailability { PoiId = HospitalId, DepartmentId = DepartmentId, Status = (int)PoiAvailabilityStatuses.Diversion, UnavailableCapabilityIds = "trauma", SetOn = DateTime.UtcNow, ExpiresOn = DateTime.UtcNow.AddHours(3) }, + "cath"); + + var model = (await Build(CallCreator).SetPoiAvailability(HospitalId, null)).Should().BeOfType().Which.Model.Should().BeOfType().Which; + + model.Status.Should().Be((int)PoiAvailabilityStatuses.Diversion); + model.UnavailableCapabilityIds.Should().Equal("trauma"); + model.ExpiresInHours.Should().Be(SetPoiAvailabilityView.KeepExpiry); + // What it offers, plus what is reported unavailable but no longer ticked on the POI. + model.Capabilities.Select(c => c.PoiCapabilityId).Should().Equal("cath", "trauma"); + } + + [Test] + public async Task A_dispatcher_sets_a_destinations_availability() + { + Hospital(null, "cath", "stroke"); + var controller = Build(CallCreator); + + var result = await controller.SetPoiAvailability(new SetPoiAvailabilityView + { + PoiId = HospitalId, Status = (int)PoiAvailabilityStatuses.Diversion, UnavailableCapabilityIds = new List { "cath" }, Note = " ED overloaded ", ExpiresInHours = 4 + }, CancellationToken.None); + + result.Should().BeOfType().Which.ActionName.Should().Be("Destinations"); + Mapping.Verify(x => x.SetPoiAvailabilityAsync(DepartmentId, HospitalId, PoiAvailabilityStatuses.Diversion, + It.Is>(ids => ids.SequenceEqual(new[] { "cath" })), "ED overloaded", + It.Is(d => d.HasValue && Math.Abs((d.Value - DateTime.UtcNow.AddHours(4)).TotalMinutes) < 1), UserId, It.IsAny()), Times.Once); + controller.TempData["PoiAvailabilityMessage"].Should().Be("PoiAvailabilitySaved"); + } + + [Test] + public async Task Open_without_an_expiry_clears_the_report() + { + Hospital(new PoiAvailability { PoiId = HospitalId, DepartmentId = DepartmentId, Status = (int)PoiAvailabilityStatuses.Closed, SetOn = DateTime.UtcNow }); + + await Build(CallCreator).SetPoiAvailability(new SetPoiAvailabilityView { PoiId = HospitalId, Status = (int)PoiAvailabilityStatuses.Open, ExpiresInHours = 0 }, CancellationToken.None); + + Mapping.Verify(x => x.SetPoiAvailabilityAsync(DepartmentId, HospitalId, PoiAvailabilityStatuses.Open, It.IsAny>(), null, null, UserId, It.IsAny()), Times.Once); + } + + [Test] + public async Task Keeping_the_expiry_passes_the_current_reports_expiry_through() + { + var expires = DateTime.UtcNow.AddHours(5); + Hospital(new PoiAvailability { PoiId = HospitalId, DepartmentId = DepartmentId, Status = (int)PoiAvailabilityStatuses.Limited, SetOn = DateTime.UtcNow, ExpiresOn = expires }); + + await Build(CallCreator).SetPoiAvailability(new SetPoiAvailabilityView { PoiId = HospitalId, Status = (int)PoiAvailabilityStatuses.Closed, ExpiresInHours = SetPoiAvailabilityView.KeepExpiry }, CancellationToken.None); + + Mapping.Verify(x => x.SetPoiAvailabilityAsync(DepartmentId, HospitalId, PoiAvailabilityStatuses.Closed, It.IsAny>(), It.IsAny(), expires, UserId, It.IsAny()), Times.Once); + } + + [Test] + public async Task A_map_administrator_returns_to_the_types_list() + { + Hospital(); + + var result = await Build(MapAdmin).SetPoiAvailability(new SetPoiAvailabilityView { PoiId = HospitalId, Status = 1, ReturnTo = "Type" }, CancellationToken.None); + + var redirect = result.Should().BeOfType().Which; + redirect.ActionName.Should().Be("ViewType"); + redirect.RouteValues["poiTypeId"].Should().Be(TypeId); + } + + [Test] + public async Task A_dispatcher_asking_for_the_types_list_goes_back_to_the_destinations_page() + { + Hospital(); + + var result = await Build(CallCreator).SetPoiAvailability(new SetPoiAvailabilityView { PoiId = HospitalId, Status = 1, ReturnTo = "Type" }, CancellationToken.None); + + result.Should().BeOfType().Which.ActionName.Should().Be("Destinations", "the POI type list is for map administrators"); + } + + [TestCase(7, 0, null, "Status")] + [TestCase(-1, 0, null, "Status")] + [TestCase(2, 3, null, "ExpiresInHours")] + [TestCase(2, SetPoiAvailabilityView.KeepExpiry, null, "ExpiresInHours")] + [TestCase(2, 0, 501, "Note")] + public async Task An_invalid_post_shows_the_form_again_and_saves_nothing(int status, int expiresInHours, int? noteLength, string field) + { + Hospital(null, "cath"); + var controller = Build(CallCreator); + + var result = await controller.SetPoiAvailability(new SetPoiAvailabilityView + { + PoiId = HospitalId, Status = status, ExpiresInHours = expiresInHours, Note = noteLength.HasValue ? new string('n', noteLength.Value) : null + }, CancellationToken.None); + + var model = result.Should().BeOfType().Which.Model.Should().BeOfType().Which; + model.Destination.Should().NotBeNull("the form shows the destination again"); + controller.ModelState[field].Errors.Should().NotBeEmpty(); + Mapping.Verify(x => x.SetPoiAvailabilityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Another_departments_POI_or_one_that_is_not_a_destination_is_not_found() + { + // GetDestinationPOIByIdAsync is department-scoped and destinations-only: it finds nothing for either. + Mapping.Setup(x => x.GetDestinationPOIByIdAsync(DepartmentId, 777)).ReturnsAsync((Poi)null); + var controller = Build(CallCreator); + + (await controller.SetPoiAvailability(777, null)).Should().BeOfType(); + (await controller.SetPoiAvailability(new SetPoiAvailabilityView { PoiId = 777, Status = 3 }, CancellationToken.None)).Should().BeOfType(); + + Mapping.Verify(x => x.SetPoiAvailabilityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task A_save_the_service_refuses_shows_the_form_with_a_message() + { + Hospital(); + Mapping.Setup(x => x.SetPoiAvailabilityAsync(DepartmentId, HospitalId, It.IsAny(), It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new ArgumentException("The destination does not exist.")); + var controller = Build(CallCreator); + + (await controller.SetPoiAvailability(new SetPoiAvailabilityView { PoiId = HospitalId, Status = 3 }, CancellationToken.None)).Should().BeOfType(); + + controller.ModelState[string.Empty].Errors.Single().ErrorMessage.Should().Be("PoiAvailabilitySaveFailed"); + } + + #endregion Destination availability + + #region Call form destination options + + [Test] + public async Task The_call_forms_destination_options_name_capabilities_and_a_status_other_than_open() + { + M().Setup(x => x.CanUserCreateCallAsync(UserId, DepartmentId)).ReturnsAsync(true); + M().Setup(x => x.GetAllGroupsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + M().Setup(x => x.GetAllStationGroupsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + M().Setup(x => x.GetCallTypesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + M().Setup(x => x.GetActiveCallPrioritiesForDepartmentAsync(DepartmentId, It.IsAny())).ReturnsAsync(new List()); + M().Setup(x => x.GetAllUsersForDepartmentAsync(DepartmentId, It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + M().Setup(x => x.GetAllUsersForDepartment(DepartmentId, It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + M().Setup(x => x.GetUnitsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + M().Setup(x => x.GetUnitsForDepartmentIncludingDeletedAsync(DepartmentId)).ReturnsAsync(new List()); + M().Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List()); + + var hospitals = new PoiType { PoiTypeId = TypeId, DepartmentId = DepartmentId, Name = "Hospitals", IsDestination = true }; + var gent = new Poi { PoiId = HospitalId, PoiTypeId = TypeId, Name = "UZ Gent", Type = hospitals }; + var aalst = new Poi { PoiId = 32, PoiTypeId = TypeId, Name = "OLV Aalst", Type = hospitals }; + var eeklo = new Poi { PoiId = 33, PoiTypeId = TypeId, Name = "AZ Alma", Type = hospitals }; + Mapping.Setup(x => x.GetDestinationPOIsForDepartmentAsync(DepartmentId)).ReturnsAsync(new List { gent, aalst, eeklo }); + Mapping.Setup(x => x.PopulatePoiDetailsAsync(DepartmentId, It.IsAny>())).Callback(() => + { + gent.Capabilities = new List { "Cath Lab", "Trauma Center" }; + gent.Availability = new PoiAvailability { PoiId = HospitalId, Status = (int)PoiAvailabilityStatuses.Diversion, SetOn = DateTime.UtcNow }; + aalst.Capabilities = new List { "Stroke Center" }; + // An expired report reads as open. + aalst.Availability = new PoiAvailability { PoiId = 32, Status = (int)PoiAvailabilityStatuses.Closed, SetOn = DateTime.UtcNow.AddDays(-1), ExpiresOn = DateTime.UtcNow.AddMinutes(-5) }; + eeklo.Capabilities = new List(); + eeklo.Availability = new PoiAvailability { PoiId = 33, Status = (int)PoiAvailabilityStatuses.Closed, SetOn = DateTime.UtcNow }; + }).Returns(Task.CompletedTask); + + var model = (await Build(CallCreator).NewCall()).Should().BeOfType().Which.Model.Should().BeOfType().Which; + + var options = model.DestinationPois.Where(o => !string.IsNullOrEmpty(o.Value)).ToDictionary(o => o.Value, o => o.Text); + options[HospitalId.ToString()].Should().Be("UZ Gent — Cath Lab, Trauma Center (DestinationAvailabilityDiversion)"); + options["32"].Should().Be("OLV Aalst — Stroke Center"); + options["33"].Should().Be("AZ Alma (DestinationAvailabilityClosed)"); + model.DestinationPois.First().Value.Should().BeEmpty("the no-destination option stays first"); + } + + #endregion Call form destination options + } +} diff --git a/Tests/Resgrid.Tests/Web/User/PoiCapabilityPagesFormTests.cs b/Tests/Resgrid.Tests/Web/User/PoiCapabilityPagesFormTests.cs new file mode 100644 index 000000000..33d0e155a --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/PoiCapabilityPagesFormTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using File = System.IO.File; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web.User +{ + /// + /// The POI capability and destination availability pages through the real MVC pipeline: the views render, the forms carry + /// their antiforgery tokens, ticked checkboxes bind, and a dispatcher (Call create, not a map administrator) can open the + /// destinations page and set availability but not manage capabilities. + /// + [TestFixture, NonParallelizable] + public class PoiCapabilityPagesFormTests + { + private const int Dept = 77; + private const int TypeId = 40; + private const int HospitalId = 1; + + private sealed class PageBodyFilter : IResultFilter + { + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is ViewResult view) + context.Result = new PartialViewResult { ViewName = "/Areas/User/Views/" + context.RouteData.Values["controller"] + "/" + (view.ViewName ?? context.RouteData.Values["action"]) + ".cshtml", ViewData = view.ViewData, TempData = view.TempData }; + } + public void OnResultExecuted(ResultExecutedContext context) { } + } + + private sealed class TestAuthentication : AuthenticationHandler + { + public TestAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + protected override Task HandleAuthenticateAsync() + { + // NameIdentifier pins the antiforgery claim uid, so a token issued to the administrator also validates for the dispatcher. + var claims = new List { new Claim(ClaimTypes.NameIdentifier, "manager"), new Claim(ClaimTypes.PrimarySid, "manager"), new Claim(ClaimTypes.PrimaryGroupSid, Dept.ToString()) }; + if (Request.Headers.ContainsKey("X-Dispatcher")) + claims.Add(new Claim(ResgridClaimTypes.Resources.Call, ResgridClaimTypes.Actions.Create)); + else + claims.Add(new Claim(ResgridClaimTypes.Resources.Department, ResgridClaimTypes.Actions.Update)); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "poi-test")), "poi-test"))); + } + } + + private static string Token(string html) + => WebUtility.HtmlDecode(Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value); + + private sealed record AvailabilityCall(int PoiId, PoiAvailabilityStatuses Status, List Unavailable, string Note, DateTime? ExpiresOn); + + [Test] + public async Task Capability_and_availability_pages_render_and_post_through_antiforgery_and_the_role_gates() + { + var root = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (root != null && !File.Exists(Path.Combine(root.FullName, "Resgrid.sln"))) root = root.Parent; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.Combine(root!.FullName, "Web", "Resgrid.Web"), EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddLocalization(); builder.Services.AddAdminAssistFieldHelpStubs(); builder.Services.AddWebOptimizer(); + builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); + builder.Services.AddAuthentication("poi-test").AddScheme("poi-test", _ => { }); + builder.Services.AddAuthorization(); + builder.Services.AddControllersWithViews(o => o.Filters.Add(new PageBodyFilter())).AddApplicationPart(typeof(MappingController).Assembly); + + var capabilities = new List + { + new PoiCapability { PoiCapabilityId = "cath", DepartmentId = Dept, Name = "Cath Lab", Sort = 1 }, + new PoiCapability { PoiCapabilityId = "stroke", DepartmentId = Dept, Name = "Stroke Center", Sort = 2 } + }; + var type = new PoiType { PoiTypeId = TypeId, DepartmentId = Dept, Name = "Hospitals", IsDestination = true, Color = "#cc0000" }; + var hospital = new Poi { PoiId = HospitalId, PoiTypeId = TypeId, Name = "UZ Gent", Address = "Corneel Heymanslaan 10", Type = type }; + type.Pois = new List { hospital }; + var report = new PoiAvailability { PoiId = HospitalId, DepartmentId = Dept, Status = (int)PoiAvailabilityStatuses.Diversion, UnavailableCapabilityIds = "cath", Note = "ED ", SetOn = DateTime.UtcNow, ExpiresOn = DateTime.UtcNow.AddHours(2) }; + + var mapping = new Mock(); + mapping.Setup(x => x.GetPoiCapabilitiesForDepartmentAsync(Dept)).ReturnsAsync(() => capabilities.Select(c => new PoiCapability { PoiCapabilityId = c.PoiCapabilityId, DepartmentId = c.DepartmentId, Name = c.Name, Sort = c.Sort }).ToList()); + mapping.Setup(x => x.GetTypeByIdAsync(TypeId)).ReturnsAsync(type); + mapping.Setup(x => x.GetPOIsForDepartmentAsync(Dept)).ReturnsAsync(() => new List { hospital }); + mapping.Setup(x => x.GetDestinationPOIsForDepartmentAsync(Dept)).ReturnsAsync(() => new List { hospital }); + mapping.Setup(x => x.GetDestinationPOIByIdAsync(Dept, HospitalId)).ReturnsAsync(() => hospital); + mapping.Setup(x => x.PopulatePoiDetailsAsync(Dept, It.IsAny>())).Callback((int _, IEnumerable pois) => + { + foreach (var p in pois.Where(p => p.PoiId == HospitalId)) + { + p.CapabilityIds = new List { "cath", "stroke" }; + p.Capabilities = new List { "Cath Lab", "Stroke Center" }; + p.Availability = report; + } + }).Returns(Task.CompletedTask); + var savedCapabilities = new List(); + mapping.Setup(x => x.SavePoiCapabilityAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((PoiCapability c, CancellationToken _) => { savedCapabilities.Add(c); return c; }); + var availabilityCalls = new List(); + mapping.Setup(x => x.SetPoiAvailabilityAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, int poiId, PoiAvailabilityStatuses status, IEnumerable ids, string note, DateTime? expires, string user, CancellationToken _) => + { + availabilityCalls.Add(new AvailabilityCall(poiId, status, ids?.ToList() ?? new List(), note, expires)); + return null; + }); + builder.Services.AddSingleton(mapping.Object); + + var departmentSettings = new Mock(); + departmentSettings.Setup(x => x.GetMapConfigForDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new Resgrid.Config.ResolvedMapConfig()); + builder.Services.AddSingleton(departmentSettings.Object); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of()); + builder.Services.AddSingleton(Mock.Of(d => d.GetDepartmentByIdAsync(Dept, false) == Task.FromResult(new Department { DepartmentId = Dept, TimeZone = "Pacific Standard Time" }))); + await using var app = builder.Build(); + var previous = ClaimsAuthorizationHelper._httpContextAccessor; + ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); + app.Use(async (context, next) => { try { await next(); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsync(ex.ToString()); } }); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("en").AddSupportedCultures("en").AddSupportedUICultures("en")); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); + app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); + try + { + await app.StartAsync(); + using var handler = new HttpClientHandler { AllowAutoRedirect = false, CookieContainer = new CookieContainer() }; + using var client = new HttpClient(handler) { BaseAddress = new Uri(app.Urls.Single()) }; + async Task Post(string url, params (string Key, string Value)[] fields) + => await client.PostAsync(url, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value)))); + async Task Page(string url) + { + var response = await client.GetAsync(url); + var body = await response.Content.ReadAsStringAsync(); + response.StatusCode.Should().Be(HttpStatusCode.OK, body); + return body; + } + + // ---- Map administrator: capabilities page and the POI form ---- + + var html = await Page("/User/Mapping/PoiCapabilities"); + html.Should().Contain("id=\"addPoiCapabilityForm\"").And.Contain("value=\"Cath Lab\"").And.Contain("value=\"Stroke Center\""); + WebUtility.HtmlDecode(html).Should().Contain("Delete the capability \"Cath Lab\"? It is removed from every POI that offers it (1)"); + var token = Token(html); + token.Should().NotBeNullOrEmpty(); + + (await Post("/User/Mapping/SavePoiCapability", ("name", "Burns Unit"))).StatusCode.Should().Be(HttpStatusCode.BadRequest, "no antiforgery token"); + savedCapabilities.Should().BeEmpty(); + + var saved = await Post("/User/Mapping/SavePoiCapability", ("__RequestVerificationToken", token), ("name", "Burns Unit"), ("sort", "")); + saved.StatusCode.Should().Be(HttpStatusCode.Redirect, await saved.Content.ReadAsStringAsync()); + saved.Headers.Location!.OriginalString.Should().Contain("/User/Mapping/PoiCapabilities"); + savedCapabilities.Should().ContainSingle().Which.Should().Match(c => c.Name == "Burns Unit" && c.DepartmentId == Dept && c.PoiCapabilityId == null && c.Sort == 0); + + var form = await Page("/User/Mapping/AddPOI?poiTypeId=" + TypeId); + form.Should().Contain("name=\"SelectedCapabilityIds\" value=\"cath\"").And.Contain("name=\"SelectedCapabilityIds\" value=\"stroke\""); + + // ---- Dispatcher: destinations and availability, but not capability management ---- + + client.DefaultRequestHeaders.Add("X-Dispatcher", "1"); + var destinations = WebUtility.HtmlDecode(await Page("/User/Mapping/Destinations")); + destinations.Should().Contain("Destination Availability").And.Contain("UZ Gent").And.Contain("Cath Lab, Stroke Center") + .And.Contain("Diversion").And.Contain("ED ").And.Contain("SetPoiAvailability?poiId=" + HospitalId); + destinations.Should().NotContain("/User/Mapping/PoiCapabilities", "capability management is for map administrators"); + + var availabilityForm = await Page("/User/Mapping/SetPoiAvailability?poiId=" + HospitalId); + availabilityForm.Should().Contain("id=\"setPoiAvailabilityForm\"").And.Contain("name=\"UnavailableCapabilityIds\" value=\"cath\" checked=\"checked\"") + .And.Contain("value=\"-1\" selected=\"selected\"").And.Contain("ED <full>"); + + var posted = await Post("/User/Mapping/SetPoiAvailability", ("__RequestVerificationToken", Token(availabilityForm)), ("PoiId", HospitalId.ToString()), + ("Status", "1"), ("UnavailableCapabilityIds", "cath"), ("UnavailableCapabilityIds", "stroke"), ("Note", "CT down"), ("ExpiresInHours", "2")); + posted.StatusCode.Should().Be(HttpStatusCode.Redirect, await posted.Content.ReadAsStringAsync()); + posted.Headers.Location!.OriginalString.Should().Contain("/User/Mapping/Destinations"); + availabilityCalls.Should().ContainSingle(); + availabilityCalls[0].Should().Match(c => c.PoiId == HospitalId && c.Status == PoiAvailabilityStatuses.Limited && c.Note == "CT down" && c.ExpiresOn.HasValue); + availabilityCalls[0].Unavailable.Should().Equal("cath", "stroke"); + + (await Post("/User/Mapping/SetPoiAvailability", ("PoiId", HospitalId.ToString()), ("Status", "3"))).StatusCode.Should().Be(HttpStatusCode.BadRequest, "no antiforgery token"); + availabilityCalls.Should().HaveCount(1); + + (await client.GetAsync("/User/Mapping/PoiCapabilities")).Headers.Location!.OriginalString.Should().Contain("/Public/Unauthorized"); + (await Post("/User/Mapping/SavePoiCapability", ("__RequestVerificationToken", token), ("name", "Dispatcher's"))).Headers.Location!.OriginalString.Should().Contain("/Public/Unauthorized"); + savedCapabilities.Should().HaveCount(1); + } + finally + { + await app.StopAsync(); + ClaimsAuthorizationHelper._httpContextAccessor = previous; + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 5e4b298da..9a4a2c580 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -985,15 +985,24 @@ public async Task> GetNewCallFieldPolicy( var result = new NewCallFieldPolicyResult(); var policy = await _departmentSettingsService.GetNewCallFieldPolicyAsync(DepartmentId); - if (policy?.Rules != null) + // The effective rule for every key that differs from "shown, optional", not the stored rules: the recipients + // section's visibility follows its four lists, and a list the department has not set on its own follows a + // section hidden before the lists could be (NewCallFieldPolicy.IsVisible). Clients then read each key as is. + if (policy != null && !policy.IsEmpty) { - foreach (var rule in policy.Rules) + foreach (var key in NewCallFieldKeys.All) { + var visible = policy.IsVisible(key); + var required = policy.IsRequired(key); + + if (visible && !required) + continue; + result.Data.Rules.Add(new NewCallFieldRuleData { - Key = rule.Key, - Visible = rule.Visible, - Required = policy.IsRequired(rule.Key) + Key = key, + Visible = visible, + Required = required }); } } @@ -1082,6 +1091,8 @@ public async Task> SaveCall([FromBody] NewCallInput ExternalId = newCallInput.ExternalId, IncidentId = newCallInput.IncidentId, ReferenceId = newCallInput.ReferenceId, + MapPage = newCallInput.MapPage, + PrimaryContactId = newCallInput.ContactId, DestinationPoiId = newCallInput.DestinationPoiId, IndoorMapZoneId = newCallInput.IndoorMapZoneId, HasProtocols = protocols.Any(), @@ -1096,6 +1107,12 @@ public async Task> SaveCall([FromBody] NewCallInput if (newCallInput.IndoorMapZoneId == null) fieldValues.Unsupported(NewCallFieldKeys.IndoorLocation); + if (newCallInput.MapPage == null) + fieldValues.Unsupported(NewCallFieldKeys.MapPage); + + if (newCallInput.ContactId == null) + fieldValues.Unsupported(NewCallFieldKeys.PrimaryContact); + if (newCallInput.ProtocolIds == null) fieldValues.Unsupported(NewCallFieldKeys.Protocols); @@ -1141,6 +1158,9 @@ public async Task> SaveCall([FromBody] NewCallInput if (!string.IsNullOrWhiteSpace(newCallInput.ReferenceId)) call.ReferenceNumber = newCallInput.ReferenceId; + if (!string.IsNullOrWhiteSpace(newCallInput.MapPage)) + call.MapPage = newCallInput.MapPage; + if (!string.IsNullOrWhiteSpace(newCallInput.Address)) call.Address = newCallInput.Address; @@ -1223,15 +1243,13 @@ public async Task> SaveCall([FromBody] NewCallInput call.RoleDispatches = new List(); call.UnitDispatches = new List(); - // An empty list means "everyone" for an immediate call (older clients send nothing), but for a pending call - // it means no proposed recipients yet: the dispatcher who picks it up chooses them. The system key never pages - // everyone on its own, but a pending call pages nobody until a dispatcher sends it, so "0" is honored there. - // A list with no entries ("|") takes the explicit branch below and dispatches nobody: clients send it for a - // call the dispatcher chose to send to no one. - if ((newCallInput.DispatchList == "0" && (!IsSystemApiKeyRequest || isPending)) || - (!IsSystemApiKeyRequest && !isPending && string.IsNullOrWhiteSpace(newCallInput.DispatchList))) + // Everyone ("All Call") is only ever explicit: "0". A blank list, or one with no entries ("|"), dispatches + // nobody -- an informational call, or for a pending call no proposed recipients yet. Older clients that send a + // blank list when nothing was picked used to page the whole department; they no longer do (2026-11-13). The + // system key never pages everyone on its own, but a pending call pages nobody until a dispatcher sends it, so + // "0" is honored there. + if (DispatchListHelper.IsEveryone(newCallInput.DispatchList) && (!IsSystemApiKeyRequest || isPending)) { - // Use case, existing clients and non-ionic2 app this will be null dispatch all users. Or we've specified everyone (0). foreach (var u in users) { var cd = new CallDispatch { UserId = u.UserId }; @@ -1239,7 +1257,7 @@ public async Task> SaveCall([FromBody] NewCallInput call.Dispatches.Add(cd); } } - else if (!string.IsNullOrWhiteSpace(newCallInput.DispatchList) && newCallInput.DispatchList != "0") + else if (!string.IsNullOrWhiteSpace(newCallInput.DispatchList) && !DispatchListHelper.IsEveryone(newCallInput.DispatchList)) { var dispatch = newCallInput.DispatchList.Split(char.Parse("|")); @@ -1642,6 +1660,9 @@ public async Task> EditCall([FromBody] EditCallInpu if (!string.IsNullOrWhiteSpace(editCallInput.ReferenceId)) call.ReferenceNumber = editCallInput.ReferenceId; + if (!string.IsNullOrWhiteSpace(editCallInput.MapPage)) + call.MapPage = editCallInput.MapPage; + var addressChanged = !string.IsNullOrWhiteSpace(editCallInput.Address) && !string.Equals(editCallInput.Address, call.Address, StringComparison.Ordinal); @@ -1759,11 +1780,10 @@ public async Task> EditCall([FromBody] EditCallInpu var existingUnitDispatches = new List(call.UnitDispatches ?? new List()); var existingRoleDispatches = new List(call.RoleDispatches ?? new List()); - if ((isPending || !fieldPolicy.IsVisible(NewCallFieldKeys.DispatchList)) && string.IsNullOrWhiteSpace(editCallInput.DispatchList)) + if (string.IsNullOrWhiteSpace(editCallInput.DispatchList)) { - // No list on an edit of a pending call keeps its proposed recipients (an empty list would otherwise mean - // "everyone"). The same when the department hides the dispatch list: the edit form never had one, so - // treating the blank as "everyone" would page the whole department on every edit. + // No list keeps the call's recipients. Everyone ("All Call") is only ever explicit ("0"): a blank list used + // to page the whole department on an edit from a client with no picker (a hidden list, an older build). if (call.Dispatches == null) call.Dispatches = new List(); @@ -1776,7 +1796,7 @@ public async Task> EditCall([FromBody] EditCallInpu if (call.UnitDispatches == null) call.UnitDispatches = new List(); } - else if (string.IsNullOrWhiteSpace(editCallInput.DispatchList) || editCallInput.DispatchList == "0") + else if (DispatchListHelper.IsEveryone(editCallInput.DispatchList)) { if (call.Dispatches == null) call.Dispatches = new List(); @@ -1791,10 +1811,13 @@ public async Task> EditCall([FromBody] EditCallInpu call.UnitDispatches = new List(); var users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId); - // Use case, existing clients and non-ionic2 app this will be null dispatch all users. Or we've specified everyone (0). + // "0": everyone. Someone already on the call stays as they are (an All Call on an edit adds everyone else). foreach (var u in users) { - var cd = new CallDispatch { UserId = u.UserId }; + if (call.Dispatches.Any(x => x.UserId == u.UserId)) + continue; + + var cd = new CallDispatch { CallId = call.CallId, UserId = u.UserId }; call.Dispatches.Add(cd); } @@ -1810,12 +1833,23 @@ public async Task> EditCall([FromBody] EditCallInpu var unitsToDispatch = DispatchListHelper.ResolveIds(dispatch,"U:", name => units.FirstOrDefault(x => string.Equals(x.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))?.UnitId); + // A recipient list the department hides is not on the client's edit form, so the list it sends has none of + // that kind: the call keeps the ones it has (a run card may have added them) rather than losing them. + bool KeepsHiddenList(string key, bool sentAny) => !sentAny && !fieldPolicy.IsVisible(key); + + var keepUsers = KeepsHiddenList(NewCallFieldKeys.DispatchPersonnel, usersToDispatch.Any()); + var keepGroups = KeepsHiddenList(NewCallFieldKeys.DispatchGroups, groupsToDispatch.Any()); + var keepRoles = KeepsHiddenList(NewCallFieldKeys.DispatchRoles, rolesToDispatch.Any()); + var keepUnits = KeepsHiddenList(NewCallFieldKeys.DispatchUnits, unitsToDispatch.Any()); + try { if (call.Dispatches == null) call.Dispatches = new List(); - var dispatchesToRemove = call.Dispatches.Select(x => x.UserId).Where(y => !usersToDispatch.Contains(y)).ToList(); + var dispatchesToRemove = keepUsers + ? new List() + : call.Dispatches.Select(x => x.UserId).Where(y => !usersToDispatch.Contains(y)).ToList(); foreach (var userId in dispatchesToRemove) { @@ -1845,7 +1879,9 @@ public async Task> EditCall([FromBody] EditCallInpu if (call.GroupDispatches == null) call.GroupDispatches = new List(); - var dispatchesToRemove = call.GroupDispatches.Select(x => x.DepartmentGroupId).Where(y => !groupsToDispatch.Contains(y)).ToList(); + var dispatchesToRemove = keepGroups + ? new List() + : call.GroupDispatches.Select(x => x.DepartmentGroupId).Where(y => !groupsToDispatch.Contains(y)).ToList(); foreach (var id in dispatchesToRemove) { @@ -1874,7 +1910,9 @@ public async Task> EditCall([FromBody] EditCallInpu if (call.RoleDispatches == null) call.RoleDispatches = new List(); - var dispatchesToRemove = call.RoleDispatches.Select(x => x.RoleId).Where(y => !rolesToDispatch.Contains(y)).ToList(); + var dispatchesToRemove = keepRoles + ? new List() + : call.RoleDispatches.Select(x => x.RoleId).Where(y => !rolesToDispatch.Contains(y)).ToList(); foreach (var id in dispatchesToRemove) { @@ -1903,7 +1941,9 @@ public async Task> EditCall([FromBody] EditCallInpu if (call.UnitDispatches == null) call.UnitDispatches = new List(); - var dispatchesToRemove = call.UnitDispatches.Select(x => x.UnitId).Where(y => !unitsToDispatch.Contains(y)).ToList(); + var dispatchesToRemove = keepUnits + ? new List() + : call.UnitDispatches.Select(x => x.UnitId).Where(y => !unitsToDispatch.Contains(y)).ToList(); foreach (var id in dispatchesToRemove) { @@ -1947,6 +1987,8 @@ public async Task> EditCall([FromBody] EditCallInpu ExternalId = call.ExternalIdentifier, IncidentId = call.IncidentNumber, ReferenceId = call.ReferenceNumber, + MapPage = call.MapPage, + PrimaryContactId = editCallInput.ContactId ?? call.Contacts?.FirstOrDefault(x => x.CallContactType == 0)?.ContactId, DestinationPoiId = call.DestinationPoiId, IndoorMapZoneId = call.IndoorMapZoneId, HasProtocols = call.Protocols?.Any() ?? false, @@ -1961,6 +2003,12 @@ public async Task> EditCall([FromBody] EditCallInpu if (editCallInput.IndoorMapZoneId == null) fieldValues.Unsupported(NewCallFieldKeys.IndoorLocation); + if (editCallInput.MapPage == null) + fieldValues.Unsupported(NewCallFieldKeys.MapPage); + + if (editCallInput.ContactId == null) + fieldValues.Unsupported(NewCallFieldKeys.PrimaryContact); + if (editCallInput.ProtocolIds == null) fieldValues.Unsupported(NewCallFieldKeys.Protocols); @@ -1981,7 +2029,17 @@ public async Task> EditCall([FromBody] EditCallInpu // Contacts plan Phase A (A5): replace the contact links only when the input carries them. if (editCallInput.ContactId != null || editCallInput.AdditionalContactIds != null) { - var contactLinks = await BuildCallContactLinksAsync(DepartmentId, editCallInput.ContactId, editCallInput.AdditionalContactIds); + // A contact picker the department hides is not on the client's edit form: when it sends nothing for it, the + // call keeps the contact(s) it has of that kind instead of losing them to the other picker's replacement. + var primaryContactId = editCallInput.ContactId; + if (primaryContactId == null && !fieldPolicy.IsVisible(NewCallFieldKeys.PrimaryContact)) + primaryContactId = call.Contacts?.FirstOrDefault(x => x.CallContactType == 0)?.ContactId; + + var additionalContactIds = editCallInput.AdditionalContactIds; + if (additionalContactIds == null && !fieldPolicy.IsVisible(NewCallFieldKeys.AdditionalContacts)) + additionalContactIds = call.Contacts?.Where(x => x.CallContactType == 1).Select(x => x.ContactId).ToList(); + + var contactLinks = await BuildCallContactLinksAsync(DepartmentId, primaryContactId, additionalContactIds); if (contactLinks == null) return BadRequest("One or more contacts do not belong to this department."); @@ -3162,6 +3220,7 @@ public static CallResultData ConvertCall(Call call, List proto callResult.ContactName = call.ContactName; callResult.ContactInfo = call.ContactNumber; callResult.ReferenceId = call.ReferenceNumber; + callResult.MapPage = call.MapPage; callResult.ExternalId = call.ExternalIdentifier; callResult.SubjectIdentifiers = ToSubjectIdentifiersResult(call.SubjectIdentifiers); callResult.Part2ConsentOnFile = call.Part2ConsentOnFile; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs index 18c70e45c..5d180989f 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs @@ -213,6 +213,16 @@ private async Task BuildConfigResultAsync(string key, int depar Resgrid.Framework.Logging.LogException(ex, $"{nameof(BuildConfigResultAsync)}: {nameof(IDepartmentSettingsService.GetStatusHoldToConfirmAsync)} failed for departmentId {departmentId}."); } + + try + { + result.Data.TransportDestinationChecks = await _departmentSettingsService.GetTransportDestinationChecksAsync(departmentId); + } + catch (System.Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, + $"{nameof(BuildConfigResultAsync)}: {nameof(IDepartmentSettingsService.GetTransportDestinationChecksAsync)} failed for departmentId {departmentId}."); + } } bool userModernApplicationSoundsEnabled = false; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs index 12fdb43ec..364df74f2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DispatchController.cs @@ -137,6 +137,8 @@ public async Task> GetNewCallData() var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId); var poiTypes = await _mappingService.GetPOITypesForDepartmentAsync(DepartmentId); + // Destinations carry their capabilities and availability (call form destination picker). + await _mappingService.PopulatePoiDetailsAsync(DepartmentId, (poiTypes ?? new List()).Where(x => x != null && x.IsDestination && x.Pois != null).SelectMany(x => x.Pois)); var pois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var canViewPII = await _authorizationService.CanUserViewPIIAsync(UserId, DepartmentId); // Shown for anyone without a status inside the auto-available hour (the department's own Available option, if any). @@ -318,6 +320,8 @@ public async Task> GetSetUnitStatusData(stri var activeCalls = await _dispatchScopeService.FilterCallsForUserAsync(DepartmentId, UserId, await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)); var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(DepartmentId); var poiTypes = await _mappingService.GetPOITypesForDepartmentAsync(DepartmentId); + // Destinations carry their capabilities and availability (status destination picker). + await _mappingService.PopulatePoiDetailsAsync(DepartmentId, (poiTypes ?? new List()).Where(x => x != null && x.IsDestination && x.Pois != null).SelectMany(x => x.Pois)); var callDefault = new CallResultData(); callDefault.CallId = "0"; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs index cf26d04c5..33ec9b8b8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs @@ -13,6 +13,7 @@ using Resgrid.Model.Providers; using System; using System.Globalization; +using System.Net.Mime; using Resgrid.Web.Services.Models.v4.Mapping; using Resgrid.Web.Services.Models.v4.Roles; using GeoJSON.Net; @@ -58,6 +59,7 @@ public class MappingController : V4AuthenticatedApiControllerbase private readonly IStaticMapService _staticMapService; private readonly ICustomMapMercatorTileRenderer _mercatorTileRenderer; private readonly IOfflineMapPackService _offlineMapPackService; + private readonly INearestDestinationService _nearestDestinationService; private readonly IOfflineMapPackStore _offlineMapPackStore; private readonly Microsoft.AspNetCore.Authorization.IAuthorizationService _policyAuthorization; private readonly Microsoft.Extensions.Localization.IStringLocalizer _legendStrings; @@ -89,9 +91,11 @@ public MappingController( Microsoft.AspNetCore.Authorization.IAuthorizationService policyAuthorization, ICustomMapMercatorTileRenderer mercatorTileRenderer, IOfflineMapPackService offlineMapPackService, - IOfflineMapPackStore offlineMapPackStore + IOfflineMapPackStore offlineMapPackStore, + INearestDestinationService nearestDestinationService ) { + _nearestDestinationService = nearestDestinationService; _offlineMapPackService = offlineMapPackService; _offlineMapPackStore = offlineMapPackStore; _mercatorTileRenderer = mercatorTileRenderer; @@ -805,7 +809,10 @@ public async Task> GetPois(int? poiTypeId, bool destina if (destinationOnly) filteredPoiTypes = filteredPoiTypes.Where(x => x.IsDestination); - foreach (var poiType in filteredPoiTypes.Where(x => x.Pois != null && x.Pois.Any())) + var withPois = filteredPoiTypes.Where(x => x.Pois != null && x.Pois.Any()).ToList(); + await _mappingService.PopulatePoiDetailsAsync(DepartmentId, withPois.SelectMany(x => x.Pois)); + + foreach (var poiType in withPois) { foreach (var poi in poiType.Pois) { @@ -838,6 +845,7 @@ public async Task> GetPoi(int poiId) if (poiType == null || poiType.DepartmentId != DepartmentId) return NotFound(); + await _mappingService.PopulatePoiDetailsAsync(DepartmentId, new[] { poi }); result.Data = ConvertPoiData(poi, poiType); result.PageSize = 1; result.Status = ResponseHelper.Success; @@ -846,6 +854,114 @@ public async Task> GetPoi(int poiId) return Ok(result); } + /// + /// The capabilities the department's POIs can offer (e.g. "Cath Lab", "Stroke Center"), in the department's order. + /// POIs name theirs in CapabilityIds. + /// + [HttpGet("GetPoiCapabilities")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task> GetPoiCapabilities() + { + var result = new PoiCapabilitiesResult(); + + foreach (var capability in await _mappingService.GetPoiCapabilitiesForDepartmentAsync(DepartmentId)) + result.Data.Add(new PoiCapabilityResultData { PoiCapabilityId = capability.PoiCapabilityId, Name = capability.Name, Sort = capability.Sort }); + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// The department's destinations nearest a point, best first: those that can take the patient (not closed, and with + /// not reported unavailable) before diversion before the rest; the closest are routed + /// (travel seconds), the others ranked by straight-line distance. + /// + /// Origin latitude (a unit's position or a call's location). + /// Origin longitude. + /// Only destinations offering this capability (GetPoiCapabilities); omit for all. + /// The travelling unit, so its travel mode and vehicle limits apply; omit for plain driving. + /// How many to return (1 to 50, default 10). + /// Request cancellation. + [HttpGet("GetNearestDestinations")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task> GetNearestDestinations(double latitude, double longitude, string capabilityId = null, int? unitId = null, + int take = 10, CancellationToken cancellationToken = default) + { + if (double.IsNaN(latitude) || double.IsNaN(longitude) || Math.Abs(latitude) > 90 || Math.Abs(longitude) > 180) + return BadRequest(); + + var result = new NearestDestinationsResult(); + var ranked = await _nearestDestinationService.GetNearestDestinationsAsync(DepartmentId, latitude, longitude, capabilityId, unitId, take, cancellationToken); + + foreach (var destination in ranked) + { + result.Data.Add(new NearestDestinationResultData + { + Poi = ConvertPoiData(destination.Poi, destination.Poi.Type), + DistanceMeters = Math.Round(destination.DistanceMeters), + TravelSeconds = destination.TravelSeconds >= 0 ? Math.Round(destination.TravelSeconds) : -1, + CanProvide = destination.CanProvide + }); + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + + /// + /// Sets a destination's availability (a hospital reporting capacity overload, a closed emergency department, or one + /// service down). Status 0 clears it. For the department's dispatchers (those who can create calls). + /// + [HttpPost("SetPoiAvailability")] + [Consumes(MediaTypeNames.Application.Json)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Authorize(Policy = ResgridResources.Call_Create)] + public async Task> SetPoiAvailability([FromBody] SetPoiAvailabilityInput input, CancellationToken cancellationToken) + { + if (input == null || input.PoiId <= 0 || !Enum.IsDefined(typeof(PoiAvailabilityStatuses), input.Status)) + return BadRequest(); + + DateTime? expiresOn = input.ExpiresInMinutes.HasValue && input.ExpiresInMinutes.Value > 0 + ? DateTime.UtcNow.AddMinutes(Math.Min(input.ExpiresInMinutes.Value, (int)PoiAvailabilityRules.MaxExpiry.TotalMinutes)) + : (DateTime?)null; + + try + { + // Writes finish even when the client disconnects: the dispatcher saw it sent. + await _mappingService.SetPoiAvailabilityAsync(DepartmentId, input.PoiId, (PoiAvailabilityStatuses)input.Status, input.UnavailableCapabilityIds, + input.Note, expiresOn, UserId, CancellationToken.None); + } + catch (ArgumentException) + { + return BadRequest(); + } + + var poi = await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, input.PoiId); + var result = new SetPoiAvailabilityResult(); + + if (poi != null) + { + await _mappingService.PopulatePoiDetailsAsync(DepartmentId, new[] { poi }); + result.Data = ConvertPoiData(poi, poi.Type); + } + + result.PageSize = result.Data == null ? 0 : 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + + return Ok(result); + } + public static GetMapLayersData ConvertMapLayerData(MapLayer layer) { var result = new GetMapLayersData(); @@ -856,11 +972,22 @@ public static GetMapLayersData ConvertMapLayerData(MapLayer layer) result.Type = layer.Type; result.Color = layer.Color; result.IsSearchable = layer.IsSearchable; - result.IsOnByDefault = layer.IsOnByDefault; + // An event layer is switched on by default while its event runs (every client reads this flag as is). + var now = DateTime.UtcNow; + result.IsOnByDefault = MapLayerEvents.IsShownByDefault(layer, now); result.AddedById = layer.AddedById; result.AddedOn = layer.AddedOn; result.UpdatedById = layer.UpdatedById; result.UpdatedOn = layer.UpdatedOn; + result.Description = layer.Description; + result.EventStartsOnUtc = layer.EventStartsOn.HasValue ? DateTime.SpecifyKind(layer.EventStartsOn.Value, DateTimeKind.Utc).ToString("o") : null; + result.EventEndsOnUtc = layer.EventEndsOn.HasValue ? DateTime.SpecifyKind(layer.EventEndsOn.Value, DateTimeKind.Utc).ToString("o") : null; + result.EventPhase = MapLayerEvents.GetPhase(layer, now).ToString().ToLowerInvariant(); + result.Organizer = layer.Organizer; + result.ContactName = layer.ContactName; + result.ContactPhone = layer.ContactPhone; + result.ContactEmail = layer.ContactEmail; + result.SourceFileName = layer.SourceFileName; result.Data = new GetMapLayersDataInfo(); result.Data.Type = ((GeoJSONObjectType)layer.Type).ToString(); @@ -901,7 +1028,14 @@ public static PoiResultData ConvertPoiData(Poi poi, PoiType poiType) PoiImage = poiType.Image, Marker = poiType.Marker, IsDestination = poiType.IsDestination, - IconUrl = TypeIcons.ApiIconUrl(Config.SystemBehaviorConfig.ResgridApiBaseUrl, poiType) + IconUrl = TypeIcons.ApiIconUrl(Config.SystemBehaviorConfig.ResgridApiBaseUrl, poiType), + CapabilityIds = poi.CapabilityIds?.ToList() ?? new List(), + Capabilities = poi.Capabilities?.ToList() ?? new List(), + AvailabilityStatus = poi.Availability?.Status ?? (int)PoiAvailabilityStatuses.Open, + UnavailableCapabilityIds = poi.Availability?.GetUnavailableCapabilityIds() ?? new List(), + AvailabilityNote = poi.Availability?.Note, + AvailabilitySetOnUtc = poi.Availability != null ? DateTime.SpecifyKind(poi.Availability.SetOn, DateTimeKind.Utc) : (DateTime?)null, + AvailabilityExpiresOnUtc = poi.Availability?.ExpiresOn.HasValue == true ? DateTime.SpecifyKind(poi.Availability.ExpiresOn.Value, DateTimeKind.Utc) : (DateTime?)null }; } @@ -1568,7 +1702,9 @@ public async Task> GetAllActiveLayers() var mapLayers = await _mappingService.GetMapLayersForTypeDepartmentAsync(DepartmentId, MapLayerTypes.TopLevel); if (mapLayers != null) { - foreach (var layer in mapLayers.Where(l => !l.IsDeleted && l.IsOnByDefault)) + // An event layer counts as on by default while its event runs (MapLayerEvents). + var now = DateTime.UtcNow; + foreach (var layer in mapLayers.Where(l => !l.IsDeleted && MapLayerEvents.IsShownByDefault(l, now))) { result.Data.Add(new ActiveLayerResultData { @@ -1578,7 +1714,7 @@ public async Task> GetAllActiveLayers() Type = layer.Type, Color = layer.Color, IsSearchable = layer.IsSearchable, - IsOnByDefault = layer.IsOnByDefault + IsOnByDefault = true }); } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs index b48be924b..92d2f39a2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordHydrantsController.cs @@ -120,7 +120,11 @@ public async Task> RecordMaintenance([Fro catch (Exception ex) { return Fail(ex); } } - /// JSON or CSV import (number, latitude, longitude, type, address, main_size, flow_gpm, owner). Validate every row before saving. + /// JSON or CSV import (number, latitude, longitude, type, address, main_size, flow_gpm, owner). Validate every row before saving. + /// CSV from another system: hydrant_type, flow_rate and location_address are read as type, flow_gpm and address, and flow_rating_color + /// (NFPA 291 colour, class letter or #hex) sets the flow class of a hydrant with no flow, and status takes hydrants out of service or returns + /// them to service (counted in TakenOutOfService and ReturnedToService); values those columns hold that cannot be read are imported as type + /// Other, a blank flow, no class or an unchanged service state and returned in UnmappedValues; other columns are skipped and returned in IgnoredColumns. [HttpPost("Import")] [Authorize(Policy = ResgridResources.Record_PreventionAdmin)] public async Task> Import([FromBody] HydrantImportInput input, CancellationToken cancellationToken) diff --git a/Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs b/Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs index 64d63fb38..f4192449b 100644 --- a/Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs +++ b/Web/Resgrid.Web.Services/Helpers/DispatchListHelper.cs @@ -10,14 +10,20 @@ namespace Resgrid.Web.Helpers /// Parsing for the pipe delimited DispatchList string clients send when creating or editing a call. ///
/// - /// The list has three forms. Blank means "everyone" on an immediate call (older clients send nothing, so this stays), - /// "0" means "everyone", and anything else is the explicit list. An explicit list with no entries ("|") dispatches - /// nobody: it is how a client creates or edits a call that goes to no one without the blank reading as "everyone". - /// A new immediate call made with a system API key is the exception: neither "0" nor a blank list pages everyone, so - /// it dispatches nobody unless the list names recipients (CallsController.SaveCall). + /// "0" means everyone ("All Call"), and it is the only way to ask for everyone. A blank list, or an explicit list with + /// no entries ("|"), dispatches nobody on a new call and keeps the recipients on an edit. (Until 2026-11-13 a blank + /// list paged everyone on an immediate call, because older clients sent nothing when nothing was picked.) Anything + /// else is the explicit list. A new immediate call made with a system API key never pages everyone on its own: "0" + /// dispatches nobody there unless the call is pending (CallsController.SaveCall). /// public static class DispatchListHelper { + /// True for "0", the explicit "everyone" (All Call). Blank is not everyone. + /// The DispatchList string as the client sent it. + /// True when the list asks for everyone. + public static bool IsEveryone(string dispatchList) => + dispatchList != null && dispatchList.Trim() == "0"; + /// /// Whether the list names anyone: "0" or at least one non-blank entry. Blank and an entry-less list ("|") do not, /// so a department that requires a dispatch list refuses a call sent to nobody. diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs index c9df69386..2f00283fa 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs @@ -172,6 +172,11 @@ public class CallResultData /// public string ReferenceId { get; set; } + /// + /// The map book page for the call's location, blank when none was entered. + /// + public string MapPage { get; set; } + /// /// External Id /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs index 00d0c0479..c79a949ba 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs @@ -66,9 +66,8 @@ public class EditCallInput /// /// Pipe separated list of users, units, roles and groups to dispatch ("P:", "U:", "R:" and "G:" prefixes), replacing - /// the call's dispatches. "0" dispatches everyone. Left blank it also means everyone, except on a pending call or when - /// the department hides the dispatch list, where blank keeps the call's dispatches. A list with no entries ("|") - /// removes every dispatch. + /// the call's dispatches. "0" adds everyone, and is the only way to. Left blank, the call keeps its dispatches. A list + /// with no entries ("|") removes every dispatch, except those of a recipient list the department hides. /// public string DispatchList { get; set; } @@ -128,6 +127,12 @@ public class EditCallInput /// public string ReferenceId { get; set; } + /// + /// Optional. The map book page for the call's location. Null or blank keeps the stored page; leave it out (null) when + /// the client has no input for it, so the department's map page requirement is not enforced against it. + /// + public string MapPage { get; set; } + /// /// Optional. Indoor map zone the incident is in. Blank keeps the stored zone; leave it out (null) when the client /// has no indoor location picker, which also leaves the department's indoor location requirement unenforced. diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs index cc3c4b012..a399be8b1 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs @@ -67,11 +67,11 @@ public class NewCallInput /// P:c0a0f71a-0758-434c-868b-c77d245db78a|P:c0a0f71a-0758-434c-868b-c77d245db78a|U:1995012|G:991031|R:893139 /// /// - /// "0" dispatches everyone. Left blank, an immediate call also goes to everyone (older clients send nothing), and a - /// pending call has no proposed recipients. Send a list with no entries ("|") to create a call that dispatches - /// nobody; a department that requires a dispatch list refuses it. A system API key never pages the whole department - /// on its own: its immediate call with "0" or a blank list dispatches nobody, so name the recipients (on a pending - /// call "0" is kept as the proposal, since nobody is paged until a dispatcher sends it). + /// "0" dispatches everyone, and is the only way to: a blank list, or one with no entries ("|"), creates a call that + /// dispatches nobody (an informational call; for a pending call, no proposed recipients). A department that requires + /// a dispatch list refuses either. A system API key never pages the whole department on its own: its immediate call + /// with "0" dispatches nobody, so name the recipients (on a pending call "0" is kept as the proposal, since nobody is + /// paged until a dispatcher sends it). /// public string DispatchList { get; set; } @@ -137,6 +137,12 @@ public class NewCallInput /// public string ReferenceId { get; set; } + /// + /// Optional. The map book page for the call's location. Leave it out (null) when the client has no input for it: the + /// department's map page requirement is then not enforced; sent blank, it is. + /// + public string MapPage { get; set; } + /// /// Optional. Id of an existing call in the department to link this call to (shown as a linked call on both). Leave it /// out (null) when the client has no way to pick one: the department's linked-call requirement is then not enforced; diff --git a/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs b/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs index 60f678619..38461f1ab 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs @@ -179,6 +179,14 @@ public class GetConfigResultData /// public bool StatusHoldToConfirm { get; set; } + /// + /// Transport destination checks (Dispatch Settings): when a unit's Transporting or At Hospital status goes to a + /// destination POI other than its call's assigned destination, the apps require a reason (the status note) and + /// Resgrid records the change as a call note. The apps pre-select the call's destination either way. Only signed-in + /// callers get it; false otherwise. + /// + public bool TransportDestinationChecks { get; set; } + /// /// Latitude every map in every client should open on for this department. Resolved from the /// department's configured map center, falling back to its address and finally to a system diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapLayersResult.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapLayersResult.cs index d27ab25b0..bf4facb6f 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapLayersResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/GetMapLayersResult.cs @@ -57,6 +57,9 @@ public class GetMapLayersData public bool IsSearchable { get; set; } + /// + /// Whether maps start with the layer switched on: the department's setting, or (event layers) the event running now. + /// public bool IsOnByDefault { get; set; } public string AddedById { get; set; } @@ -66,6 +69,29 @@ public class GetMapLayersData public string UpdatedById { get; set; } public DateTime UpdatedOn { get; set; } + + /// What the layer is for. Event layers carry the event's summary here. + public string Description { get; set; } + + /// Event start (UTC, ISO 8601), blank for a layer with no event dates. + public string EventStartsOnUtc { get; set; } + + /// Event end (UTC, ISO 8601), blank when open-ended or not an event layer. + public string EventEndsOnUtc { get; set; } + + /// "none", "upcoming", "active" or "ended", relative to the request time. + public string EventPhase { get; set; } + + public string Organizer { get; set; } + + public string ContactName { get; set; } + + public string ContactPhone { get; set; } + + public string ContactEmail { get; set; } + + /// The file the shapes were imported from (KML, KMZ or GeoJSON), blank for a drawn layer. + public string SourceFileName { get; set; } } public class GetMapLayersDataInfo diff --git a/Web/Resgrid.Web.Services/Models/v4/Mapping/PoiResultModels.cs b/Web/Resgrid.Web.Services/Models/v4/Mapping/PoiResultModels.cs index ad1eeab7d..3502256b1 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Mapping/PoiResultModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Mapping/PoiResultModels.cs @@ -52,6 +52,90 @@ public class PoiResultData /// immutable); null when the type has no uploaded icon, so PoiImage / ImagePath stay the marker icon. /// public string IconUrl { get; set; } + + /// + /// Ids of the department capabilities this POI offers (GetPoiCapabilities), in the department's order. Empty when it + /// offers none, or on endpoints that do not fill capabilities. + /// + public List CapabilityIds { get; set; } = new List(); + + /// The names of , same order (e.g. "Cath Lab", "Trauma Center"). + public List Capabilities { get; set; } = new List(); + + /// + /// The destination's current availability (Resgrid.Model.PoiAvailabilityStatuses): 0 open, 1 limited, 2 diversion, + /// 3 closed. An expired report reads as 0. + /// + public int AvailabilityStatus { get; set; } + + /// Capability ids the destination cannot provide right now (with a non-open ). + public List UnavailableCapabilityIds { get; set; } = new List(); + + /// What the hospital reported, e.g. "ED capacity overload, CT down". + public string AvailabilityNote { get; set; } + + /// When the current report was set (UTC), or null when open. + public System.DateTime? AvailabilitySetOnUtc { get; set; } + + /// When the current report lapses (UTC); null when open or when it lasts until changed. + public System.DateTime? AvailabilityExpiresOnUtc { get; set; } + } + + /// A capability the department's POIs can offer. + public class PoiCapabilityResultData + { + public string PoiCapabilityId { get; set; } + public string Name { get; set; } + public int Sort { get; set; } + } + + public class PoiCapabilitiesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// One destination of GetNearestDestinations, best first. + public class NearestDestinationResultData + { + /// The destination, with capabilities and availability. + public PoiResultData Poi { get; set; } + + /// Straight-line distance from the origin. + public double DistanceMeters { get; set; } + + /// Travel seconds from the origin; -1 when not looked up (beyond the shortlist, or the lookup failed). + public double TravelSeconds { get; set; } = -1; + + /// True when it can take the patient now (not closed; the asked capability not reported unavailable). + public bool CanProvide { get; set; } + } + + public class NearestDestinationsResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + } + + /// SetPoiAvailability body. + public class SetPoiAvailabilityInput + { + public int PoiId { get; set; } + + /// 0 open (clears the report), 1 limited, 2 diversion, 3 closed. + public int Status { get; set; } + + /// Capability ids the destination cannot provide right now. + public List UnavailableCapabilityIds { get; set; } = new List(); + + public string Note { get; set; } + + /// Minutes until the report lapses (at most a week); null or 0 lasts until changed. + public int? ExpiresInMinutes { get; set; } + } + + public class SetPoiAvailabilityResult : StandardApiResponseV4Base + { + /// The destination after the change. + public PoiResultData Data { get; set; } } public class PoiTypesResult : StandardApiResponseV4Base diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 3ce2bc3ff..f6f4c77c8 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -3349,6 +3349,31 @@ GetMapLayersResult object + + + The capabilities the department's POIs can offer (e.g. "Cath Lab", "Stroke Center"), in the department's order. + POIs name theirs in CapabilityIds. + + + + + The department's destinations nearest a point, best first: those that can take the patient (not closed, and with + not reported unavailable) before diversion before the rest; the closest are routed + (travel seconds), the others ranked by straight-line distance. + + Origin latitude (a unit's position or a call's location). + Origin longitude. + Only destinations offering this capability (GetPoiCapabilities); omit for all. + The travelling unit, so its travel mode and vehicle limits apply; omit for plain driving. + How many to return (1 to 50, default 10). + Request cancellation. + + + + Sets a destination's availability (a hospital reporting capacity overload, a closed emergency department, or one + service down). Status 0 clears it. For the department's dispatchers (those who can create calls). + + Gets all indoor maps for the department. @@ -4155,7 +4180,11 @@ Removes many hydrants at once: the ids given (Scope 0), every imported hydrant (1) or every hydrant (2). Scopes 1 and 2 need ExpectedCount equal to the number that match. - JSON or CSV import (number, latitude, longitude, type, address, main_size, flow_gpm, owner). Validate every row before saving. + JSON or CSV import (number, latitude, longitude, type, address, main_size, flow_gpm, owner). Validate every row before saving. + CSV from another system: hydrant_type, flow_rate and location_address are read as type, flow_gpm and address, and flow_rating_color + (NFPA 291 colour, class letter or #hex) sets the flow class of a hydrant with no flow, and status takes hydrants out of service or returns + them to service (counted in TakenOutOfService and ReturnedToService); values those columns hold that cannot be read are imported as type + Other, a blank flow, no class or an unchanged service state and returned in UnmappedValues; other columns are skipped and returned in IgnoredColumns. Map layer for the response map and the apps; bounded to a viewport when bounds are given. @@ -10107,6 +10136,11 @@ Reference Id + + + The map book page for the call's location, blank when none was entered. + + External Id @@ -10450,9 +10484,8 @@ Pipe separated list of users, units, roles and groups to dispatch ("P:", "U:", "R:" and "G:" prefixes), replacing - the call's dispatches. "0" dispatches everyone. Left blank it also means everyone, except on a pending call or when - the department hides the dispatch list, where blank keeps the call's dispatches. A list with no entries ("|") - removes every dispatch. + the call's dispatches. "0" adds everyone, and is the only way to. Left blank, the call keeps its dispatches. A list + with no entries ("|") removes every dispatch, except those of a recipient list the department hides. @@ -10511,6 +10544,12 @@ Reference Id + + + Optional. The map book page for the call's location. Null or blank keeps the stored page; leave it out (null) when + the client has no input for it, so the department's map page requirement is not enforced against it. + + Optional. Indoor map zone the incident is in. Blank keeps the stored zone; leave it out (null) when the client @@ -10792,11 +10831,11 @@ P:c0a0f71a-0758-434c-868b-c77d245db78a|P:c0a0f71a-0758-434c-868b-c77d245db78a|U:1995012|G:991031|R:893139 - "0" dispatches everyone. Left blank, an immediate call also goes to everyone (older clients send nothing), and a - pending call has no proposed recipients. Send a list with no entries ("|") to create a call that dispatches - nobody; a department that requires a dispatch list refuses it. A system API key never pages the whole department - on its own: its immediate call with "0" or a blank list dispatches nobody, so name the recipients (on a pending - call "0" is kept as the proposal, since nobody is paged until a dispatcher sends it). + "0" dispatches everyone, and is the only way to: a blank list, or one with no entries ("|"), creates a call that + dispatches nobody (an informational call; for a pending call, no proposed recipients). A department that requires + a dispatch list refuses either. A system API key never pages the whole department on its own: its immediate call + with "0" dispatches nobody, so name the recipients (on a pending call "0" is kept as the proposal, since nobody is + paged until a dispatcher sends it). @@ -10861,6 +10900,12 @@ Reference Id + + + Optional. The map book page for the call's location. Leave it out (null) when the client has no input for it: the + department's map page requirement is then not enforced; sent blank, it is. + + Optional. Id of an existing call in the department to link this call to (shown as a linked call on both). Leave it @@ -13306,6 +13351,14 @@ false otherwise. + + + Transport destination checks (Dispatch Settings): when a unit's Transporting or At Hospital status goes to a + destination POI other than its call's assigned destination, the apps require a reason (the status note) and + Resgrid records the change as a call note. The apps pre-select the call's destination either way. Only signed-in + callers get it; false otherwise. + + Latitude every map in every client should open on for this department. Resolved from the @@ -15411,6 +15464,26 @@ Map Layers + + + Whether maps start with the layer switched on: the department's setting, or (event layers) the event running now. + + + + What the layer is for. Event layers carry the event's summary here. + + + Event start (UTC, ISO 8601), blank for a layer with no event dates. + + + Event end (UTC, ISO 8601), blank when open-ended or not an event layer. + + + "none", "upcoming", "active" or "ended", relative to the request time. + + + The file the shapes were imported from (KML, KMZ or GeoJSON), blank for a drawn layer. + One unit's map: its own position (when it reports one) and the positions of the members seated in its active @@ -15487,6 +15560,66 @@ immutable); null when the type has no uploaded icon, so PoiImage / ImagePath stay the marker icon. + + + Ids of the department capabilities this POI offers (GetPoiCapabilities), in the department's order. Empty when it + offers none, or on endpoints that do not fill capabilities. + + + + The names of , same order (e.g. "Cath Lab", "Trauma Center"). + + + + The destination's current availability (Resgrid.Model.PoiAvailabilityStatuses): 0 open, 1 limited, 2 diversion, + 3 closed. An expired report reads as 0. + + + + Capability ids the destination cannot provide right now (with a non-open ). + + + What the hospital reported, e.g. "ED capacity overload, CT down". + + + When the current report was set (UTC), or null when open. + + + When the current report lapses (UTC); null when open or when it lasts until changed. + + + A capability the department's POIs can offer. + + + One destination of GetNearestDestinations, best first. + + + The destination, with capabilities and availability. + + + Straight-line distance from the origin. + + + Travel seconds from the origin; -1 when not looked up (beyond the shortlist, or the lookup failed). + + + True when it can take the patient now (not closed; the asked capability not reported unavailable). + + + SetPoiAvailability body. + + + 0 open (clears the report), 1 limited, 2 diversion, 3 closed. + + + Capability ids the destination cannot provide right now. + + + Minutes until the report lapses (at most a week); null or 0 lasts until changed. + + + The destination after the change. + "indoor_zone" or "custom_region" @@ -20517,13 +20650,18 @@ Parsing for the pipe delimited DispatchList string clients send when creating or editing a call. - The list has three forms. Blank means "everyone" on an immediate call (older clients send nothing, so this stays), - "0" means "everyone", and anything else is the explicit list. An explicit list with no entries ("|") dispatches - nobody: it is how a client creates or edits a call that goes to no one without the blank reading as "everyone". - A new immediate call made with a system API key is the exception: neither "0" nor a blank list pages everyone, so - it dispatches nobody unless the list names recipients (CallsController.SaveCall). + "0" means everyone ("All Call"), and it is the only way to ask for everyone. A blank list, or an explicit list with + no entries ("|"), dispatches nobody on a new call and keeps the recipients on an edit. (Until 2026-11-13 a blank + list paged everyone on an immediate call, because older clients sent nothing when nothing was picked.) Anything + else is the explicit list. A new immediate call made with a system API key never pages everyone on its own: "0" + dispatches nobody there unless the call is pending (CallsController.SaveCall). + + True for "0", the explicit "everyone" (All Call). Blank is not everyone. + The DispatchList string as the client sent it. + True when the list asks for everyone. + Whether the list names anyone: "0" or at least one non-blank entry. Blank and an entry-less list ("|") do not, diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx index 5f1a9dcde..38047ce98 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/adminAssist/AdminAssistElement.tsx @@ -12,8 +12,8 @@ import NotificationPreview from './NotificationPreview'; import SecurityPreview from './SecurityPreview'; import SetupWizard from './SetupWizard'; import AreaSetupChoice from './AreaSetupChoice'; -import { ModuleCard, ModuleReadiness, isKey, tiers, type Module } from './ModuleViews'; -import { Chip, FindingGroups, FindingRow, FindingSubjectsContext, Ibox, MetricRow, ResultChip, SeverityChip, byPriority, format, isSuggestion, type Finding, type FindingSubject } from './SetupVisuals'; +import { ModuleCard, ModuleReadiness, ModuleTile, isKey, tiers, type Module } from './ModuleViews'; +import { Chip, FilterPills, FindingGroups, FindingRow, FindingSubjectsContext, Ibox, MetricRow, ResultChip, SeverityChip, StatusBar, byPriority, countFindings, countsLabel, format, isSuggestion, type Finding, type FindingSubject } from './SetupVisuals'; import AskPanel from './AskPanel'; import PlansPanel, { type PlanSource } from './PlansPanel'; import TroubleshootPanel from './TroubleshootPanel'; @@ -36,7 +36,7 @@ type Catalog = { moduleImpactTypes: string[]; permissionImpactTypes: string[]; impactSettings: string[]; version: string; strings: Record; rightToLeft: boolean; canSetup: boolean; areas: Module[]; capabilities: Capability[]; - settings: { id: string; valueType: string; labelKey: string; helpKey: string; location: Location; impact: { risk: string; timingKey: string; reversibilityKey: string } }[]; + settings: { id: string; areaId: string; valueType: string; labelKey: string; helpKey: string; location: Location; impact: { risk: string; timingKey: string; reversibilityKey: string } }[]; articles: { id: string; locale: string; body: string; sourcePath: string; anchor: string; packVersion: string }[]; packs: { id: string; labelKey: string; purposeKey: string; areaIds: string[]; prerequisiteKeys: string[] }[]; }; @@ -54,12 +54,26 @@ type SearchHit = { id: string; titleKey: string; excerpt: string; sourcePath: st type Followup = { digestsAvailable: boolean; preferences: { revision: number; digestEnabled: boolean; quietStartHour: number; quietEndHour: number; lastAttemptOutcome: string | null }; worker: { lastEvaluatedOn: string | null } }; const endpoint = 'api/v4/AdminAssist/'; // Admin Assist tabs: Setup Wizard, Setup Report and Explore, then Admin AI, which holds the workspace tools. The page runs in -// setup mode until Admin.Assist and Ai.AdminAssist are both on; Admin AI is shown but disabled until then. -const aiTabIds = ['ask', 'overview', 'health', 'worklist', 'reference', 'history', 'plans', 'troubleshoot']; +// setup mode until Admin.Assist and Ai.AdminAssist are both on; Admin AI is shown but disabled until then. The setup summary +// lives only on Setup Report (with the impact previews once Admin AI is on); Admin AI holds the tools that need it. +const aiTabIds = ['ask', 'health', 'worklist', 'reference', 'history', 'plans', 'troubleshoot']; +const tabIcons: Record = { wizard: 'fa-magic', report: 'fa-clipboard', explore: 'fa-compass', ai: 'fa-comments', ask: 'fa-comments-o', + health: 'fa-heartbeat', worklist: 'fa-tasks', reference: 'fa-book', history: 'fa-history', plans: 'fa-list-ol', troubleshoot: 'fa-stethoscope' }; const tabsFor = (catalog: Catalog, setup: boolean) => ({ primary: [...(catalog.canSetup ? ['wizard'] : []), 'report', 'explore'], ai: setup ? [] : aiTabIds.filter(id => id === 'ask' ? catalog.askAvailable : id === 'plans' ? catalog.plansAvailable : id === 'troubleshoot' ? catalog.troubleshootingAvailable : true), }); +type Tone = 'ok' | 'fail' | 'unknown' | 'info' | 'muted'; +const resultIcons: Record = { + Fail: { icon: 'fa-times-circle', tone: 'fail' }, Unknown: { icon: 'fa-question-circle', tone: 'unknown' }, Pass: { icon: 'fa-check-circle', tone: 'ok' }, NotApplicable: { icon: 'fa-minus-circle', tone: 'muted' }, +}; +const healthResults = ['Fail', 'Unknown', 'Pass', 'NotApplicable']; +const referencePage = 50; +// Indexed by WorkItem.reviewStatus. +const reviewStatuses: { key: string; icon: string; tone: Tone }[] = [ + { key: 'Unassigned', icon: 'fa-circle-o', tone: 'muted' }, { key: 'Assigned', icon: 'fa-user', tone: 'info' }, { key: 'InReview', icon: 'fa-eye', tone: 'info' }, + { key: 'AcceptedException', icon: 'fa-shield', tone: 'info' }, { key: 'Resolved', icon: 'fa-check', tone: 'ok' }, +]; // No external URLs, scheme-relative links or redirects from catalog data. const safeLocalLink = (url: string | null | undefined) => url && /^\/User\/[A-Za-z0-9]+\/[A-Za-z0-9]+(?:\?aa=[A-Za-z0-9._-]+)?$/.test(url) ? url : undefined; @@ -73,7 +87,10 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab return !destination || !catalog?.canSetup ? destination : `${destination}${destination.includes('?') ? '&' : '?'}aaReturn=${tab === 'wizard' ? 'wizard' : 'report'}`; }; const [query, setQuery] = useState(''); - const [addonsOnly, setAddonsOnly] = useState(false); + // Explore: a tier filter over the module tiles, and the module open in full ('' shows the tiles). + const [exploreTier, setExploreTier] = useState(''); + const [exploreOpen, setExploreOpen] = useState(''); + const lastExplored = useRef(''); const [healthArea, setHealthArea] = useState(''); const [healthResult, setHealthResult] = useState(''); const [error, setError] = useState(''); @@ -83,6 +100,8 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab const [worklist, setWorklist] = useState([]); const [hits, setHits] = useState([]); const [workFilter, setWorkFilter] = useState('active'); + const [referenceArea, setReferenceArea] = useState(''); + const [referenceLimit, setReferenceLimit] = useState(referencePage); const [followup, setFollowup] = useState(null); const request = useRef(null); const protectionRevision = useRef(0); @@ -125,6 +144,18 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab const { primary, ai } = tabsFor(catalog, setup); if (!primary.includes(tab) && !ai.includes(tab)) setTab(aiTabIds.includes(tab) && ai.length > 0 ? ai[0] : primary[0]); }, [catalog, setup, tab]); + // Opening a module moves focus to its heading; returning to the tiles moves it back to the tile that was opened. + useEffect(() => { + const target = exploreOpen ? `rgaa-module-${exploreOpen}` : lastExplored.current ? `rgaa-tile-${lastExplored.current}` : ''; + if (target) document.getElementById(target)?.focus(); + }, [exploreOpen]); + // The worklist and change history load when their tab opens, however it was opened (a tab click, a link or the page's first tab). + const catalogReady = catalog !== null; + useEffect(() => { + if (!catalog || !tabsFor(catalog, setup).ai.includes(tab)) return; + if (tab === 'history') void loadHistory(true); + if (tab === 'worklist') void loadWorklist(); + }, [tab, catalogReady]); async function save(operation: string, targetId: string | null = null, choice: string | null = null, reasonCode: string | null = null, revisitOnUtc: string | null = null) { if (!overview || !catalog || busy) return; @@ -230,21 +261,38 @@ export default function AdminAssistElement({ page, setup, loadingLabel, errorLab const lower = (keys: string[]) => keys.map(k => t(k)).join(' ').toLocaleLowerCase(); const needle = query.trim().toLocaleLowerCase(); // A module matches its own text or any of its features, including detail features that are not listed on the card. - const exploreModules = areas.filter(m => (!addonsOnly || m.tier === 'AddOn') && (!needle || lower([m.labelKey, m.purposeKey, m.valueKey]).includes(needle) || - catalog.capabilities.some(c => c.areaId === m.id && lower([c.labelKey, c.purposeKey]).includes(needle)))); + const featureMatches = (moduleId: string) => !needle ? [] : catalog.capabilities.filter(c => c.areaId === moduleId && lower([c.labelKey, c.purposeKey]).includes(needle)).map(c => t(c.labelKey)); + const searchModules = areas.filter(m => !needle || lower([m.labelKey, m.purposeKey, m.valueKey]).includes(needle) || featureMatches(m.id).length > 0); + const exploreModules = searchModules.filter(m => !exploreTier || m.tier === exploreTier); + // Tiles are shown tier by tier; previous and next follow the same order. + const exploreSequence = tiers.flatMap(tier => exploreModules.filter(m => m.tier === tier)); + const openModule = areas.find(m => m.id === exploreOpen); + const openIndex = exploreSequence.findIndex(m => m.id === exploreOpen); + const showModule = (id: string) => { lastExplored.current = id; setExploreOpen(id); }; const reviewState: 'current' | 'changed' | 'none' = !workspace.reviewEvidence ? 'none' : workspace.reviewEvidence.catalogVersion !== catalog.version || workspace.reviewEvidence.snapshotRevision !== report.snapshot.revision || (workspace.reviewEvidence.scopeRevision ?? 0) !== (workspace.scopeRevision ?? 0) ? 'changed' : 'current'; - const showTab = (id: string) => { setTab(id); if (id === 'history') void loadHistory(true); if (id === 'worklist') void loadWorklist(); }; - const tabLink = (id: string) =>
  • - { event.preventDefault(); if (!busy) showTab(id); }}>{ui(id)} + const showTab = (id: string) => { if (id === 'explore') setExploreOpen(''); setTab(id); }; + // Own classes, not Bootstrap .nav: the theme's sidebar rule `.nav > li.active` would otherwise paint these tabs. + const tabButton = (id: string, className: string) =>
  • +