diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index abfc222f..46377988 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -312,6 +312,21 @@ Person Call Release Status + + Unit Call Dispatch Status + + + Unit Call Release Status + + + These default unit statuses only use the built-in unit state types. Use the unit type override table below when a unit type needs one of its own custom statuses. + + + Unit Type Status Overrides + + + Only unit types with custom unit statuses appear here. Leave a value on Default to use the department-wide built-in status above. + Personnel Sorting diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx index 3d09b45f..94ec745a 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -192,6 +192,21 @@ + + Unit Call Dispatch Status + + + Unit Call Release Status + + + These default unit statuses only use the built-in unit state types. Use the unit type override table below when a unit type needs one of its own custom statuses. + + + Unit Type Status Overrides + + + Only unit types with custom unit statuses appear here. Leave a value on Default to use the department-wide built-in status above. + diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index a341d0cc..17465002 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -51,5 +51,8 @@ public enum DepartmentSettingTypes MappingMapboxStyleUrl = 47, MappingMapboxAccessToken = 48, TtsLanguage = 49, + UnitCallDispatchStatusToSet = 50, + UnitCallReleaseStatusToSet = 51, + UnitCallStatusOverridesByUnitType = 52, } } diff --git a/Core/Resgrid.Model/Services/ICallDispatchStatusService.cs b/Core/Resgrid.Model/Services/ICallDispatchStatusService.cs new file mode 100644 index 00000000..9cd890ef --- /dev/null +++ b/Core/Resgrid.Model/Services/ICallDispatchStatusService.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; + +namespace Resgrid.Model.Services +{ + public interface ICallDispatchStatusService + { + Task ApplyDispatchStatusesAsync(Call call, IEnumerable groupIds = null, IEnumerable unitIds = null, CancellationToken cancellationToken = default(CancellationToken)); + + Task ApplyReleaseStatusesAsync(Call call, IEnumerable groupIds = null, IEnumerable unitIds = null, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 1e5a334a..cd646867 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -283,6 +283,15 @@ public interface IDepartmentSettingsService Task GetUnitDispatchAlsoDispatchToGroupAsync(int departmentId); + Task GetUnitCallDispatchStatusToSetAsync(int departmentId); + + Task GetUnitCallReleaseStatusToSetAsync(int departmentId); + + Task> GetUnitCallStatusOverridesByUnitTypeAsync(int departmentId); + + Task SetUnitCallStatusOverridesByUnitTypeAsync(int departmentId, + List overrides, CancellationToken cancellationToken = default(CancellationToken)); + Task GetPersonnelOnUnitSetUnitStatusAsync(int departmentId, bool bypassCache = false); Task SetDepartmentModuleSettingsAsync(int departmentId, DepartmentModuleSettings settings, CancellationToken cancellationToken = default(CancellationToken)); diff --git a/Core/Resgrid.Model/UnitTypeCallStatusOverride.cs b/Core/Resgrid.Model/UnitTypeCallStatusOverride.cs new file mode 100644 index 00000000..4bdd51d1 --- /dev/null +++ b/Core/Resgrid.Model/UnitTypeCallStatusOverride.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using ProtoBuf; + +namespace Resgrid.Model +{ + [ProtoContract] + public class UnitTypeCallStatusOverride + { + public UnitTypeCallStatusOverride() + { + DispatchStatus = -1; + ReleaseStatus = -1; + } + + [ProtoMember(1)] + public int UnitTypeId { get; set; } + + [ProtoMember(2)] + public int DispatchStatus { get; set; } + + [ProtoMember(3)] + public int ReleaseStatus { get; set; } + } + + [ProtoContract] + public class UnitTypeCallStatusOverrideSetting + { + public UnitTypeCallStatusOverrideSetting() + { + Overrides = new List(); + } + + [ProtoMember(1)] + public List Overrides { get; set; } + } +} diff --git a/Core/Resgrid.Services/CallDispatchStatusService.cs b/Core/Resgrid.Services/CallDispatchStatusService.cs new file mode 100644 index 00000000..22fde959 --- /dev/null +++ b/Core/Resgrid.Services/CallDispatchStatusService.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class CallDispatchStatusService : ICallDispatchStatusService + { + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IDepartmentsService _departmentsService; + private readonly IShiftsService _shiftsService; + private readonly IActionLogsService _actionLogsService; + private readonly IUnitsService _unitsService; + private readonly ICustomStateService _customStateService; + + public CallDispatchStatusService( + IDepartmentSettingsService departmentSettingsService, + IDepartmentsService departmentsService, + IShiftsService shiftsService, + IActionLogsService actionLogsService, + IUnitsService unitsService, + ICustomStateService customStateService) + { + _departmentSettingsService = departmentSettingsService; + _departmentsService = departmentsService; + _shiftsService = shiftsService; + _actionLogsService = actionLogsService; + _unitsService = unitsService; + _customStateService = customStateService; + } + + public async Task ApplyDispatchStatusesAsync(Call call, IEnumerable groupIds = null, IEnumerable unitIds = null, CancellationToken cancellationToken = default(CancellationToken)) + { + await ApplyStatusesAsync(call, groupIds, unitIds, true, cancellationToken); + } + + public async Task ApplyReleaseStatusesAsync(Call call, IEnumerable groupIds = null, IEnumerable unitIds = null, CancellationToken cancellationToken = default(CancellationToken)) + { + await ApplyStatusesAsync(call, groupIds, unitIds, false, cancellationToken); + } + + private async Task ApplyStatusesAsync(Call call, IEnumerable groupIds, IEnumerable unitIds, bool isDispatch, CancellationToken cancellationToken) + { + if (call == null) + throw new ArgumentNullException(nameof(call)); + + var resolvedGroupIds = GetDistinctIds(groupIds, call.GroupDispatches?.Select(x => x.DepartmentGroupId)); + var resolvedUnitIds = GetDistinctIds(unitIds, call.UnitDispatches?.Select(x => x.UnitId)); + + if (!resolvedGroupIds.Any() && !resolvedUnitIds.Any()) + return; + + var department = await _departmentsService.GetDepartmentByIdAsync(call.DepartmentId); + + if (resolvedGroupIds.Any()) + await ApplyPersonnelStatusesAsync(call, department, resolvedGroupIds, isDispatch, cancellationToken); + + if (resolvedUnitIds.Any()) + await ApplyUnitStatusesAsync(call, department, resolvedUnitIds, isDispatch, cancellationToken); + } + + private async Task ApplyPersonnelStatusesAsync(Call call, Department department, IReadOnlyCollection groupIds, bool isDispatch, CancellationToken cancellationToken) + { + var dispatchShiftInsteadOfGroup = await _departmentSettingsService.GetDispatchShiftInsteadOfGroupAsync(call.DepartmentId); + var autoSetStatusForShiftPersonnel = await _departmentSettingsService.GetAutoSetStatusForShiftDispatchPersonnelAsync(call.DepartmentId); + + if (!dispatchShiftInsteadOfGroup || !autoSetStatusForShiftPersonnel) + return; + + var shiftUserIds = await GetShiftUserIdsAsync(call, department, groupIds); + if (!shiftUserIds.Any()) + return; + + var statusToSet = isDispatch + ? await _departmentSettingsService.GetShiftCallDispatchPersonnelStatusToSetAsync(call.DepartmentId) + : await _departmentSettingsService.GetShiftCallReleasePersonnelStatusToSetAsync(call.DepartmentId); + + if (statusToSet < 0) + statusToSet = isDispatch ? (int)ActionTypes.RespondingToScene : (int)ActionTypes.StandingBy; + + foreach (var userId in shiftUserIds) + { + await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, statusToSet, null, call.CallId, cancellationToken); + } + } + + private async Task ApplyUnitStatusesAsync(Call call, Department department, IReadOnlyCollection unitIds, bool isDispatch, CancellationToken cancellationToken) + { + var defaultStatusToSet = isDispatch + ? await _departmentSettingsService.GetUnitCallDispatchStatusToSetAsync(call.DepartmentId) + : await _departmentSettingsService.GetUnitCallReleaseStatusToSetAsync(call.DepartmentId); + + if (defaultStatusToSet < 0) + defaultStatusToSet = isDispatch ? (int)UnitStateTypes.Responding : (int)UnitStateTypes.Released; + + var resolvedStatuses = await ResolveUnitStatusesAsync(call.DepartmentId, unitIds, defaultStatusToSet, isDispatch); + + var timestamp = DateTime.UtcNow; + var localTimestamp = department != null ? DateTimeHelpers.GetLocalDateTime(timestamp, department.TimeZone) : timestamp; + + foreach (var unitId in unitIds) + { + var statusToSet = resolvedStatuses.TryGetValue(unitId, out var resolvedStatus) ? resolvedStatus : defaultStatusToSet; + + var state = new UnitState + { + UnitId = unitId, + State = statusToSet, + Timestamp = timestamp, + LocalTimestamp = localTimestamp, + DestinationId = call.CallId, + DestinationType = (int)DestinationEntityTypes.Call + }; + + await _unitsService.SetUnitStateAsync(state, call.DepartmentId, cancellationToken); + } + } + + private async Task> ResolveUnitStatusesAsync(int departmentId, IReadOnlyCollection unitIds, + int defaultStatusToSet, bool isDispatch) + { + var resolvedStatuses = unitIds.ToDictionary(x => x, _ => defaultStatusToSet); + var unitTypeOverrides = await _departmentSettingsService.GetUnitCallStatusOverridesByUnitTypeAsync(departmentId); + + if (unitTypeOverrides == null || !unitTypeOverrides.Any()) + return resolvedStatuses; + + var unitTypeOverrideLookup = unitTypeOverrides + .Where(x => x != null && x.UnitTypeId > 0) + .GroupBy(x => x.UnitTypeId) + .ToDictionary(x => x.Key, x => x.Last()); + + if (!unitTypeOverrideLookup.Any()) + return resolvedStatuses; + + var units = (await Task.WhenAll(unitIds.Select(x => _unitsService.GetUnitByIdAsync(x)))) + .Where(x => x != null) + .ToList(); + + if (!units.Any()) + return resolvedStatuses; + + var unitTypesByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var unitTypeName in units + .Select(x => x.Type) + .Where(x => !String.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + var unitType = await _unitsService.GetUnitTypeByNameAsync(departmentId, unitTypeName); + + if (unitType != null) + unitTypesByName[unitTypeName] = unitType; + } + + var customStateDetailIdsByStateId = new Dictionary>(); + var customStateIds = unitTypesByName.Values + .Where(x => x.CustomStatesId.HasValue && x.CustomStatesId.Value > 0 && unitTypeOverrideLookup.ContainsKey(x.UnitTypeId)) + .Select(x => x.CustomStatesId.Value) + .Distinct() + .ToList(); + + foreach (var customStateId in customStateIds) + { + var customState = await _customStateService.GetCustomSateByIdAsync(customStateId); + customStateDetailIdsByStateId[customStateId] = customState != null && !customState.IsDeleted + ? new HashSet(customState.GetActiveDetails().Select(x => x.CustomStateDetailId)) + : new HashSet(); + } + + foreach (var unit in units) + { + if (String.IsNullOrWhiteSpace(unit.Type)) + continue; + + if (!unitTypesByName.TryGetValue(unit.Type, out var unitType)) + continue; + + if (!unitTypeOverrideLookup.TryGetValue(unitType.UnitTypeId, out var unitTypeOverride)) + continue; + + var candidateStatus = isDispatch ? unitTypeOverride.DispatchStatus : unitTypeOverride.ReleaseStatus; + + if (candidateStatus < 0 || !unitType.CustomStatesId.HasValue || unitType.CustomStatesId.Value <= 0) + continue; + + if (customStateDetailIdsByStateId.TryGetValue(unitType.CustomStatesId.Value, out var validStateIds) && + validStateIds.Contains(candidateStatus)) + resolvedStatuses[unit.UnitId] = candidateStatus; + } + + return resolvedStatuses; + } + + private async Task> GetShiftUserIdsAsync(Call call, Department department, IReadOnlyCollection groupIds) + { + var shiftUserIds = new HashSet(); + var shiftDate = GetShiftDate(call, department); + + foreach (var groupId in groupIds) + { + var signups = await _shiftsService.GetShiftSignupsByDepartmentGroupIdAndDayAsync(groupId, shiftDate); + + if (signups == null) + continue; + + foreach (var signup in signups) + { + if (!String.IsNullOrWhiteSpace(signup.UserId)) + shiftUserIds.Add(signup.UserId); + } + } + + return shiftUserIds; + } + + private static List GetDistinctIds(IEnumerable primaryIds, IEnumerable fallbackIds) + { + return (primaryIds ?? fallbackIds ?? Enumerable.Empty()).Distinct().ToList(); + } + + private static DateTime GetShiftDate(Call call, Department department) + { + var referenceDate = GetReferenceDate(call); + var localizedDate = department != null ? TimeConverterHelper.TimeConverter(referenceDate, department) : referenceDate; + + return new DateTime(localizedDate.Year, localizedDate.Month, localizedDate.Day); + } + + private static DateTime GetReferenceDate(Call call) + { + if (call.LastDispatchedOn.HasValue) + return call.LastDispatchedOn.Value; + + if (call.DispatchOn.HasValue) + return call.DispatchOn.Value; + + if (call.LoggedOn != default(DateTime)) + return call.LoggedOn; + + return DateTime.UtcNow; + } + } +} diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 991713ff..f45e1535 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -784,6 +784,63 @@ public async Task GetUnitDispatchAlsoDispatchToGroupAsync(int departmentId return false; } + public async Task GetUnitCallDispatchStatusToSetAsync(int departmentId) + { + var settingValue = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.UnitCallDispatchStatusToSet); + + if (settingValue != null && int.TryParse(settingValue.Setting, out var stateToSet) && + Enum.IsDefined(typeof(UnitStateTypes), stateToSet)) + return stateToSet; + + return -1; + } + + public async Task GetUnitCallReleaseStatusToSetAsync(int departmentId) + { + var settingValue = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.UnitCallReleaseStatusToSet); + + if (settingValue != null && int.TryParse(settingValue.Setting, out var stateToSet) && + Enum.IsDefined(typeof(UnitStateTypes), stateToSet)) + return stateToSet; + + return -1; + } + + public async Task> GetUnitCallStatusOverridesByUnitTypeAsync(int departmentId) + { + var settingValue = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.UnitCallStatusOverridesByUnitType); + + if (settingValue != null && !String.IsNullOrWhiteSpace(settingValue.Setting)) + { + var setting = ObjectSerialization.Deserialize(settingValue.Setting); + + if (setting?.Overrides != null) + return setting.Overrides + .Where(x => x != null && x.UnitTypeId > 0) + .GroupBy(x => x.UnitTypeId) + .Select(x => x.Last()) + .ToList(); + } + + return new List(); + } + + public async Task SetUnitCallStatusOverridesByUnitTypeAsync(int departmentId, + List overrides, CancellationToken cancellationToken = default(CancellationToken)) + { + var setting = new UnitTypeCallStatusOverrideSetting + { + Overrides = overrides? + .Where(x => x != null && x.UnitTypeId > 0) + .GroupBy(x => x.UnitTypeId) + .Select(x => x.Last()) + .ToList() ?? new List() + }; + + return await SaveOrUpdateSettingAsync(departmentId, ObjectSerialization.Serialize(setting), + DepartmentSettingTypes.UnitCallStatusOverridesByUnitType, cancellationToken); + } + public async Task GetPersonnelOnUnitSetUnitStatusAsync(int departmentId, bool bypassCache = false) { async Task getSetting() diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index c23f4939..8908a99a 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -43,6 +43,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs new file mode 100644 index 00000000..6fd8548e --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CallDispatchStatusServiceTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class CallDispatchStatusServiceTests + { + private Mock _departmentSettingsService; + private Mock _departmentsService; + private Mock _shiftsService; + private Mock _actionLogsService; + private Mock _unitsService; + private Mock _customStateService; + private CallDispatchStatusService _service; + + [SetUp] + public void SetUp() + { + _departmentSettingsService = new Mock(); + _departmentsService = new Mock(); + _shiftsService = new Mock(); + _actionLogsService = new Mock(); + _unitsService = new Mock(); + _customStateService = new Mock(); + + _departmentsService + .Setup(x => x.GetDepartmentByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = 7, TimeZone = "UTC" }); + _actionLogsService + .Setup(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ActionLog()); + _unitsService + .Setup(x => x.SetUnitStateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((UnitState state, int _, CancellationToken __) => state); + + _service = new CallDispatchStatusService( + _departmentSettingsService.Object, + _departmentsService.Object, + _shiftsService.Object, + _actionLogsService.Object, + _unitsService.Object, + _customStateService.Object); + } + + [Test] + public async Task ApplyDispatchStatusesAsync_uses_default_shift_and_unit_dispatch_statuses() + { + var call = new Call + { + CallId = 12, + DepartmentId = 7, + LoggedOn = new DateTime(2026, 1, 12, 15, 0, 0, DateTimeKind.Utc), + GroupDispatches = new List { new CallDispatchGroup { DepartmentGroupId = 5 } }, + UnitDispatches = new List { new CallDispatchUnit { UnitId = 11 } } + }; + + _departmentSettingsService.Setup(x => x.GetDispatchShiftInsteadOfGroupAsync(7)).ReturnsAsync(true); + _departmentSettingsService.Setup(x => x.GetAutoSetStatusForShiftDispatchPersonnelAsync(7)).ReturnsAsync(true); + _departmentSettingsService.Setup(x => x.GetShiftCallDispatchPersonnelStatusToSetAsync(7)).ReturnsAsync(-1); + _departmentSettingsService.Setup(x => x.GetUnitCallDispatchStatusToSetAsync(7)).ReturnsAsync(-1); + _shiftsService + .Setup(x => x.GetShiftSignupsByDepartmentGroupIdAndDayAsync(5, It.Is(d => d == new DateTime(2026, 1, 12)))) + .ReturnsAsync(new List + { + new ShiftSignup { UserId = "user1" }, + new ShiftSignup { UserId = "user2" } + }); + + await _service.ApplyDispatchStatusesAsync(call); + + _actionLogsService.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.RespondingToScene, null, 12, It.IsAny()), Times.Once); + _actionLogsService.Verify(x => x.SetUserActionAsync("user2", 7, (int)ActionTypes.RespondingToScene, null, 12, It.IsAny()), Times.Once); + _unitsService.Verify(x => x.SetUnitStateAsync( + It.Is(s => + s.UnitId == 11 && + s.State == (int)UnitStateTypes.Responding && + s.DestinationId == 12 && + s.DestinationType == (int)DestinationEntityTypes.Call), + 7, + It.IsAny()), Times.Once); + } + + [Test] + public async Task ApplyReleaseStatusesAsync_uses_configured_release_statuses() + { + var call = new Call + { + CallId = 22, + DepartmentId = 7, + LoggedOn = new DateTime(2026, 2, 4, 9, 30, 0, DateTimeKind.Utc) + }; + + _departmentSettingsService.Setup(x => x.GetDispatchShiftInsteadOfGroupAsync(7)).ReturnsAsync(true); + _departmentSettingsService.Setup(x => x.GetAutoSetStatusForShiftDispatchPersonnelAsync(7)).ReturnsAsync(true); + _departmentSettingsService.Setup(x => x.GetShiftCallReleasePersonnelStatusToSetAsync(7)).ReturnsAsync((int)ActionTypes.AvailableStation); + _departmentSettingsService.Setup(x => x.GetUnitCallReleaseStatusToSetAsync(7)).ReturnsAsync((int)UnitStateTypes.Returning); + _shiftsService + .Setup(x => x.GetShiftSignupsByDepartmentGroupIdAndDayAsync(5, It.Is(d => d == new DateTime(2026, 2, 4)))) + .ReturnsAsync(new List { new ShiftSignup { UserId = "user1" } }); + + await _service.ApplyReleaseStatusesAsync(call, new[] { 5 }, new[] { 11 }); + + _actionLogsService.Verify(x => x.SetUserActionAsync("user1", 7, (int)ActionTypes.AvailableStation, null, 22, It.IsAny()), Times.Once); + _unitsService.Verify(x => x.SetUnitStateAsync( + It.Is(s => + s.UnitId == 11 && + s.State == (int)UnitStateTypes.Returning && + s.DestinationId == 22 && + s.DestinationType == (int)DestinationEntityTypes.Call), + 7, + It.IsAny()), Times.Once); + } + + [Test] + public async Task ApplyDispatchStatusesAsync_skips_shift_personnel_when_auto_status_is_disabled() + { + var call = new Call + { + CallId = 32, + DepartmentId = 7, + LoggedOn = new DateTime(2026, 3, 7, 11, 0, 0, DateTimeKind.Utc) + }; + + _departmentSettingsService.Setup(x => x.GetDispatchShiftInsteadOfGroupAsync(7)).ReturnsAsync(true); + _departmentSettingsService.Setup(x => x.GetAutoSetStatusForShiftDispatchPersonnelAsync(7)).ReturnsAsync(false); + _departmentSettingsService.Setup(x => x.GetUnitCallDispatchStatusToSetAsync(7)).ReturnsAsync((int)UnitStateTypes.Committed); + + await _service.ApplyDispatchStatusesAsync(call, new[] { 5 }, new[] { 11 }); + + _shiftsService.Verify(x => x.GetShiftSignupsByDepartmentGroupIdAndDayAsync(It.IsAny(), It.IsAny()), Times.Never); + _actionLogsService.Verify(x => x.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _unitsService.Verify(x => x.SetUnitStateAsync( + It.Is(s => + s.UnitId == 11 && + s.State == (int)UnitStateTypes.Committed && + s.DestinationId == 32 && + s.DestinationType == (int)DestinationEntityTypes.Call), + 7, + It.IsAny()), Times.Once); + } + + [Test] + public async Task ApplyDispatchStatusesAsync_uses_unit_type_override_only_for_matching_unit_type() + { + var call = new Call + { + CallId = 42, + DepartmentId = 7, + LoggedOn = new DateTime(2026, 4, 6, 14, 0, 0, DateTimeKind.Utc) + }; + + _departmentSettingsService.Setup(x => x.GetDispatchShiftInsteadOfGroupAsync(7)).ReturnsAsync(false); + _departmentSettingsService.Setup(x => x.GetUnitCallDispatchStatusToSetAsync(7)).ReturnsAsync((int)UnitStateTypes.Responding); + _departmentSettingsService.Setup(x => x.GetUnitCallStatusOverridesByUnitTypeAsync(7)).ReturnsAsync(new List + { + new UnitTypeCallStatusOverride { UnitTypeId = 2, DispatchStatus = 44, ReleaseStatus = -1 } + }); + _unitsService.Setup(x => x.GetUnitByIdAsync(11)).ReturnsAsync(new Unit { UnitId = 11, Type = "Engine" }); + _unitsService.Setup(x => x.GetUnitByIdAsync(12)).ReturnsAsync(new Unit { UnitId = 12, Type = "Truck" }); + _unitsService.Setup(x => x.GetUnitTypeByNameAsync(7, "Engine")).ReturnsAsync(new UnitType { UnitTypeId = 2, Type = "Engine", CustomStatesId = 100 }); + _unitsService.Setup(x => x.GetUnitTypeByNameAsync(7, "Truck")).ReturnsAsync(new UnitType { UnitTypeId = 3, Type = "Truck", CustomStatesId = 101 }); + _customStateService.Setup(x => x.GetCustomSateByIdAsync(100)).ReturnsAsync(new CustomState + { + CustomStateId = 100, + Details = new List + { + new CustomStateDetail { CustomStateDetailId = 44, ButtonText = "Enroute Custom" } + } + }); + + await _service.ApplyDispatchStatusesAsync(call, unitIds: new[] { 11, 12 }); + + _unitsService.Verify(x => x.SetUnitStateAsync( + It.Is(s => s.UnitId == 11 && s.State == 44 && s.DestinationId == 42), + 7, + It.IsAny()), Times.Once); + _unitsService.Verify(x => x.SetUnitStateAsync( + It.Is(s => s.UnitId == 12 && s.State == (int)UnitStateTypes.Responding && s.DestinationId == 42), + 7, + It.IsAny()), Times.Once); + } + + [Test] + public async Task ApplyReleaseStatusesAsync_uses_unit_type_release_override_when_valid() + { + var call = new Call + { + CallId = 52, + DepartmentId = 7, + LoggedOn = new DateTime(2026, 4, 6, 18, 0, 0, DateTimeKind.Utc) + }; + + _departmentSettingsService.Setup(x => x.GetDispatchShiftInsteadOfGroupAsync(7)).ReturnsAsync(false); + _departmentSettingsService.Setup(x => x.GetUnitCallReleaseStatusToSetAsync(7)).ReturnsAsync((int)UnitStateTypes.Released); + _departmentSettingsService.Setup(x => x.GetUnitCallStatusOverridesByUnitTypeAsync(7)).ReturnsAsync(new List + { + new UnitTypeCallStatusOverride { UnitTypeId = 2, DispatchStatus = -1, ReleaseStatus = 77 } + }); + _unitsService.Setup(x => x.GetUnitByIdAsync(11)).ReturnsAsync(new Unit { UnitId = 11, Type = "Engine" }); + _unitsService.Setup(x => x.GetUnitTypeByNameAsync(7, "Engine")).ReturnsAsync(new UnitType { UnitTypeId = 2, Type = "Engine", CustomStatesId = 100 }); + _customStateService.Setup(x => x.GetCustomSateByIdAsync(100)).ReturnsAsync(new CustomState + { + CustomStateId = 100, + Details = new List + { + new CustomStateDetail { CustomStateDetailId = 77, ButtonText = "Back In Service" } + } + }); + + await _service.ApplyReleaseStatusesAsync(call, unitIds: new[] { 11 }); + + _unitsService.Verify(x => x.SetUnitStateAsync( + It.Is(s => s.UnitId == 11 && s.State == 77 && s.DestinationId == 52), + 7, + It.IsAny()), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceDispatchStatusTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceDispatchStatusTests.cs new file mode 100644 index 00000000..b9f0801d --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceDispatchStatusTests.cs @@ -0,0 +1,111 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class DepartmentSettingsServiceDispatchStatusTests + { + private Mock _departmentSettingsRepository; + private Mock _addressService; + private Mock _geoLocationProvider; + private Mock _cacheProvider; + private DepartmentSettingsService _service; + + [SetUp] + public void SetUp() + { + _departmentSettingsRepository = new Mock(); + _addressService = new Mock(); + _geoLocationProvider = new Mock(); + _cacheProvider = new Mock(); + + _service = new DepartmentSettingsService( + _departmentSettingsRepository.Object, + _addressService.Object, + _geoLocationProvider.Object, + _cacheProvider.Object); + } + + [Test] + public async Task GetUnitCallDispatchStatusToSetAsync_returns_minus_one_when_setting_is_missing() + { + _departmentSettingsRepository + .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.UnitCallDispatchStatusToSet)) + .ReturnsAsync((DepartmentSetting)null); + + var result = await _service.GetUnitCallDispatchStatusToSetAsync(7); + + result.Should().Be(-1); + } + + [Test] + public async Task GetUnitCallReleaseStatusToSetAsync_returns_saved_value() + { + _departmentSettingsRepository + .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.UnitCallReleaseStatusToSet)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.UnitCallReleaseStatusToSet, + Setting = "10" + }); + + var result = await _service.GetUnitCallReleaseStatusToSetAsync(7); + + result.Should().Be(10); + } + + [Test] + public async Task GetUnitCallDispatchStatusToSetAsync_returns_minus_one_for_non_builtin_value() + { + _departmentSettingsRepository + .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.UnitCallDispatchStatusToSet)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.UnitCallDispatchStatusToSet, + Setting = "99" + }); + + var result = await _service.GetUnitCallDispatchStatusToSetAsync(7); + + result.Should().Be(-1); + } + + [Test] + public async Task GetUnitCallStatusOverridesByUnitTypeAsync_returns_saved_overrides() + { + var setting = new UnitTypeCallStatusOverrideSetting + { + Overrides = new System.Collections.Generic.List + { + new UnitTypeCallStatusOverride { UnitTypeId = 12, DispatchStatus = 30, ReleaseStatus = 31 } + } + }; + + _departmentSettingsRepository + .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.UnitCallStatusOverridesByUnitType)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + SettingType = (int)DepartmentSettingTypes.UnitCallStatusOverridesByUnitType, + Setting = Resgrid.Framework.ObjectSerialization.Serialize(setting) + }); + + var result = await _service.GetUnitCallStatusOverridesByUnitTypeAsync(7); + + result.Should().HaveCount(1); + result[0].UnitTypeId.Should().Be(12); + result[0].DispatchStatus.Should().Be(30); + result[0].ReleaseStatus.Should().Be(31); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/EmailController.cs b/Web/Resgrid.Web.Services/Controllers/EmailController.cs index 2c99795b..319df77a 100644 --- a/Web/Resgrid.Web.Services/Controllers/EmailController.cs +++ b/Web/Resgrid.Web.Services/Controllers/EmailController.cs @@ -45,13 +45,14 @@ public class EmailController : ControllerBase private readonly IFileService _fileService; private readonly IUnitsService _unitsService; private readonly IGeoLocationProvider _geoLocationProvider; + private readonly ICallDispatchStatusService _callDispatchStatusService; public EmailController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IDistributionListsService distributionListsService, IUsersService usersService, IEmailService emailService, IDepartmentGroupsService departmentGroupsService, IMessageService messageService, - IFileService fileService, IUnitsService unitsService, IGeoLocationProvider geoLocationProvider) + IFileService fileService, IUnitsService unitsService, IGeoLocationProvider geoLocationProvider, ICallDispatchStatusService callDispatchStatusService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -72,6 +73,7 @@ public EmailController(IDepartmentSettingsService departmentSettingsService, INu _fileService = fileService; _unitsService = unitsService; _geoLocationProvider = geoLocationProvider; + _callDispatchStatusService = callDispatchStatusService; } #endregion Private Readonly Properties and Constructors @@ -374,12 +376,7 @@ public async Task Receive(PostmarkInboundMessage message, Cancella var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); - var cqi = new CallQueueItem(); - cqi.Call = savedCall; - cqi.Profiles = (await _userProfileService.GetAllProfilesForDepartmentAsync(call.DepartmentId)).Select(x => x.Value).ToList(); - cqi.DepartmentTextNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(cqi.Call.DepartmentId); - - await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + await QueueCallBroadcastAsync(savedCall, cancellationToken); return CreatedAtAction(nameof(Receive), new { id = savedCall.CallId }, savedCall); } @@ -396,12 +393,7 @@ public async Task Receive(PostmarkInboundMessage message, Cancella // If our Dispatch Count changed, i.e. 2nd Alarm to 3rd Alarm, redispatch if (call.DidDispatchCountChange()) { - var cqi = new CallQueueItem(); - cqi.Call = savedCall; - cqi.Profiles = (await _userProfileService.GetAllProfilesForDepartmentAsync(call.DepartmentId)).Select(x => x.Value).ToList(); - cqi.DepartmentTextNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(cqi.Call.DepartmentId); - - await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + await QueueCallBroadcastAsync(savedCall, cancellationToken); } return CreatedAtAction(nameof(Receive), new { id = savedCall.CallId }, savedCall); @@ -571,12 +563,7 @@ public async Task Receive(PostmarkInboundMessage message, Cancella var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); - var cqi = new CallQueueItem(); - cqi.Call = savedCall; - cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(departmentGroupUsers.Select(x => x.UserId).ToList()); - cqi.DepartmentTextNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(cqi.Call.DepartmentId); - - await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + await QueueCallBroadcastAsync(savedCall, cancellationToken); return CreatedAtAction(nameof(Receive), new { id = savedCall.CallId }, savedCall); } @@ -649,6 +636,27 @@ public async Task Receive(PostmarkInboundMessage message, Cancella } } + private async Task QueueCallBroadcastAsync(Call savedCall, CancellationToken cancellationToken) + { + var call = await _callsService.PopulateCallData(savedCall, true, false, false, true, true, true, false, false, false); + var cqi = new CallQueueItem(); + cqi.Call = call; + + if ((call.GroupDispatches != null && call.GroupDispatches.Any()) || (call.UnitDispatches != null && call.UnitDispatches.Any()) || (call.RoleDispatches != null && call.RoleDispatches.Any())) + cqi.Profiles = (await _userProfileService.GetAllProfilesForDepartmentAsync(call.DepartmentId)).Select(x => x.Value).ToList(); + else if (call.Dispatches != null && call.Dispatches.Any()) + cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(call.Dispatches.Select(x => x.UserId).ToList()); + else + cqi.Profiles = new List(); + + cqi.DepartmentTextNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(call.DepartmentId); + + if ((call.GroupDispatches != null && call.GroupDispatches.Any()) || (call.UnitDispatches != null && call.UnitDispatches.Any())) + await _callDispatchStatusService.ApplyDispatchStatusesAsync(call, cancellationToken: cancellationToken); + + await _queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); + } + private static Tuple ProcessEmailAddress(string email) { if (string.IsNullOrWhiteSpace(email)) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 19d944a8..552649b2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -55,6 +55,7 @@ public class CallsController : V4AuthenticatedApiControllerbase private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly ICommunicationService _communicationService; private readonly IWeatherAlertService _weatherAlertService; + private readonly ICallDispatchStatusService _callDispatchStatusService; public CallsController( ICallsService callsService, @@ -76,7 +77,8 @@ public CallsController( IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, ICommunicationService communicationService, - IWeatherAlertService weatherAlertService + IWeatherAlertService weatherAlertService, + ICallDispatchStatusService callDispatchStatusService ) { _callsService = callsService; @@ -99,6 +101,7 @@ IWeatherAlertService weatherAlertService _userDefinedFieldsService = userDefinedFieldsService; _communicationService = communicationService; _weatherAlertService = weatherAlertService; + _callDispatchStatusService = callDispatchStatusService; } #endregion Members and Constructors @@ -735,58 +738,7 @@ public async Task> SaveCall([FromBody] NewCallInput } } - if (call.UnitDispatches != null && call.UnitDispatches.Any()) - { - foreach (var unitDispatch in call.UnitDispatches) - { - var unitRoleAssignments = await _unitsService.GetActiveRolesForUnitAsync(unitDispatch.UnitId); - - if (unitRoleAssignments != null && unitRoleAssignments.Any()) - { - foreach (var unitRoleAssignment in unitRoleAssignments) - { - if (!call.Dispatches.Any(x => x.UserId == unitRoleAssignment.UserId)) - { - CallDispatch cd = new CallDispatch(); - cd.UserId = unitRoleAssignment.UserId; - - call.Dispatches.Add(cd); - } - } - } - } - } - - var dispatchShiftInsteadOfGroup = await _departmentSettingsService.GetDispatchShiftInsteadOfGroupAsync(DepartmentId); - var autoSetStatusForShiftPersonnel = await _departmentSettingsService.GetAutoSetStatusForShiftDispatchPersonnelAsync(DepartmentId); - var shiftDispatchStatus = await _departmentSettingsService.GetShiftCallDispatchPersonnelStatusToSetAsync(DepartmentId); - //var shiftClearStatus = await _departmentSettingsService.GetShiftCallReleasePersonnelStatusToSetAsync(DepartmentId); - - List shiftUserIds = new List(); - if (dispatchShiftInsteadOfGroup) - { - if (call.GroupDispatches != null && call.GroupDispatches.Any()) - { - var localizedDate = TimeConverterHelper.TimeConverter(DateTime.UtcNow, department); - var shiftDate = new DateTime(localizedDate.Year, localizedDate.Month, localizedDate.Day); - foreach (var group in call.GroupDispatches) - { - var signups = await _shiftsService.GetShiftSignupsByDepartmentGroupIdAndDayAsync(group.DepartmentGroupId, shiftDate); - - if (signups != null && signups.Any()) - { - foreach (var signup in signups) - { - CallDispatch cd = new CallDispatch(); - cd.UserId = signup.UserId; - - call.Dispatches.Add(cd); - shiftUserIds.Add(signup.UserId); - } - } - } - } - } + var shouldDispatchNow = !call.DispatchOn.HasValue || call.DispatchOn.Value <= DateTime.UtcNow; // Call is in the past or is now, were dispatching now (at the end of this func) if (call.DispatchOn.HasValue && call.DispatchOn.Value <= DateTime.UtcNow) @@ -801,15 +753,12 @@ public async Task> SaveCall([FromBody] NewCallInput //OutboundEventProvider..Handle(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall }); _eventAggregator.SendMessage(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall }); - if (autoSetStatusForShiftPersonnel && shiftUserIds.Any() && call.HasBeenDispatched.GetValueOrDefault()) + if (shouldDispatchNow && ((call.GroupDispatches != null && call.GroupDispatches.Any()) || (call.UnitDispatches != null && call.UnitDispatches.Any()))) { - if (shiftDispatchStatus < 0) - shiftDispatchStatus = (int)ActionTypes.RespondingToScene; - - foreach (var user in shiftUserIds) - { - await _actionLogsService.SetUserActionAsync(user, DepartmentId, shiftDispatchStatus, null, call.CallId, cancellationToken); - } + await _callDispatchStatusService.ApplyDispatchStatusesAsync(savedCall, + call.GroupDispatches?.Select(x => x.DepartmentGroupId), + call.UnitDispatches?.Select(x => x.UnitId), + cancellationToken); } var profiles = new List(); @@ -1158,23 +1107,39 @@ public async Task> EditCall([FromBody] EditCallInpu // Attach weather alerts as call notes if enabled (deduplication handled inside) await _weatherAlertService.AttachWeatherAlertsToCallAsync(call, cancellationToken); + var currentUserIds = call.Dispatches?.Select(x => x.UserId).ToList() ?? new List(); + var currentGroupIds = call.GroupDispatches?.Select(x => x.DepartmentGroupId).ToList() ?? new List(); + var currentUnitIds = call.UnitDispatches?.Select(x => x.UnitId).ToList() ?? new List(); + var currentRoleIds = call.RoleDispatches?.Select(x => x.RoleId).ToList() ?? new List(); + + var cancelledUserIds = existingDispatches.Select(x => x.UserId) + .Where(y => !currentUserIds.Contains(y)).ToList(); + var cancelledGroupIds = existingGroupDispatches.Select(x => x.DepartmentGroupId) + .Where(y => !currentGroupIds.Contains(y)).ToList(); + var cancelledUnitIds = existingUnitDispatches.Select(x => x.UnitId) + .Where(y => !currentUnitIds.Contains(y)).ToList(); + var cancelledRoleIds = existingRoleDispatches.Select(x => x.RoleId) + .Where(y => !currentRoleIds.Contains(y)).ToList(); + + var newUserIds = currentUserIds.Where(id => !existingDispatches.Any(d => d.UserId == id)).ToList(); + var newGroupIds = currentGroupIds.Where(id => !existingGroupDispatches.Any(d => d.DepartmentGroupId == id)).ToList(); + var newUnitIds = currentUnitIds.Where(id => !existingUnitDispatches.Any(d => d.UnitId == id)).ToList(); + var newRoleIds = currentRoleIds.Where(id => !existingRoleDispatches.Any(d => d.RoleId == id)).ToList(); + + var shouldApplyDispatchStatuses = call.HasBeenDispatched.GetValueOrDefault() || !call.DispatchOn.HasValue || call.DispatchOn.Value <= DateTime.UtcNow; + if (shouldApplyDispatchStatuses && (cancelledGroupIds.Any() || cancelledUnitIds.Any())) + { + await _callDispatchStatusService.ApplyReleaseStatusesAsync(call, cancelledGroupIds, cancelledUnitIds, cancellationToken); + } + + if (shouldApplyDispatchStatuses && (newGroupIds.Any() || newUnitIds.Any())) + { + await _callDispatchStatusService.ApplyDispatchStatusesAsync(call, newGroupIds, newUnitIds, cancellationToken); + } + // Send cancel notifications to removed entities if (editCallInput.NotifyCancelledEntities) { - var currentUserIds = call.Dispatches?.Select(x => x.UserId).ToList() ?? new List(); - var currentGroupIds = call.GroupDispatches?.Select(x => x.DepartmentGroupId).ToList() ?? new List(); - var currentUnitIds = call.UnitDispatches?.Select(x => x.UnitId).ToList() ?? new List(); - var currentRoleIds = call.RoleDispatches?.Select(x => x.RoleId).ToList() ?? new List(); - - var cancelledUserIds = existingDispatches.Select(x => x.UserId) - .Where(y => !currentUserIds.Contains(y)).ToList(); - var cancelledGroupIds = existingGroupDispatches.Select(x => x.DepartmentGroupId) - .Where(y => !currentGroupIds.Contains(y)).ToList(); - var cancelledUnitIds = existingUnitDispatches.Select(x => x.UnitId) - .Where(y => !currentUnitIds.Contains(y)).ToList(); - var cancelledRoleIds = existingRoleDispatches.Select(x => x.RoleId) - .Where(y => !currentRoleIds.Contains(y)).ToList(); - if (cancelledUserIds.Any() || cancelledGroupIds.Any() || cancelledUnitIds.Any() || cancelledRoleIds.Any()) { var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(DepartmentId); @@ -1244,16 +1209,6 @@ public async Task> EditCall([FromBody] EditCallInpu // Auto-dispatch newly added entities when RebroadcastCall is not checked if (!editCallInput.RebroadcastCall) { - var currentUserIds2 = call.Dispatches?.Select(x => x.UserId).ToList() ?? new List(); - var currentGroupIds2 = call.GroupDispatches?.Select(x => x.DepartmentGroupId).ToList() ?? new List(); - var currentUnitIds2 = call.UnitDispatches?.Select(x => x.UnitId).ToList() ?? new List(); - var currentRoleIds2 = call.RoleDispatches?.Select(x => x.RoleId).ToList() ?? new List(); - - var newUserIds = currentUserIds2.Where(id => !existingDispatches.Any(d => d.UserId == id)).ToList(); - var newGroupIds = currentGroupIds2.Where(id => !existingGroupDispatches.Any(d => d.DepartmentGroupId == id)).ToList(); - var newUnitIds = currentUnitIds2.Where(id => !existingUnitDispatches.Any(d => d.UnitId == id)).ToList(); - var newRoleIds = currentRoleIds2.Where(id => !existingRoleDispatches.Any(d => d.RoleId == id)).ToList(); - if (newUserIds.Any() || newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) { var cqi = new CallQueueItem(); @@ -1443,6 +1398,7 @@ public async Task> CloseCall([FromBody] CloseCallI if (call.DepartmentId != DepartmentId) return Unauthorized(); + call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); call.ClosedByUserId = UserId; call.ClosedOn = DateTime.UtcNow; call.CompletedNotes = closeCallInput.Notes; @@ -1452,6 +1408,12 @@ public async Task> CloseCall([FromBody] CloseCallI _eventAggregator.SendMessage(new CallClosedEvent() { DepartmentId = DepartmentId, Call = savedCall }); + var shouldApplyReleaseStatuses = call.HasBeenDispatched.GetValueOrDefault() || !call.DispatchOn.HasValue || call.DispatchOn.Value <= DateTime.UtcNow; + if (shouldApplyReleaseStatuses && ((call.GroupDispatches != null && call.GroupDispatches.Any()) || (call.UnitDispatches != null && call.UnitDispatches.Any()))) + { + await _callDispatchStatusService.ApplyReleaseStatusesAsync(call, cancellationToken: cancellationToken); + } + result.Id = savedCall.CallId.ToString(); result.PageSize = 0; result.Status = ResponseHelper.Updated; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index a5e5010e..aa0c9f3e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -1732,21 +1732,7 @@ public async Task DispatchSettings() { var model = new DispatchSettingsView(); - var actionLogs = await _customStateService.GetActivePersonnelStateForDepartmentAsync(DepartmentId); - if (actionLogs == null) - { - var statuses = model.UserStatusTypes.ToSelectListInt().ToList(); - statuses.Insert(0, new SelectListItem() { Value = "-1", Text = "Default" }); - model.StatusLevels = new SelectList(statuses, "Value", "Text"); - } - else - { - List statuses = new List(); - statuses.Add(new CustomStateDetail() { CustomStateDetailId = -1, ButtonText = "Default" }); - statuses.AddRange(actionLogs.GetActiveDetails()); - - model.StatusLevels = new SelectList(statuses, "CustomStateDetailId", "ButtonText"); - } + await PopulateDispatchSettingsSelectionsAsync(model); model.DispatchShiftInsteadOfGroup = await _departmentSettingsService.GetDispatchShiftInsteadOfGroupAsync(DepartmentId); model.AutoSetStatusForShiftPersonnel = await _departmentSettingsService.GetAutoSetStatusForShiftDispatchPersonnelAsync(DepartmentId); @@ -1754,37 +1740,14 @@ public async Task DispatchSettings() model.ShiftClearStatus = await _departmentSettingsService.GetShiftCallReleasePersonnelStatusToSetAsync(DepartmentId); model.UnitDispatchAlsoDispatchToAssignedPersonnel = await _departmentSettingsService.GetUnitDispatchAlsoDispatchToAssignedPersonnelAsync(DepartmentId); model.UnitDispatchAlsoDispatchToGroup = await _departmentSettingsService.GetUnitDispatchAlsoDispatchToGroupAsync(DepartmentId); + model.UnitDispatchStatus = await _departmentSettingsService.GetUnitCallDispatchStatusToSetAsync(DepartmentId); + model.UnitClearStatus = await _departmentSettingsService.GetUnitCallReleaseStatusToSetAsync(DepartmentId); model.PersonnelOnUnitSetUnitStatus = await _departmentSettingsService.GetPersonnelOnUnitSetUnitStatusAsync(DepartmentId); - - // Check-In Timer data model.AutoEnableCheckInTimers = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(DepartmentId); - model.TimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(DepartmentId); - model.TimerOverrides = await _checkInTimerService.GetTimerOverridesForDepartmentAsync(DepartmentId); - model.UnitTypes = (await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId))?.ToList() ?? new List(); - model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); - // Build state ID → name lookup for display in the configs/overrides tables - var personnelStatuses = await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(DepartmentId); - foreach (var s in personnelStatuses) - model.StateNames[s.CustomStateDetailId.ToString()] = s.ButtonText; - - var unitDefaults = _customStateService.GetDefaultUnitStatuses(); - foreach (var s in unitDefaults) - model.StateNames.TryAdd(s.CustomStateDetailId.ToString(), s.ButtonText); - - // Also include custom unit states from each unit type - foreach (var ut in model.UnitTypes) - { - if (ut.CustomStatesId.HasValue && ut.CustomStatesId.Value > 0) - { - var customState = await _customStateService.GetCustomSateByIdAsync(ut.CustomStatesId.Value); - if (customState != null) - { - foreach (var detail in customState.GetActiveDetails()) - model.StateNames.TryAdd(detail.CustomStateDetailId.ToString(), detail.ButtonText); - } - } - } + await PopulateDispatchSettingsSupportingDataAsync(model); + await PopulateDispatchSettingsUnitTypeOverridesAsync(model, + await _departmentSettingsService.GetUnitCallStatusOverridesByUnitTypeAsync(DepartmentId)); return View(model); } @@ -1797,24 +1760,15 @@ public async Task DispatchSettings(DispatchSettingsView model, Ca if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) return Unauthorized(); - var actionLogs = await _customStateService.GetActivePersonnelStateForDepartmentAsync(DepartmentId); - if (actionLogs == null) - { - var statuses = model.UserStatusTypes.ToSelectListInt().ToList(); - statuses.Insert(0, new SelectListItem() { Value = "-1", Text = "Default" }); - model.StatusLevels = new SelectList(statuses, "Value", "Text"); - } - else - { - List statuses = new List(); - statuses.Add(new CustomStateDetail() { CustomStateDetailId = -1, ButtonText = "Default" }); - statuses.AddRange(actionLogs.GetActiveDetails()); - - model.StatusLevels = new SelectList(statuses, "CustomStateDetailId", "ButtonText"); - } + await PopulateDispatchSettingsSelectionsAsync(model); + await PopulateDispatchSettingsSupportingDataAsync(model); if (ModelState.IsValid) { + model.UnitDispatchStatus = NormalizeUnitDispatchStatus(model.UnitDispatchStatus); + model.UnitClearStatus = NormalizeUnitDispatchStatus(model.UnitClearStatus); + var unitTypeStatusOverrides = await BuildValidUnitTypeStatusOverridesAsync(model); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.DispatchShiftInsteadOfGroup.ToString(), DepartmentSettingTypes.DispatchShiftInsteadOfGroup, cancellationToken); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.AutoSetStatusForShiftPersonnel.ToString(), @@ -1828,6 +1782,11 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Un DepartmentSettingTypes.UnitDispatchAlsoDispatchToAssignedPersonnel, cancellationToken); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.UnitDispatchAlsoDispatchToGroup.ToString(), DepartmentSettingTypes.UnitDispatchAlsoDispatchToGroup, cancellationToken); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.UnitDispatchStatus.ToString(), + DepartmentSettingTypes.UnitCallDispatchStatusToSet, cancellationToken); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.UnitClearStatus.ToString(), + DepartmentSettingTypes.UnitCallReleaseStatusToSet, cancellationToken); + await _departmentSettingsService.SetUnitCallStatusOverridesByUnitTypeAsync(DepartmentId, unitTypeStatusOverrides, cancellationToken); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.PersonnelOnUnitSetUnitStatus.ToString(), DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus, cancellationToken); @@ -1836,24 +1795,193 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Pe await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.AutoEnableCheckInTimers.ToString(), DepartmentSettingTypes.CheckInTimersAutoEnableForNewCalls, cancellationToken); - // Reload timer data for re-display - model.TimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(DepartmentId); - model.TimerOverrides = await _checkInTimerService.GetTimerOverridesForDepartmentAsync(DepartmentId); - model.UnitTypes = (await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId))?.ToList() ?? new List(); - model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); - + ModelState.Clear(); + model.UnitTypeStatusOverrides = new List(); + await PopulateDispatchSettingsUnitTypeOverridesAsync(model, unitTypeStatusOverrides); model.SaveSuccess = true; return View(model); } - // Reload timer data even on validation failure + await PopulateDispatchSettingsUnitTypeOverridesAsync(model); + model.SaveSuccess = false; + return View(model); + } + + private async Task PopulateDispatchSettingsSelectionsAsync(DispatchSettingsView model) + { + model.StatusLevels = await GetPersonnelDispatchStatusSelectListAsync(model); + model.UnitStatusLevels = await GetUnitDispatchStatusSelectListAsync(); + } + + private async Task GetPersonnelDispatchStatusSelectListAsync(DispatchSettingsView model) + { + var actionLogs = await _customStateService.GetActivePersonnelStateForDepartmentAsync(DepartmentId); + if (actionLogs == null) + { + var statuses = model.UserStatusTypes.ToSelectListInt().ToList(); + statuses.Insert(0, new SelectListItem() { Value = "-1", Text = "Default" }); + return new SelectList(statuses, "Value", "Text"); + } + + List statusesWithDefault = new List(); + statusesWithDefault.Add(new CustomStateDetail() { CustomStateDetailId = -1, ButtonText = "Default" }); + statusesWithDefault.AddRange(actionLogs.GetActiveDetails()); + + return new SelectList(statusesWithDefault, "CustomStateDetailId", "ButtonText"); + } + + private Task GetUnitDispatchStatusSelectListAsync() + { + var statuses = new List + { + new SelectListItem { Value = "-1", Text = "Default" } + }; + + foreach (var state in Enum.GetValues(typeof(UnitStateTypes)).Cast()) + { + statuses.Add(new SelectListItem + { + Value = ((int)state).ToString(), + Text = state.DisplayName() + }); + } + + return Task.FromResult(new SelectList(statuses, "Value", "Text")); + } + + private async Task PopulateDispatchSettingsSupportingDataAsync(DispatchSettingsView model) + { model.TimerConfigs = await _checkInTimerService.GetTimerConfigsForDepartmentAsync(DepartmentId); model.TimerOverrides = await _checkInTimerService.GetTimerOverridesForDepartmentAsync(DepartmentId); model.UnitTypes = (await _unitsService.GetUnitTypesForDepartmentAsync(DepartmentId))?.ToList() ?? new List(); model.CallTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId) ?? new List(); + model.StateNames = new Dictionary(); - model.SaveSuccess = false; - return View(model); + var personnelStatuses = await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(DepartmentId); + foreach (var s in personnelStatuses) + model.StateNames[s.CustomStateDetailId.ToString()] = s.ButtonText; + + var unitDefaults = _customStateService.GetDefaultUnitStatuses(); + foreach (var s in unitDefaults) + model.StateNames.TryAdd(s.CustomStateDetailId.ToString(), s.ButtonText); + + foreach (var ut in model.UnitTypes) + { + if (ut.CustomStatesId.HasValue && ut.CustomStatesId.Value > 0) + { + var customState = await _customStateService.GetCustomSateByIdAsync(ut.CustomStatesId.Value); + if (customState != null) + { + foreach (var detail in customState.GetActiveDetails()) + model.StateNames.TryAdd(detail.CustomStateDetailId.ToString(), detail.ButtonText); + } + } + } + } + + private async Task PopulateDispatchSettingsUnitTypeOverridesAsync(DispatchSettingsView model, + IEnumerable savedOverrides = null) + { + var postedOverrides = model.UnitTypeStatusOverrides? + .Where(x => x != null) + .GroupBy(x => x.UnitTypeId) + .ToDictionary(x => x.Key, x => x.Last()) ?? new Dictionary(); + var overrideLookup = savedOverrides? + .Where(x => x != null) + .GroupBy(x => x.UnitTypeId) + .ToDictionary(x => x.Key, x => x.Last()) ?? new Dictionary(); + + model.UnitTypeStatusOverrides = new List(); + + foreach (var unitType in model.UnitTypes + .Where(x => x.CustomStatesId.HasValue && x.CustomStatesId.Value > 0) + .OrderBy(x => x.Type)) + { + var availableStatuses = new List + { + new SelectListItem { Value = "-1", Text = "Default" } + }; + + var customState = await _customStateService.GetCustomSateByIdAsync(unitType.CustomStatesId.Value); + if (customState != null && !customState.IsDeleted) + { + foreach (var detail in customState.GetActiveDetails()) + { + availableStatuses.Add(new SelectListItem + { + Value = detail.CustomStateDetailId.ToString(), + Text = detail.ButtonText + }); + } + } + + var overrideRow = new UnitTypeDispatchStatusOverrideView + { + UnitTypeId = unitType.UnitTypeId, + UnitTypeName = unitType.Type, + AvailableStatuses = availableStatuses + }; + + if (postedOverrides.TryGetValue(unitType.UnitTypeId, out var postedOverride)) + { + overrideRow.DispatchStatus = postedOverride.DispatchStatus; + overrideRow.ReleaseStatus = postedOverride.ReleaseStatus; + } + else if (overrideLookup.TryGetValue(unitType.UnitTypeId, out var savedOverride)) + { + overrideRow.DispatchStatus = savedOverride.DispatchStatus; + overrideRow.ReleaseStatus = savedOverride.ReleaseStatus; + } + + model.UnitTypeStatusOverrides.Add(overrideRow); + } + } + + private async Task> BuildValidUnitTypeStatusOverridesAsync(DispatchSettingsView model) + { + var overrides = new List(); + + if (model.UnitTypeStatusOverrides == null || !model.UnitTypeStatusOverrides.Any()) + return overrides; + + var unitTypesById = model.UnitTypes + .Where(x => x.CustomStatesId.HasValue && x.CustomStatesId.Value > 0) + .ToDictionary(x => x.UnitTypeId); + + foreach (var row in model.UnitTypeStatusOverrides) + { + if (!unitTypesById.TryGetValue(row.UnitTypeId, out var unitType)) + continue; + + var validStateIds = new HashSet(); + var customState = await _customStateService.GetCustomSateByIdAsync(unitType.CustomStatesId.Value); + + if (customState != null && !customState.IsDeleted) + { + foreach (var detail in customState.GetActiveDetails()) + validStateIds.Add(detail.CustomStateDetailId); + } + + var dispatchStatus = validStateIds.Contains(row.DispatchStatus) ? row.DispatchStatus : -1; + var releaseStatus = validStateIds.Contains(row.ReleaseStatus) ? row.ReleaseStatus : -1; + + if (dispatchStatus < 0 && releaseStatus < 0) + continue; + + overrides.Add(new UnitTypeCallStatusOverride + { + UnitTypeId = row.UnitTypeId, + DispatchStatus = dispatchStatus, + ReleaseStatus = releaseStatus + }); + } + + return overrides; + } + + private static int NormalizeUnitDispatchStatus(int status) + { + return status >= 0 && Enum.IsDefined(typeof(UnitStateTypes), status) ? status : -1; } #endregion Dispatch Settings diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 15d03637..bda67e2e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -69,6 +69,7 @@ public class DispatchController : SecureBaseController private readonly IUdfRenderingService _udfRenderingService; private readonly ICheckInTimerService _checkInTimerService; private readonly IWeatherAlertService _weatherAlertService; + private readonly ICallDispatchStatusService _callDispatchStatusService; private readonly IStringLocalizer _dispatchLocalizer; private readonly IStringLocalizer _commonLocalizer; @@ -79,9 +80,10 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService IUnitsService unitsService, IActionLogsService actionLogsService, IEventAggregator eventAggregator, ICustomStateService customStateService, ITemplatesService templatesService, IPdfProvider pdfProvider, IProtocolsService protocolsService, IFormsService formsService, IShiftsService shiftsService, IContactsService contactsService, IMappingService mappingService, - IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, - ICheckInTimerService checkInTimerService, IWeatherAlertService weatherAlertService, - IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer) + IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, + ICheckInTimerService checkInTimerService, IWeatherAlertService weatherAlertService, + ICallDispatchStatusService callDispatchStatusService, + IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer) { _departmentsService = departmentsService; _usersService = usersService; @@ -110,6 +112,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _udfRenderingService = udfRenderingService; _checkInTimerService = checkInTimerService; _weatherAlertService = weatherAlertService; + _callDispatchStatusService = callDispatchStatusService; _dispatchLocalizer = dispatchLocalizer; _commonLocalizer = commonLocalizer; } @@ -413,58 +416,7 @@ public async Task NewCall(NewCallView model, IFormCollection coll } } - if (model.Call.UnitDispatches != null && model.Call.UnitDispatches.Any()) - { - foreach (var unitDispatch in model.Call.UnitDispatches) - { - var unitRoleAssignments = await _unitsService.GetActiveRolesForUnitAsync(unitDispatch.UnitId); - - if (unitRoleAssignments != null && unitRoleAssignments.Any()) - { - foreach (var unitRoleAssignment in unitRoleAssignments) - { - if (!model.Call.Dispatches.Any(x => x.UserId == unitRoleAssignment.UserId)) - { - CallDispatch cd = new CallDispatch(); - cd.UserId = unitRoleAssignment.UserId; - - model.Call.Dispatches.Add(cd); - } - } - } - } - } - - var dispatchShiftInsteadOfGroup = await _departmentSettingsService.GetDispatchShiftInsteadOfGroupAsync(DepartmentId); - var autoSetStatusForShiftPersonnel = await _departmentSettingsService.GetAutoSetStatusForShiftDispatchPersonnelAsync(DepartmentId); - var shiftDispatchStatus = await _departmentSettingsService.GetShiftCallDispatchPersonnelStatusToSetAsync(DepartmentId); - //var shiftClearStatus = await _departmentSettingsService.GetShiftCallReleasePersonnelStatusToSetAsync(DepartmentId); - - List shiftUserIds = new List(); - if (dispatchShiftInsteadOfGroup) - { - if (model.Call.GroupDispatches != null && model.Call.GroupDispatches.Any()) - { - var localizedDate = TimeConverterHelper.TimeConverter(DateTime.UtcNow, model.Department); - var shiftDate = new DateTime(localizedDate.Year, localizedDate.Month, localizedDate.Day); - foreach (var group in model.Call.GroupDispatches) - { - var signups = await _shiftsService.GetShiftSignupsByDepartmentGroupIdAndDayAsync(group.DepartmentGroupId, shiftDate); - - if (signups != null && signups.Any()) - { - foreach (var signup in signups) - { - CallDispatch cd = new CallDispatch(); - cd.UserId = signup.UserId; - - model.Call.Dispatches.Add(cd); - shiftUserIds.Add(signup.UserId); - } - } - } - } - } + var shouldDispatchNow = !model.Call.DispatchOn.HasValue || model.Call.DispatchOn.Value <= DateTime.UtcNow; model.Call.Contacts = new List(); if (!String.IsNullOrWhiteSpace(model.PrimaryContact)) @@ -543,15 +495,9 @@ public async Task NewCall(NewCallView model, IFormCollection coll await _userDefinedFieldsService.SaveFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Call, call.CallId.ToString(), udfValues, UserId, isDeptAdmin, isGroupAdmin, cancellationToken); } - if (autoSetStatusForShiftPersonnel && shiftUserIds.Any()) + if (shouldDispatchNow && (dispatchingGroupIds.Any() || dispatchingUnitIds.Any())) { - if (shiftDispatchStatus < 0) - shiftDispatchStatus = (int)ActionTypes.RespondingToScene; - - foreach (var user in shiftUserIds) - { - await _actionLogsService.SetUserActionAsync(user, DepartmentId, shiftDispatchStatus, null, call.CallId, cancellationToken); - } + await _callDispatchStatusService.ApplyDispatchStatusesAsync(call, dispatchingGroupIds, dispatchingUnitIds, cancellationToken); } var cqi = new CallQueueItem(); @@ -915,25 +861,41 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio await _userDefinedFieldsService.SaveFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Call, call.CallId.ToString(), udfValues, UserId, isDeptAdmin, isGroupAdmin, cancellationToken); } + var savedUserIds = new HashSet((call.Dispatches ?? Enumerable.Empty()).Select(x => x.UserId)); + var savedGroupIds = new HashSet((call.GroupDispatches ?? Enumerable.Empty()).Select(x => x.DepartmentGroupId)); + var savedUnitIds = new HashSet((call.UnitDispatches ?? Enumerable.Empty()).Select(x => x.UnitId)); + var savedRoleIds = new HashSet((call.RoleDispatches ?? Enumerable.Empty()).Select(x => x.RoleId)); + + var cancelledUserIds = existingDispatches.Select(x => x.UserId) + .Where(y => !savedUserIds.Contains(y)).ToList(); + var cancelledGroupIds = existingGroupDispatches.Select(x => x.DepartmentGroupId) + .Where(y => !savedGroupIds.Contains(y)).ToList(); + var cancelledUnitIds = existingUnitDispatches.Select(x => x.UnitId) + .Where(y => !savedUnitIds.Contains(y)).ToList(); + var cancelledRoleIds = existingRoleDispatches.Select(x => x.RoleId) + .Where(y => !savedRoleIds.Contains(y)).ToList(); + + var newUserIds = dispatchingUserIds.Where(id => !existingDispatches.Any(d => d.UserId == id)).ToList(); + var newGroupIds = dispatchingGroupIds.Where(id => !existingGroupDispatches.Any(d => d.DepartmentGroupId == id)).ToList(); + var newUnitIds = dispatchingUnitIds.Where(id => !existingUnitDispatches.Any(d => d.UnitId == id)).ToList(); + var newRoleIds = dispatchingRoleIds.Where(id => !existingRoleDispatches.Any(d => d.RoleId == id)).ToList(); + + var shouldApplyDispatchStatuses = call.HasBeenDispatched.GetValueOrDefault() || !call.DispatchOn.HasValue || call.DispatchOn.Value <= DateTime.UtcNow; + if (shouldApplyDispatchStatuses && (cancelledGroupIds.Any() || cancelledUnitIds.Any())) + { + await _callDispatchStatusService.ApplyReleaseStatusesAsync(call, cancelledGroupIds, cancelledUnitIds, cancellationToken); + } + + if (shouldApplyDispatchStatuses && (newGroupIds.Any() || newUnitIds.Any())) + { + await _callDispatchStatusService.ApplyDispatchStatusesAsync(call, newGroupIds, newUnitIds, cancellationToken); + } + _eventAggregator.SendMessage(new CallUpdatedEvent() { DepartmentId = DepartmentId, Call = call }); // Send cancel notifications to removed entities if (model.NotifyCancelledEntities) { - var savedUserIds = new HashSet((call.Dispatches ?? Enumerable.Empty()).Select(x => x.UserId)); - var savedGroupIds = new HashSet((call.GroupDispatches ?? Enumerable.Empty()).Select(x => x.DepartmentGroupId)); - var savedUnitIds = new HashSet((call.UnitDispatches ?? Enumerable.Empty()).Select(x => x.UnitId)); - var savedRoleIds = new HashSet((call.RoleDispatches ?? Enumerable.Empty()).Select(x => x.RoleId)); - - var cancelledUserIds = existingDispatches.Select(x => x.UserId) - .Where(y => !savedUserIds.Contains(y)).ToList(); - var cancelledGroupIds = existingGroupDispatches.Select(x => x.DepartmentGroupId) - .Where(y => !savedGroupIds.Contains(y)).ToList(); - var cancelledUnitIds = existingUnitDispatches.Select(x => x.UnitId) - .Where(y => !savedUnitIds.Contains(y)).ToList(); - var cancelledRoleIds = existingRoleDispatches.Select(x => x.RoleId) - .Where(y => !savedRoleIds.Contains(y)).ToList(); - if (cancelledUserIds.Any() || cancelledGroupIds.Any() || cancelledUnitIds.Any() || cancelledRoleIds.Any()) { var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(DepartmentId); @@ -1003,11 +965,6 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio // Auto-dispatch newly added entities when RebroadcastCall is not checked if (!model.RebroadcastCall) { - var newUserIds = dispatchingUserIds.Where(id => !existingDispatches.Any(d => d.UserId == id)).ToList(); - var newGroupIds = dispatchingGroupIds.Where(id => !existingGroupDispatches.Any(d => d.DepartmentGroupId == id)).ToList(); - var newUnitIds = dispatchingUnitIds.Where(id => !existingUnitDispatches.Any(d => d.UnitId == id)).ToList(); - var newRoleIds = dispatchingRoleIds.Where(id => !existingRoleDispatches.Any(d => d.RoleId == id)).ToList(); - if (newUserIds.Any() || newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) { var cqi = new CallQueueItem(); @@ -1419,6 +1376,7 @@ public async Task CloseCall(CloseCallView model, CancellationToke if (ModelState.IsValid) { + call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); call.ClosedByUserId = UserId; call.ClosedOn = DateTime.UtcNow; call.CompletedNotes = System.Net.WebUtility.HtmlDecode(model.ClosedCallNotes); @@ -1427,6 +1385,12 @@ public async Task CloseCall(CloseCallView model, CancellationToke await _callsService.SaveCallAsync(call, cancellationToken); _eventAggregator.SendMessage(new CallClosedEvent() { DepartmentId = DepartmentId, Call = call }); + var shouldApplyReleaseStatuses = call.HasBeenDispatched.GetValueOrDefault() || !call.DispatchOn.HasValue || call.DispatchOn.Value <= DateTime.UtcNow; + if (shouldApplyReleaseStatuses && ((call.GroupDispatches != null && call.GroupDispatches.Any()) || (call.UnitDispatches != null && call.UnitDispatches.Any()))) + { + await _callDispatchStatusService.ApplyReleaseStatusesAsync(call, cancellationToken: cancellationToken); + } + return RedirectToAction("Dashboard", "Dispatch", new { Area = "User" }); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs b/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs index 03c3b9cd..b66b1781 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Departments/DispatchSettingsView.cs @@ -7,8 +7,12 @@ namespace Resgrid.Web.Areas.User.Models.Departments public class DispatchSettingsView { public SelectList StatusLevels { get; set; } + public SelectList UnitStatusLevels { get; set; } public int ShiftDispatchStatus { get; set; } public int ShiftClearStatus { get; set; } + public int UnitDispatchStatus { get; set; } + public int UnitClearStatus { get; set; } + public List UnitTypeStatusOverrides { get; set; } public ActionTypes UserStatusTypes { get; set; } public bool DispatchShiftInsteadOfGroup { get; set; } public bool AutoSetStatusForShiftPersonnel { get; set; } @@ -34,6 +38,9 @@ public DispatchSettingsView() { ShiftDispatchStatus = -1; ShiftClearStatus = -1; + UnitDispatchStatus = -1; + UnitClearStatus = -1; + UnitTypeStatusOverrides = new List(); TimerConfigs = new List(); TimerOverrides = new List(); UnitTypes = new List(); @@ -41,4 +48,20 @@ public DispatchSettingsView() StateNames = new Dictionary(); } } + + public class UnitTypeDispatchStatusOverrideView + { + public UnitTypeDispatchStatusOverrideView() + { + DispatchStatus = -1; + ReleaseStatus = -1; + AvailableStatuses = new List(); + } + + public int UnitTypeId { get; set; } + public string UnitTypeName { get; set; } + public int DispatchStatus { get; set; } + public int ReleaseStatus { get; set; } + public List AvailableStatuses { get; set; } + } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml index c5912691..09287811 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml @@ -90,6 +90,46 @@ +
+ +
+ @Html.DropDownListFor(m => m.UnitDispatchStatus, Model.UnitStatusLevels, new { style = "width: 70%" }) + @localizer["UnitDefaultStatusesHelp"] +
+
+
+ +
@Html.DropDownListFor(m => m.UnitClearStatus, Model.UnitStatusLevels, new { style = "width: 70%" })
+
+ @if (Model.UnitTypeStatusOverrides.Any()) + { +
+
+

@localizer["UnitTypeStatusOverridesHeader"]

+

@localizer["UnitTypeStatusOverridesHelp"]

+ + + + + + + + + + @for (var i = 0; i < Model.UnitTypeStatusOverrides.Count; i++) + { + + @Html.HiddenFor(m => m.UnitTypeStatusOverrides[i].UnitTypeId) + + + + + } + +
@localizer["CheckInTimerUnitTypeLabel"]@localizer["UnitCallDispatchStatusLabel"]@localizer["UnitCallReleaseStatusLabel"]
@Model.UnitTypeStatusOverrides[i].UnitTypeName@Html.DropDownListFor(m => m.UnitTypeStatusOverrides[i].DispatchStatus, Model.UnitTypeStatusOverrides[i].AvailableStatuses, new { style = "width: 100%" })@Html.DropDownListFor(m => m.UnitTypeStatusOverrides[i].ReleaseStatus, Model.UnitTypeStatusOverrides[i].AvailableStatuses, new { style = "width: 100%" })
+
+
+ }
diff --git a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs index 91afc1e6..3a703417 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/DispatchScheduledCallsTask.cs @@ -30,9 +30,10 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP { progress.Report(1, $"Starting the {Name} Task"); - IUserProfileService _userProfileService = null; + var userProfileService = Bootstrapper.GetKernel().Resolve(); var callsService = Bootstrapper.GetKernel().Resolve(); var queueService = Bootstrapper.GetKernel().Resolve(); + var callDispatchStatusService = Bootstrapper.GetKernel().Resolve(); var pendingCalls = await callsService.GetAllNonDispatchedScheduledCallsWithinDateRange(DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(5)); @@ -44,7 +45,7 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP cqi.Call = await callsService.PopulateCallData(call, true, false, false, true, true, true, true, false, false); if (cqi.Call.Dispatches != null && cqi.Call.Dispatches.Any()) - cqi.Profiles = await _userProfileService.GetSelectedUserProfilesAsync(cqi.Call.Dispatches.Select(x => x.UserId).ToList()); + cqi.Profiles = await userProfileService.GetSelectedUserProfilesAsync(cqi.Call.Dispatches.Select(x => x.UserId).ToList()); var result = await queueService.EnqueueCallBroadcastAsync(cqi, cancellationToken); @@ -52,6 +53,7 @@ public async Task ProcessAsync(DispatchScheduledCallsCommand command, IQuidjiboP { call.HasBeenDispatched = true; await callsService.SaveCallAsync(call); + await callDispatchStatusService.ApplyDispatchStatusesAsync(cqi.Call, cancellationToken: cancellationToken); } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs index a90f0407..492d8b58 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs @@ -116,7 +116,29 @@ public static async Task ProcessCallQueueItem(CallQueueItem cqi) var signups = await _shiftsService.GetShiftSignupsByDepartmentGroupIdAndDayAsync(d.DepartmentGroupId, shiftDate); if (dispatchShiftInsteadOfGroup && (signups != null && signups.Any())) + { + foreach (var signup in signups) + { + if (!dispatchedUsers.Contains(signup.UserId)) + { + dispatchedUsers.Add(signup.UserId); + try + { + var profile = cqi.Profiles.FirstOrDefault(x => x.UserId == signup.UserId); + await _communicationService.SendCallAsync(cqi.Call, new CallDispatch() { UserId = signup.UserId }, cqi.DepartmentTextNumber, cqi.Call.DepartmentId, profile, cqi.Address); + } + catch (SocketException sex) + { + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + } + continue; + } var members = await _departmentGroupsService.GetAllMembersForGroupAsync(d.DepartmentGroupId);