(occurrence.Content)?.Name ?? row.TargetId });
}
return result;
}
diff --git a/Core/Resgrid.Services/ChecklistReportDocuments.cs b/Core/Resgrid.Services/ChecklistReportDocuments.cs
index d89cc31de..3f04e95e3 100644
--- a/Core/Resgrid.Services/ChecklistReportDocuments.cs
+++ b/Core/Resgrid.Services/ChecklistReportDocuments.cs
@@ -28,7 +28,7 @@ public static string Compliance(ChecklistComplianceSummary report, bool missedOn
body.Append("").Append(H(Text("AuthorizedScope"))).Append("
").Append(Head("Target", "ExpectedChecks", "CompletedChecks", "OnTimeChecks", "MissedChecks", "ExcusedChecks", "CompletionRate"));
foreach (var group in report.Groups.Where(g => !missedOnly || g.Missed > 0)) body.Append("").Append(Cell(group.Target.Name)).Append(Cell(group.Expected)).Append(Cell(group.Completed)).Append(Cell(group.OnTime)).Append(Cell(group.Missed)).Append(Cell(group.Skipped)).Append(Cell(group.CompletionRate?.ToString("0.##") ?? "—")).Append("
");
body.Append("
").Append(H(Text("MissedTrend"))).Append("
").Append(Head("Date", "ExpectedChecks", "MissedChecks"));
- foreach (var day in report.Trend) body.Append("").Append(Cell(day.DayUtc.ToString("yyyy-MM-dd"))).Append(Cell(day.Expected)).Append(Cell(day.Missed)).Append("
");
+ foreach (var day in report.Trend) body.Append("").Append(Cell(day.DayUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))).Append(Cell(day.Expected)).Append(Cell(day.Missed)).Append("
");
body.Append("
").Append(Entries(report.Entries.Where(e => !missedOnly || e.Missed))).Append(Unavailable(report.UnavailableSources));
return Page(missedOnly ? "ChecklistMissedReport" : "ChecklistComplianceReport", body.ToString());
}
diff --git a/Core/Resgrid.Services/ChecklistsScheduling.cs b/Core/Resgrid.Services/ChecklistsScheduling.cs
index c049a63c5..39e34bfad 100644
--- a/Core/Resgrid.Services/ChecklistsScheduling.cs
+++ b/Core/Resgrid.Services/ChecklistsScheduling.cs
@@ -13,13 +13,13 @@ namespace Resgrid.Services
{
public partial class ChecklistsService
{
- public async Task> SchedulesAsync(ChecklistActor actor, string definitionId, int page = 0)
+ public async Task> SchedulesAsync(ChecklistActor actor, string definitionId, int page = 0, bool includeNext = false)
{
Id(definitionId); await _authorization.RequireMemberAsync(actor);
if (!await CanManageAsync(actor)) throw new ChecklistException(403, "SchedulePermission");
if (page < 0 || page > 10000) throw new ChecklistException(400, "ScheduleValidation");
var views = new List();
- foreach (var row in await _store.ListAsync(actor.DepartmentId, definitionId, page * 50, 50))
+ foreach (var row in await _store.ListAsync(actor.DepartmentId, definitionId, page * 50, includeNext ? 51 : 50))
{
await RevealAsync(actor, row); views.Add(new ChecklistScheduleView { Schedule = row, Content = Decode(row.Content) });
}
diff --git a/Core/Resgrid.Services/ChecklistsService.cs b/Core/Resgrid.Services/ChecklistsService.cs
index 9bff8c7f3..00249c89b 100644
--- a/Core/Resgrid.Services/ChecklistsService.cs
+++ b/Core/Resgrid.Services/ChecklistsService.cs
@@ -268,7 +268,7 @@ public async Task> HistoryAsync(ChecklistActor actor
{
await RevealAsync(actor, row);
var occurrence = await RevealAsync(actor, await _store.GetAsync(actor.DepartmentId, row.OccurrenceId));
- row.Content = null; result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(occurrence.Content).Name });
+ row.Content = null; result.Add(new ChecklistHistoryEntry { Completion = row, TargetName = Decode(occurrence.Content)?.Name ?? row.TargetId });
}
return result;
}
diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs
index 09b0abe2c..1f6032f78 100644
--- a/Core/Resgrid.Services/DeleteService.cs
+++ b/Core/Resgrid.Services/DeleteService.cs
@@ -42,6 +42,8 @@ public class DeleteService : IDeleteService
private readonly IUserSessionService _userSessionService;
private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService;
private readonly IDepartmentMemberEmergencyContactService _emergencyContactService;
+ private readonly IInventoryStore _inventoryStore;
+ private readonly Resgrid.Model.Repositories.Queries.IUnitOfWork _inventoryUnitOfWork;
public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService,
ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService,
@@ -53,7 +55,8 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer
IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository,
IScheduledTasksService scheduledTasksService, IUserSessionService userSessionService,
IDepartmentMemberSensitiveDataService memberSensitiveDataService,
- IDepartmentMemberEmergencyContactService emergencyContactService)
+ IDepartmentMemberEmergencyContactService emergencyContactService,
+ IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null)
{
_authorizationService = authorizationService;
_departmentsService = departmentsService;
@@ -82,6 +85,8 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer
_userSessionService = userSessionService;
_memberSensitiveDataService = memberSensitiveDataService;
_emergencyContactService = emergencyContactService;
+ _inventoryStore = inventoryStore;
+ _inventoryUnitOfWork = inventoryUnitOfWork;
}
public async Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete, CancellationToken cancellationToken = default(CancellationToken))
@@ -283,6 +288,9 @@ await _userSessionService.RevokeAllAsync(userIdToDelete, userIdToDelete,
if (!await _authorizationService.CanUserEditDepartmentGroupAsync(currentUserId, departmentGroupId))
return DeleteGroupResults.UnAuthorized;
+ // Check retained inventory evidence before clearing any group associations, under the same department lock as posting.
+ await InventoryHolderRetention.DeleteAsync(_inventoryStore, _inventoryUnitOfWork, departmentId, departmentGroupId, false, async () =>
+ {
await _callsService.ClearGroupForDispatchesAsync(departmentGroupId, cancellationToken);
await _workLogsService.ClearGroupForLogsAsync(departmentGroupId, cancellationToken);
await _unitsService.ClearGroupForUnitsAsync(departmentGroupId, cancellationToken);
@@ -290,6 +298,8 @@ await _userSessionService.RevokeAllAsync(userIdToDelete, userIdToDelete,
await _inventoryService.DeleteInventoriesByGroupIdAsync(departmentGroupId, departmentId, cancellationToken);
await _departmentGroupsService.DeleteGroupMembersByGroupIdAsync(departmentGroupId, departmentId, cancellationToken);
await _departmentGroupsService.DeleteGroupByIdAsync(departmentGroupId, cancellationToken);
+ return true;
+ }, cancellationToken);
return DeleteGroupResults.NoFailure;
}
diff --git a/Core/Resgrid.Services/DepartmentGroupsService.cs b/Core/Resgrid.Services/DepartmentGroupsService.cs
index e60ee69a2..a8441c6b7 100644
--- a/Core/Resgrid.Services/DepartmentGroupsService.cs
+++ b/Core/Resgrid.Services/DepartmentGroupsService.cs
@@ -30,11 +30,12 @@ public class DepartmentGroupsService : IDepartmentGroupsService
private readonly ICacheProvider _cacheProvider;
private readonly IIdentityRepository _identityRepository;
private readonly IUnitOfWork _unitOfWork;
+ private readonly IInventoryStore _inventoryStore;
public DepartmentGroupsService(IDepartmentGroupsRepository departmentGroupsRepository, IDepartmentGroupMembersRepository departmentGroupMembersRepository,
ISubscriptionsService subscriptionsService, IAddressService addressService, IDepartmentsService departmentsService, IGeoLocationProvider geoLocationProvider,
IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator, ICacheProvider cacheProvider,
- IIdentityRepository identityRepository, IUnitOfWork unitOfWork)
+ IIdentityRepository identityRepository, IUnitOfWork unitOfWork, IInventoryStore inventoryStore = null)
{
_departmentGroupsRepository = departmentGroupsRepository;
_departmentGroupMembersRepository = departmentGroupMembersRepository;
@@ -47,6 +48,7 @@ public DepartmentGroupsService(IDepartmentGroupsRepository departmentGroupsRepos
_cacheProvider = cacheProvider;
_identityRepository = identityRepository;
_unitOfWork = unitOfWork;
+ _inventoryStore = inventoryStore;
}
public async Task> GetAllAsync()
@@ -251,6 +253,9 @@ async Task getDepartmentGroup()
public async Task DeleteGroupByIdAsync(int groupId, CancellationToken cancellationToken = default(CancellationToken))
{
var group = await GetGroupByIdAsync(groupId);
+ if (group == null) return false;
+ return await InventoryHolderRetention.DeleteAsync(_inventoryStore, _unitOfWork, group.DepartmentId, groupId, false, async () =>
+ {
var members = await _departmentGroupMembersRepository.GetAllGroupMembersByGroupIdAsync(groupId);
foreach (var departmentGroupMember in members)
@@ -263,6 +268,7 @@ async Task getDepartmentGroup()
SendGroupVisibilityRefresh(group?.DepartmentId ?? 0);
return true;
+ }, cancellationToken);
}
public async Task UpdateAsync(DepartmentGroup departmentGroup, CancellationToken cancellationToken = default(CancellationToken))
diff --git a/Core/Resgrid.Services/FeatureFlagMutations.cs b/Core/Resgrid.Services/FeatureFlagMutations.cs
index 01b09d37e..26576a27d 100644
--- a/Core/Resgrid.Services/FeatureFlagMutations.cs
+++ b/Core/Resgrid.Services/FeatureFlagMutations.cs
@@ -15,6 +15,11 @@ public partial class FeatureToggleService
private bool _invalidateFlags;
private readonly HashSet _invalidateOverrides = new();
private readonly List _committedAudits = new();
+ private async Task InvalidateCacheAfterCommitAsync(string key)
+ {
+ try { await _cacheProvider.RemoveAsync(key); }
+ catch (Exception ex) { Resgrid.Framework.Logging.LogError($"Feature flag cache invalidation failed after commit for {key}: {ex.GetType().FullName}."); }
+ }
private async Task MutateFlagAsync(Func> action, CancellationToken ct)
{
if (_mutationObserver == null || _mutationUnit == null) return await action();
@@ -35,12 +40,13 @@ private async Task MutateFlagAsync(Func> action, CancellationToken
catch { _mutationUnit.DiscardChanges(); throw; }
_mutationActive = false;
// Cache failures cannot roll back committed writes or suppress their audit publication.
- try
- {
- if (_invalidateFlags) await InvalidateFlagCacheAsync();
- foreach (var department in _invalidateOverrides) await InvalidateDepartmentOverrideCacheAsync(department);
- }
- finally { foreach (var audit in _committedAudits) audit(); }
+ if (_invalidateFlags)
+ foreach (var key in new[] { AllFlagsCacheKey, AllRulesCacheKey, AllPrereqsCacheKey })
+ await InvalidateCacheAfterCommitAsync(key);
+ foreach (var department in _invalidateOverrides)
+ await InvalidateCacheAfterCommitAsync(string.Format(DepartmentOverridesCacheKey, department));
+ // PublishAudit already isolates individual publication failures.
+ foreach (var audit in _committedAudits) audit();
return result;
}
finally { _mutationActive = false; _invalidateFlags = false; _invalidateOverrides.Clear(); _committedAudits.Clear(); }
diff --git a/Core/Resgrid.Services/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs
index 3895bd22b..d45e3fe60 100644
--- a/Core/Resgrid.Services/GdprDataExportService.cs
+++ b/Core/Resgrid.Services/GdprDataExportService.cs
@@ -53,10 +53,11 @@ public GdprDataExportService(
ICertificationService certificationService,
ITrainingService trainingService,
IShiftsService shiftsService,
- IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders, IWorkOrderRepository workOrders = null)
+ IEmailService emailService, IChecklistRepository checklists, Lazy checklistProtection, IChecklistReminderRepository checklistReminders, IWorkOrderRepository workOrders, IInventoryStore inventoryStore = null)
{
_repository = repository;
- _workOrders = workOrders;
+ _workOrders = workOrders ?? throw new ArgumentNullException(nameof(workOrders));
+ _inventoryStore = inventoryStore;
_checklistReminders = checklistReminders ?? throw new ArgumentNullException(nameof(checklistReminders));
_userProfileService = userProfileService;
_memberSensitiveDataService = memberSensitiveDataService;
@@ -189,6 +190,7 @@ private async Task BuildExportZipAsync(string userId, int departmentId)
await AddJsonEntry(archive, "shifts.json", await BuildShiftsDataAsync(userId), ledger);
await AddJsonEntry(archive, "checklists.json", await BuildChecklistDataAsync(userId, departmentId), ledger);
await AddJsonEntry(archive, "workorders.json", await BuildWorkOrderDataAsync(userId, departmentId), ledger);
+ await AddJsonEntry(archive, "inventory.json", await BuildInventoryDataAsync(userId, departmentId), ledger);
// Written last, so it can report what every other entry withheld. Only present when
// something actually was: a member of an unprotected department gets the archive they
diff --git a/Core/Resgrid.Services/InventoryAuthorizationService.cs b/Core/Resgrid.Services/InventoryAuthorizationService.cs
new file mode 100644
index 000000000..9985914df
--- /dev/null
+++ b/Core/Resgrid.Services/InventoryAuthorizationService.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Inventories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public sealed class InventoryAuthorizationService : IInventoryAuthorizationService
+ {
+ private readonly IDepartmentsService _departments;
+ private readonly IDepartmentGroupsService _groups;
+ private readonly IUnitsService _units;
+ private readonly IAuthorizationService _resources;
+ private readonly IPermissionsService _permissions;
+ private readonly IPersonnelRolesService _roles;
+ private readonly IDepartmentSettingsService _settings;
+ public InventoryAuthorizationService(IDepartmentsService departments, IDepartmentGroupsService groups, IUnitsService units,
+ IAuthorizationService resources, IPermissionsService permissions, IPersonnelRolesService roles, IDepartmentSettingsService settings)
+ { _departments = departments; _groups = groups; _units = units; _resources = resources; _permissions = permissions; _roles = roles; _settings = settings; }
+ public async Task IsEnabledAsync(int departmentId) => departmentId > 0 && (await _settings.GetDepartmentModuleSettingsAsync(departmentId, true))?.InventoryDisabled != true;
+ public async Task RequireAsync(InventoryActor actor, bool write = false, PermissionTypes? permission = null, int? groupId = null)
+ {
+ if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) throw new InventoryException(403, "MembershipRequired");
+ var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true);
+ if (member?.DepartmentId != actor.DepartmentId || member.IsDeleted || member.IsDisabled == true) throw new InventoryException(403, "MembershipRequired");
+ if (write && !await IsEnabledAsync(actor.DepartmentId)) throw new InventoryException(409, "InventoryDisabled");
+ if (!write && !permission.HasValue) return;
+ var department = await _departments.GetDepartmentByIdAsync(actor.DepartmentId, true);
+ var admin = member.IsAdmin == true || department?.ManagingUserId == actor.UserId;
+ var group = await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId);
+ var type = permission ?? PermissionTypes.AdjustInventory;
+ var rule = await _permissions.GetPermissionByDepartmentTypeAsync(actor.DepartmentId, type);
+ if (rule == null && (type == PermissionTypes.TransferInventory || type == PermissionTypes.IssueInventory))
+ rule = await _permissions.GetPermissionByDepartmentTypeAsync(actor.DepartmentId, PermissionTypes.AdjustInventory);
+ if (!RecordPermissionEvaluation.IsSatisfied(rule?.Action ?? (int)PermissionActions.DepartmentAdminsOnly, rule?.Data, admin, group?.IsUserGroupAdmin(actor.UserId) == true,
+ await _roles.GetRolesForUserAsync(actor.UserId, actor.DepartmentId)) || !admin && rule?.LockToGroup == true && (!groupId.HasValue || groupId != group?.DepartmentGroupId))
+ throw new InventoryException(403, "PermissionRequired");
+ }
+ public async Task CanLocationAsync(InventoryActor actor, InventoryLocation location)
+ {
+ if (location?.DepartmentId != actor.DepartmentId) return false;
+ if (location.UnitId.HasValue) return (await _units.GetUnitByIdAsync(location.UnitId.Value))?.DepartmentId == actor.DepartmentId && await _resources.CanUserViewUnitAsync(actor.UserId, location.UnitId.Value);
+ if (location.UserId != null) return location.UserId == actor.UserId || await _resources.CanUserViewPersonAsync(actor.UserId, location.UserId, actor.DepartmentId);
+ if (location.GroupId.HasValue)
+ {
+ if ((await _groups.GetGroupForUserAsync(actor.UserId, actor.DepartmentId))?.DepartmentGroupId == location.GroupId) return true;
+ try { await RequireAsync(actor, false, PermissionTypes.AdjustInventory, location.GroupId); return true; } catch (InventoryException) { return false; }
+ }
+ return location.LocationType != (int)InventoryLocationType.Container; // Container authorization requires resolving its effective holder in the service.
+ }
+ public async Task ValidateHolderAsync(InventoryActor actor, InventoryLocation location)
+ {
+ var holders = (location.GroupId.HasValue ? 1 : 0) + (location.UnitId.HasValue ? 1 : 0) + (location.UserId != null ? 1 : 0) + (location.ContainerAssetId != null ? 1 : 0);
+ var type = (InventoryLocationType)location.LocationType;
+ if (!Enum.IsDefined(type) || holders != (type is InventoryLocationType.Facility or InventoryLocationType.External ? 0 : 1)
+ || type == InventoryLocationType.Station && !location.GroupId.HasValue || type == InventoryLocationType.Unit && !location.UnitId.HasValue
+ || type == InventoryLocationType.Personnel && location.UserId == null || type == InventoryLocationType.Container && location.ContainerAssetId == null)
+ throw new InventoryException(400, "HolderRequired");
+ if (location.GroupId.HasValue && (await _groups.GetGroupByIdAsync(location.GroupId.Value, true))?.DepartmentId != actor.DepartmentId) throw new InventoryException(404, "LocationUnavailable");
+ if (location.UnitId.HasValue && (await _units.GetUnitByIdAsync(location.UnitId.Value))?.DepartmentId != actor.DepartmentId) throw new InventoryException(404, "LocationUnavailable");
+ if (location.UserId != null)
+ {
+ var member = await _departments.GetDepartmentMemberAsync(location.UserId, actor.DepartmentId, true);
+ if (member?.DepartmentId != actor.DepartmentId || member.IsDeleted || member.IsDisabled == true) throw new InventoryException(404, "LocationUnavailable");
+ }
+ if (type != InventoryLocationType.Container && !await CanLocationAsync(actor, location)) throw new InventoryException(404, "LocationUnavailable");
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/InventoryCatalog.cs b/Core/Resgrid.Services/InventoryCatalog.cs
new file mode 100644
index 000000000..a9ab1e68f
--- /dev/null
+++ b/Core/Resgrid.Services/InventoryCatalog.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Resgrid.Model;
+using Resgrid.Model.Inventories;
+
+namespace Resgrid.Services
+{
+ public sealed partial class InventoryModernizationService
+ {
+ public Task SaveItemAsync(InventoryActor actor, InventoryItemInput input) => TransactionAsync(actor, async events =>
+ {
+ await _auth.RequireAsync(actor, true);
+ if (input?.Details == null || !Enum.IsDefined(input.TrackingMode)) throw new InventoryException(400, "InvalidItem");
+ Text(input.Details.Name); Text(input.Details.UnitOfMeasure, 80);
+ if (input.Details.Description?.Length > 16000 || input.Details.Code?.Length > 100 || input.Details.Barcode?.Length > 250
+ || input.Details.DefaultUnitCost < 0 || input.Details.MinLevel < 0 || input.Details.ReorderPoint < 0 || input.Details.DefaultExpirationDays < 0)
+ throw new InventoryException(400, "InvalidItem");
+ if (input.IsKit && input.TrackingMode != InventoryTrackingMode.Serialized) throw new InventoryException(400, "KitMustBeSerialized");
+ if (input.RequiresExpiration && input.TrackingMode == InventoryTrackingMode.Bulk && !input.RequiresLotTracking) throw new InventoryException(400, "ExpiryRequiresLotTracking");
+ if (input.CategoryId != null && (await GetAsync(actor, input.CategoryId)).IsDeleted) throw new InventoryException(404, "Unavailable");
+ if (input.IsControlledSubstance) await _auth.RequireAsync(actor, true, PermissionTypes.ManageControlledSubstances);
+ var row = input.Id == null ? New(actor) : await GetAsync(actor, input.Id);
+ if (input.Id != null && row.Revision != input.Revision) throw new InventoryException(409, "RevisionConflict");
+ if (input.Id != null && (row.TrackingMode != (int)input.TrackingMode || row.IsKit != input.IsKit || row.RequiresLotTracking != input.RequiresLotTracking || row.RequiresExpiration != input.RequiresExpiration || row.IsControlledSubstance != input.IsControlledSubstance)
+ && (await _store.RelatedAsync(actor.DepartmentId, "ItemId", row.Id)).Count > 0) throw new InventoryException(409, "ItemTrackingLocked");
+ foreach (var other in (await AllAsync(actor.DepartmentId)).Where(x => !x.IsDeleted && x.Id != row.Id))
+ {
+ var details = Decode(await RevealAsync(actor, other));
+ if (string.Equals(details.Name?.Trim(), input.Details.Name.Trim(), StringComparison.OrdinalIgnoreCase)
+ || !string.IsNullOrWhiteSpace(input.Details.Barcode) && string.Equals(details.Barcode, input.Details.Barcode, StringComparison.OrdinalIgnoreCase)) throw new InventoryException(409, "DuplicateItem");
+ }
+ row.CategoryId = input.CategoryId; row.TrackingMode = (int)input.TrackingMode; row.IsKit = input.IsKit; row.RequiresLotTracking = input.RequiresLotTracking;
+ row.RequiresExpiration = input.RequiresExpiration; row.IsControlledSubstance = input.IsControlledSubstance; row.IsActive = input.IsActive;
+ input.Details.Name = input.Details.Name.Trim(); row.Content = JsonConvert.SerializeObject(input.Details);
+ await SaveAsync(actor, row, input.Id == null); await AuditAsync(actor, row, "InventoryItemSaved"); return await RevealAsync(actor, row);
+ });
+ public Task SaveCategoryAsync(InventoryActor actor, string id, int revision, string name, string parentId) => TransactionAsync(actor, async events =>
+ {
+ await _auth.RequireAsync(actor, true); Text(name);
+ var row = id == null ? New(actor) : await GetAsync(actor, id);
+ if (id != null && row.Revision != revision) throw new InventoryException(409, "RevisionConflict");
+ var parent = parentId; var depth = 0;
+ while (parent != null) { if (parent == row.Id || ++depth > 32) throw new InventoryException(400, "InvalidCategoryHierarchy"); var p = await GetAsync(actor, parent); if (p.IsDeleted) throw new InventoryException(404, "Unavailable"); parent = p.ParentCategoryId; }
+ row.ParentCategoryId = parentId; row.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = name.Trim() });
+ await SaveAsync(actor, row, id == null); await AuditAsync(actor, row, "InventoryCategorySaved"); return await RevealAsync(actor, row);
+ });
+ public Task SaveLocationAsync(InventoryActor actor, InventoryLocationInput input) => TransactionAsync(actor, async events =>
+ {
+ await _auth.RequireAsync(actor, true); if (input == null) throw new InventoryException(400, "LocationRequired"); Text(input.Name);
+ var row = input.Id == null ? New(actor) : await GetAsync(actor, input.Id);
+ if (input.Id != null && row.Revision != input.Revision) throw new InventoryException(409, "RevisionConflict");
+ if (input.Id != null && (row.LocationType != (int)input.Type || row.GroupId != input.GroupId || row.UnitId != input.UnitId || row.UserId != input.UserId || row.ContainerAssetId != input.ContainerAssetId || row.ParentLocationId != input.ParentLocationId))
+ throw new InventoryException(409, "LocationHolderImmutable");
+ row.LocationType = (int)input.Type; row.GroupId = input.GroupId; row.UnitId = input.UnitId; row.UserId = input.UserId; row.ContainerAssetId = input.ContainerAssetId; row.ParentLocationId = input.ParentLocationId;
+ await _auth.ValidateHolderAsync(actor, row);
+ if (row.ParentLocationId != null)
+ {
+ if (input.Type is not (InventoryLocationType.Facility or InventoryLocationType.External) || row.ParentLocationId == row.Id) throw new InventoryException(400, "InvalidLocationHierarchy");
+ await LocationAsync(actor, row.ParentLocationId, true);
+ }
+ if (row.ContainerAssetId != null)
+ {
+ var asset = await GetAsync(actor, row.ContainerAssetId); var item = await GetAsync(actor, asset.ItemId);
+ if (!item.IsKit || asset.IsDeleted) throw new InventoryException(400, "KitRequired");
+ await LocationAsync(actor, asset.CurrentLocationId, true);
+ }
+ if (input.IsDefault && (input.Type != InventoryLocationType.Facility || input.ParentLocationId != null)) throw new InventoryException(400, "InvalidDefaultLocation");
+ if (input.IsDefault) foreach (var old in (await AllAsync(actor.DepartmentId)).Where(x => x.Id != row.Id && x.IsDefault)) { old.IsDefault = false; await SaveAsync(actor, old, false); }
+ row.IsDefault = input.IsDefault; row.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = input.Name.Trim() });
+ await SaveAsync(actor, row, input.Id == null); await AuditAsync(actor, row, "InventoryLocationSaved"); return await RevealAsync(actor, row);
+ });
+ private async Task HolderLocationAsync(InventoryActor actor, int? unitId, string userId)
+ {
+ var type = unitId.HasValue ? InventoryLocationType.Unit : InventoryLocationType.Personnel;
+ var row = (await AllAsync(actor.DepartmentId)).SingleOrDefault(l => !l.IsDeleted && l.LocationType == (int)type && l.UnitId == unitId && l.UserId == userId);
+ if (row != null) { await LocationAsync(actor, row.Id, true, PermissionTypes.IssueInventory); return row; }
+ row = New(actor); row.LocationType = (int)type; row.UnitId = unitId; row.UserId = userId;
+ await _auth.ValidateHolderAsync(actor, row); row.Content = JsonConvert.SerializeObject(new InventoryLabel { Name = type.ToString() }); await SaveAsync(actor, row); return row;
+ }
+ public Task SaveLotAsync(InventoryActor actor, InventoryLot lot, InventoryLotContent details) => TransactionAsync(actor, async events =>
+ {
+ await _auth.RequireAsync(actor, true); if (lot == null || details == null) throw new InventoryException(400, "LotRequired"); Text(details.LotNumber);
+ var item = await GetAsync(actor, lot.ItemId);
+ if (item.IsDeleted || !item.IsActive || item.RequiresExpiration && !lot.ExpiresOn.HasValue || details.UnitCost < 0) throw new InventoryException(400, "InvalidLot");
+ foreach (var other in await _store.RelatedAsync(actor.DepartmentId, "ItemId", item.Id))
+ if (Decode(await RevealAsync(actor, other)).LotNumber == details.LotNumber) throw new InventoryException(409, "DuplicateLot");
+ var row = New(actor); row.ItemId = item.Id; row.ExpiresOn = lot.ExpiresOn?.ToUniversalTime(); row.ReceivedOn = Now; row.Content = JsonConvert.SerializeObject(details);
+ await SaveAsync(actor, row); await AuditAsync(actor, row, "InventoryLotCreated"); return await RevealAsync(actor, row);
+ });
+ public Task ArchiveAsync(InventoryActor actor, string id, int revision) where T : InventoryMutableRow => TransactionAsync(actor, async events =>
+ {
+ await _auth.RequireAsync(actor, true); var row = await GetAsync(actor, id); if (row.Revision != revision) throw new InventoryException(409, "RevisionConflict");
+ if (row is not (InventoryItem or InventoryCategory or InventoryLocation or InventoryKit)) throw new InventoryException(400, "ArchiveNotSupported");
+ if (row is InventoryItem item && ((await _store.RelatedAsync(actor.DepartmentId, "ItemId", id)).Any(x => x.Quantity != 0) || (await _store.RelatedAsync(actor.DepartmentId, "ItemId", id)).Any(x => !x.IsDeleted && x.Status is not (4 or 5 or 6)))) throw new InventoryException(409, "StockRemains");
+ if (row is InventoryLocation location && (location.IsDefault || (await _store.RelatedAsync(actor.DepartmentId, "LocationId", id)).Any(x => x.Quantity != 0) || (await _store.RelatedAsync(actor.DepartmentId, "CurrentLocationId", id)).Any() || (await _store.RelatedAsync(actor.DepartmentId, "ParentLocationId", id)).Any(x => !x.IsDeleted))) throw new InventoryException(409, "LocationInUse");
+ if (row is InventoryCategory && ((await _store.RelatedAsync(actor.DepartmentId, "CategoryId", id)).Any(x => !x.IsDeleted) || (await _store.RelatedAsync(actor.DepartmentId, "ParentCategoryId", id)).Any(x => !x.IsDeleted))) throw new InventoryException(409, "CategoryInUse");
+ row.IsDeleted = true; await SaveAsync(actor, row, false); await AuditAsync(actor, row, "InventoryArchived"); return true;
+ });
+ public Task RebuildStocksAsync(InventoryActor actor) => TransactionAsync(actor, async events => { await _auth.RequireAsync(actor, true); await _store.RebuildStocksAsync(actor.DepartmentId); return true; });
+ public async Task> GetByReferenceAsync(InventoryActor actor, InventoryReferenceType type, string id)
+ {
+ await _auth.RequireAsync(actor); var result = new System.Collections.Generic.List();
+ foreach (var row in (await _store.RelatedAsync(actor.DepartmentId, "ReferenceId", id)).Where(x => x.ReferenceType == (int)type)) { await AuthorizeRowAsync(actor, row); result.Add(await RevealAsync(actor, row)); } return result;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/InventoryChecklistAssets.cs b/Core/Resgrid.Services/InventoryChecklistAssets.cs
new file mode 100644
index 000000000..ca8ae281a
--- /dev/null
+++ b/Core/Resgrid.Services/InventoryChecklistAssets.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Resgrid.Model.Checklists;
+using Resgrid.Model.Inventories;
+
+namespace Resgrid.Services
+{
+ public sealed partial class InventoryModernizationService
+ {
+ public async Task IsAvailableAsync(int departmentId) => await _auth.IsEnabledAsync(departmentId) && await _store.HasLegacyMigrationAsync(departmentId);
+
+ public async Task> ListAsync(ChecklistActor actor)
+ {
+ var inventoryActor = ChecklistInventoryActor(actor); await RequireChecklistInventoryActorAsync(inventoryActor);
+ var result = new List();
+ if (!await IsAvailableAsync(actor.DepartmentId)) return result;
+ foreach (var asset in await AllAsync(actor.DepartmentId))
+ {
+ var target = await CurrentChecklistAssetAsync(inventoryActor, asset.Id, true);
+ if (target != null) result.Add(target);
+ }
+ return result;
+ }
+
+ public async Task GetAsync(ChecklistActor actor, string id)
+ {
+ var inventoryActor = ChecklistInventoryActor(actor); await RequireChecklistInventoryActorAsync(inventoryActor);
+ return await IsAvailableAsync(actor.DepartmentId) ? await CurrentChecklistAssetAsync(inventoryActor, id, true) : null;
+ }
+
+ public async Task RoutingAsync(int departmentId, string id)
+ {
+ if (!await IsAvailableAsync(departmentId)) return null;
+ return await CurrentChecklistAssetAsync(new InventoryActor { DepartmentId = departmentId }, id, false);
+ }
+
+ public async Task CanReceiveReminderAsync(int departmentId, string userId, string id)
+ {
+ var actor = new InventoryActor { DepartmentId = departmentId, UserId = userId };
+ try
+ {
+ await _auth.RequireAsync(actor);
+ if (!await IsAvailableAsync(departmentId)) return false;
+ var asset = await CurrentChecklistMetadataAsync(departmentId, id);
+ if (asset == null) return false;
+ var location = await EffectiveLocationAsync(departmentId, await _store.GetAsync(departmentId, asset.CurrentLocationId));
+ // This is authorization for a generic notice only: no grant, protected-read call or display content.
+ await _auth.RequireAsync(actor);
+ return !location.IsDeleted && await _auth.CanLocationAsync(actor, location);
+ }
+ catch (InventoryException) { return false; }
+ }
+
+ private async Task CurrentChecklistMetadataAsync(int departmentId, string id)
+ {
+ if (!Guid.TryParseExact(id, "D", out _)) return null;
+ var asset = await _store.GetAsync(departmentId, id);
+ if (asset?.DepartmentId != departmentId || asset.IsDeleted || !ChecklistAssetPresent(asset.Status) || asset.CurrentLocationId == null) return null;
+ var item = await _store.GetAsync(departmentId, asset.ItemId);
+ if (item?.DepartmentId != departmentId || item.IsDeleted || !item.IsActive || item.TrackingMode != (int)InventoryTrackingMode.Serialized) return null;
+ var location = await _store.GetAsync(departmentId, asset.CurrentLocationId);
+ return location?.DepartmentId == departmentId && !location.IsDeleted ? asset : null;
+ }
+
+ private async Task CurrentChecklistAssetAsync(InventoryActor actor, string id, bool attended)
+ {
+ try
+ {
+ var asset = await CurrentChecklistMetadataAsync(actor.DepartmentId, id); if (asset == null) return null;
+ var location = await EffectiveLocationAsync(actor.DepartmentId, await _store.GetAsync(actor.DepartmentId, asset.CurrentLocationId));
+ if (location.IsDeleted || attended && !await _auth.CanLocationAsync(actor, location)) return null;
+ var target = await ChecklistRoutingTargetAsync(actor.DepartmentId, asset.Id, location); if (target == null) return null;
+ if (attended)
+ {
+ var item = await _store.GetAsync(actor.DepartmentId, asset.ItemId);
+ var itemContent = Decode(await RevealAsync(actor, ChecklistReadCopy(item)));
+ var assetContent = Decode(await RevealAsync(actor, ChecklistReadCopy(asset)));
+ target.Name = ChecklistAssetLabel(itemContent.Name, assetContent.SerialNumber);
+ await _auth.RequireAsync(actor);
+ if (!await _auth.CanLocationAsync(actor, location)) return null;
+ }
+ return target;
+ }
+ catch (InventoryException ex) when (ex.StatusCode == 404 || ex.StatusCode == 409) { return null; }
+ catch (InventoryException ex) { throw new ChecklistException(ex.StatusCode, ex.Code); }
+ }
+
+ private async Task ChecklistRoutingTargetAsync(int departmentId, string assetId, InventoryLocation location)
+ {
+ if (location?.DepartmentId != departmentId) return null;
+ var target = new ChecklistAssetTarget { DepartmentId = departmentId, Id = assetId, UnitId = location.UnitId, GroupId = location.GroupId, UserId = location.UserId };
+ if (location.UnitId.HasValue)
+ {
+ var unit = await _units.GetUnitByIdAsync(location.UnitId.Value);
+ if (unit?.DepartmentId != departmentId) return null;
+ target.GroupId = unit.StationGroupId;
+ }
+ else if (location.UserId != null) target.GroupId = (await _groups.GetGroupForUserAsync(location.UserId, departmentId))?.DepartmentGroupId;
+ if (target.GroupId.HasValue && (await _groups.GetGroupByIdAsync(target.GroupId.Value, true))?.DepartmentId != departmentId) return null;
+ return target;
+ }
+
+ public async Task> AtCallAsync(ChecklistActor actor, int callId, DateTime callUtc, IReadOnlyCollection unitIds, bool contractorEquipment)
+ {
+ var inventoryActor = ChecklistInventoryActor(actor); await RequireChecklistInventoryActorAsync(inventoryActor);
+ if (contractorEquipment || !await _store.HasLegacyMigrationAsync(actor.DepartmentId)) return null;
+ if (callId <= 0 || unitIds == null || unitIds.Any(id => id <= 0)) throw new ChecklistException(400, "ReadinessCallUnavailable");
+ // Historical reads remain available after module suspension. The caller authorizes the call and dispatch list.
+ var units = unitIds.ToHashSet(); var result = new List(); if (units.Count == 0) return result;
+ var assets = (await AllAsync(actor.DepartmentId)).Where(a => a.DepartmentId == actor.DepartmentId).ToDictionary(a => a.Id, StringComparer.Ordinal);
+ var locations = (await AllAsync(actor.DepartmentId)).Where(l => l.DepartmentId == actor.DepartmentId).ToDictionary(l => l.Id, StringComparer.Ordinal);
+ var ledger = (await AllAsync(actor.DepartmentId)).Where(t => t.DepartmentId == actor.DepartmentId && t.AssetId != null)
+ .OrderBy(t => t.OccurredOn).ThenBy(t => t.EntryId).ToList();
+ var histories = ledger.GroupBy(t => t.AssetId).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
+ foreach (var asset in assets.Values)
+ {
+ var state = HistoricalChecklistPosition(asset.Id, callUtc, long.MaxValue, assets, locations, histories);
+ if (state?.Location?.UnitId == null || !units.Contains(state.Location.UnitId.Value)) continue;
+ if (!await _auth.CanLocationAsync(inventoryActor, state.Location)) continue;
+ var unit = await _units.GetUnitByIdAsync(state.Location.UnitId.Value); if (unit?.DepartmentId != actor.DepartmentId) continue;
+ string name;
+ try
+ {
+ // Use the asset's own immutable label snapshot, even when its holder provenance belongs to a moving bag.
+ var source = await RevealAsync(inventoryActor, ChecklistReadCopy(state.AssetSource));
+ var content = string.IsNullOrEmpty(source.Content) ? new JObject() : JObject.Parse(source.Content);
+ name = ChecklistAssetLabel(content["ItemName"]?.Type == JTokenType.String ? content["ItemName"].Value() : null,
+ content["SerialNumber"]?.Type == JTokenType.String ? content["SerialNumber"].Value() : null);
+ }
+ catch (InventoryException ex) { throw new ChecklistException(ex.StatusCode, ex.Code); }
+ var next = HistoricalChecklistDeparture(asset.Id, callUtc, state, ledger, assets, locations, histories);
+ await RequireChecklistInventoryActorAsync(inventoryActor);
+ // The unit's current station/resource policy authorizes this historical read; it is not historical ownership evidence.
+ if (!await _auth.CanLocationAsync(inventoryActor, state.Location)) continue;
+ result.Add(new ReadinessAssetSnapshot { DepartmentId = actor.DepartmentId, AssetId = asset.Id, UnitId = state.Location.UnitId,
+ SourceSubsystem = "Inventory", SourceId = state.HolderSource.Id, SourceVersion = state.HolderSource.EntryId.ToString(CultureInfo.InvariantCulture),
+ IssuedUtc = DateTime.SpecifyKind(state.HolderSource.OccurredOn, DateTimeKind.Utc), ReturnedUtc = next, Name = name });
+ if (result.Count > 1000) throw new ChecklistException(400, "ReportTooLarge");
+ }
+ return result;
+ }
+
+ private sealed class HistoricalChecklistAssetPosition
+ {
+ public InventoryLocation Location { get; set; }
+ public InventoryTransaction HolderSource { get; set; }
+ public InventoryTransaction AssetSource { get; set; }
+ public HashSet AssetChain { get; set; }
+ }
+ private static HistoricalChecklistAssetPosition HistoricalChecklistPosition(string assetId, DateTime at, long throughEntry,
+ Dictionary assets, Dictionary locations, Dictionary> histories, HashSet visited = null)
+ {
+ visited ??= new HashSet(StringComparer.Ordinal);
+ if (!visited.Add("asset:" + assetId) || visited.Count > 64 || !assets.TryGetValue(assetId, out var asset) || !histories.TryGetValue(assetId, out var history)) return null;
+ var entries = history.Where(t => t.ItemId == asset.ItemId && t.EntryId > 0 && Guid.TryParseExact(t.Id, "D", out _)
+ && (t.OccurredOn < at || t.OccurredOn == at && t.EntryId <= throughEntry)).ToList();
+ var source = entries.LastOrDefault(); var status = entries.LastOrDefault(t => t.NewStatus.HasValue)?.NewStatus;
+ var movement = entries.LastOrDefault(IsChecklistLocationMovement);
+ if (source == null || !status.HasValue || !ChecklistAssetPresent(status.Value) || movement?.ToLocationId == null) return null;
+ var result = new HistoricalChecklistAssetPosition { AssetSource = source, HolderSource = movement, AssetChain = new HashSet(StringComparer.Ordinal) { assetId } };
+ var locationId = movement.ToLocationId;
+ while (locationId != null)
+ {
+ if (!visited.Add("location:" + locationId) || visited.Count > 64 || !locations.TryGetValue(locationId, out var location) || location.CreatedOn > at) return null;
+ if (location.ContainerAssetId != null)
+ {
+ var parent = HistoricalChecklistPosition(location.ContainerAssetId, at, throughEntry, assets, locations, histories, visited);
+ if (parent?.Location == null) return null;
+ result.Location = parent.Location; result.AssetChain.UnionWith(parent.AssetChain);
+ if (parent.HolderSource.OccurredOn > result.HolderSource.OccurredOn || parent.HolderSource.OccurredOn == result.HolderSource.OccurredOn && parent.HolderSource.EntryId > result.HolderSource.EntryId)
+ result.HolderSource = parent.HolderSource;
+ return result;
+ }
+ if (location.ParentLocationId == null) { result.Location = location; return result; }
+ locationId = location.ParentLocationId;
+ }
+ return null;
+ }
+ private static DateTime? HistoricalChecklistDeparture(string assetId, DateTime callUtc, HistoricalChecklistAssetPosition initial, List ledger,
+ Dictionary assets, Dictionary locations, Dictionary> histories)
+ {
+ var state = initial;
+ foreach (var movement in ledger.Where(t => t.OccurredOn > callUtc && (IsChecklistLocationMovement(t) || t.NewStatus.HasValue && !ChecklistAssetPresent(t.NewStatus.Value))))
+ {
+ if (!state.AssetChain.Contains(movement.AssetId)) continue;
+ var next = HistoricalChecklistPosition(assetId, movement.OccurredOn, movement.EntryId, assets, locations, histories);
+ if (next == null || next.Location?.UnitId != initial.Location.UnitId) return DateTime.SpecifyKind(movement.OccurredOn, DateTimeKind.Utc);
+ state = next;
+ }
+ return null;
+ }
+ private static bool IsChecklistLocationMovement(InventoryTransaction transaction) => transaction.TransactionType != (int)InventoryTransactionType.StatusChange && transaction.FromLocationId != transaction.ToLocationId;
+ private static bool ChecklistAssetPresent(int status) => status >= (int)InventoryAssetStatus.InService && status <= (int)InventoryAssetStatus.Damaged;
+ private static string ChecklistAssetLabel(string itemName, string serial) => string.IsNullOrWhiteSpace(itemName) ? "Equipment" : string.IsNullOrWhiteSpace(serial) ? itemName : itemName + " — " + serial;
+ private static T ChecklistReadCopy(T row) where T : InventoryRow => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(row));
+ private static InventoryActor ChecklistInventoryActor(ChecklistActor actor) => actor == null ? throw new ChecklistException(403, "MembershipRequired")
+ : new InventoryActor { DepartmentId = actor.DepartmentId, UserId = actor.UserId, GrantToken = actor.GrantToken };
+ private async Task RequireChecklistInventoryActorAsync(InventoryActor actor)
+ {
+ try { await _auth.RequireAsync(actor); } catch (InventoryException ex) { throw new ChecklistException(ex.StatusCode, ex.Code); }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/InventoryGdprExport.cs b/Core/Resgrid.Services/InventoryGdprExport.cs
new file mode 100644
index 000000000..8d3327eb1
--- /dev/null
+++ b/Core/Resgrid.Services/InventoryGdprExport.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Inventories;
+using Resgrid.Model.Repositories;
+namespace Resgrid.Services
+{
+ public partial class GdprDataExportService
+ {
+ private readonly IInventoryStore _inventoryStore;
+ private async Task